diff --git a/.github/dependabot.yml b/.github/dependabot.yml index eab8cbf..fb50b3c 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -10,3 +10,13 @@ updates: applies-to: security-updates patterns: - "*" + - package-ecosystem: npm + directory: /msp-onboarding/frontend + schedule: + interval: weekly + open-pull-requests-limit: 0 + groups: + security-updates: + applies-to: security-updates + patterns: + - "*" diff --git a/README.md b/README.md index 641a3c7..d68e75b 100644 --- a/README.md +++ b/README.md @@ -78,6 +78,12 @@ This script automates the execution of predefined troubleshooting show commands ### [MSP Control Tower](/msp-tenant-monitoring/) This workflow monitors every tenant an MSP manages using a single MSP credential. It discovers managed tenants, then collects sites, devices, clients, and alerts from each one. It ships in two forms: a **web dashboard** for interactive exploration and a **Python CLI** (`main.py`) for scripted export. +### [MSP Onboarding](/msp-onboarding/) +This workflow automates common MSP onboarding tasks with MSP API credentials: +- **Create tenants**: Create new MSP tenants and provision a Central service in each. +- **Assign devices to tenants**: Assign MSP-owned devices and subscriptions to existing tenants. +Both workflows support guided web and Python CLI execution, with individual entry or bulk CSV upload options. + ## HPE Greenlake Platform Workflows ### [Onboarding](/glp-device-onboarding/) diff --git a/msp-onboarding/README.md b/msp-onboarding/README.md new file mode 100644 index 0000000..0798670 --- /dev/null +++ b/msp-onboarding/README.md @@ -0,0 +1,214 @@ +# MSP onboarding workflow + +Automate the two most common MSP onboarding workflows using MSP API Credentials: + +- **Create tenants**: Create new MSP tenants and provision a Central service in each. +- **Assign devices to tenants**: Assign MSP-owned devices in an existing tenant's Central application and attach the subscription each device needs. + +Devices and tenants details can be entered individually or uploaded in bulk via CSVs. It ships in two forms: a **guided web workflow** and a **Python CLI** (`onboarding.py`) for scripted, manifest-driven runs. + +> [!NOTE] +> This is a proof of concept for getting started with the MSP onboarding APIs in GreenLake and Central. It is not optimized for large-scale production use. Use it as a reference for your own MSP integrations. + + + +## Features + +- **Create tenants**: create one or more new MSP tenants and provision a Central service in each, in the region you choose. +- **Assign devices to tenants**: for each MSP-owned device, pick the tenant, the Central application within it, and the subscription to attach, the workflow submits the device assignment and the seat assignment together. +- **CSV upload**: bulk-load tenants to create, or devices with their tenant and subscription mapping, from a CSV file. Rows are validated and errors are reported per row before anything is submitted. +- **Bulk seat assignment**: apply one subscription key to every eligible device of a type, with the key's expiry shown first. +- **Demo mode**: a deterministic catalog for exploring both journeys without credentials. + +The flow has three stages: discover tenants and services at the MSP level, exchange into a tenant to provision a service, then assign devices and seats from the MSP workspace. It maps directly onto the pycentral `MSPBase` feature: + + + +**Legend** — 🟩 green: MSP context (MSP credential) · 🟦 blue: tenant context (token exchanged into the tenant). + +A copy-pasteable sketch of the same flow: + +```python +from pycentral import MSPBase + +msp = MSPBase(client_id=..., client_secret=..., workspace_id=MSP_WORKSPACE_ID) + +# 1. Discover tenants at the MSP level +tenants = msp.command("GET", "workspaces/v1/msp-tenants", app_name="glp") + +# 2. Exchange into one tenant and provision a Central service there +tenant = msp.get_tenant_connection(tenant_workspace_id=TENANT_ID_32_CHARS) +tenant.command( + "POST", "service-catalog/v1/service-manager-provisions", app_name="glp", + data={"serviceManagerId": SERVICE_MANAGER_ID, "region": REGION}, +) + +# 3. Assign devices to that tenant and service (MSP-scoped, batched at five) +msp.command( + "PATCH", "devices/v1/devices", app_name="glp", params={"id": DEVICE_IDS}, + data={ + "application": {"id": SERVICE_MANAGER_ID}, + "region": REGION, + "tenantPlatformCustomerId": TENANT_ID_32_CHARS, + }, +) + +# 4. Assign a subscription to the same devices +msp.command( + "PATCH", "devices/v1/devices", app_name="glp", params={"id": DEVICE_IDS}, + data={"subscription": [{"id": SUBSCRIPTION_ID}]}, +) +``` + +## API Calls + +All calls go to the GreenLake Platform (GLP) API. Read calls run during discovery and preflight; write calls run only after the operator confirms. Both tracks start by listing tenants with the MSP credential. + +### Create tenants + +| Step | Service | Method | Endpoint | Description | +|------|---------|--------|----------|-------------| +| 1 | GLP | `GET` | `workspaces/v1/msp-tenants` | Lists managed tenants (exact-name check before creating) | +| 2 | GLP | `GET` | `service-catalog/v1/service-managers` | Lists Central services available to provision | +| 3 | GLP | `GET` | `service-catalog/v1/per-region-service-managers` | Lists regions each service can be provisioned in | +| 4 | GLP | `POST` | `workspaces/v1/msp-tenants` | Creates the tenant | +| 5 | GLP | `POST` | `{base_url}/{workspace_id}/token` | Token exchange (MSP token → tenant-scoped token) | +| 6 | GLP | `POST` | `service-catalog/v1/service-manager-provisions` | Provisions the Central service in the new tenant | + +### Assign devices to tenants + +| Step | Service | Method | Endpoint | Description | +|------|---------|--------|----------|-------------| +| 1 | GLP | `GET` | `workspaces/v1/msp-tenants` | Lists managed tenants | +| 2 | GLP | `POST` | `{base_url}/{workspace_id}/token` | Token exchange (MSP token → tenant-scoped token) | +| 3 | GLP | `GET` | `service-catalog/v1/service-manager-provisions` | Central applications provisioned in the tenant | +| 4 | GLP | `GET` | `devices/v1/devices` | MSP-owned device inventory (by serial, MAC, or ID) | +| 5 | GLP | `GET` | `subscriptions/v1/subscriptions` | Subscription keys, capacity, and expiry | +| 6 | GLP | `PATCH` | `devices/v1/devices` | Assigns devices to the tenant's Central application (batches of five) | +| 7 | GLP | `PATCH` | `devices/v1/devices` | Assigns the subscription to those devices (batches of five) | +| 8 | GLP | `GET` | `devices/v1/async-operations/{transaction_id}` | Polls each write to completion (up to 2 minutes) | + +## Prerequisites + +- [uv](https://docs.astral.sh/uv/getting-started/installation/) — the workflow requires Python 3.10+, which uv installs on demand +- An HPE GreenLake **MSP** workspace with an API client credential + +## Installation + +1. Clone the repository and enter the workflow: + + ```bash + git clone https://github.com/aruba/central-python-workflows.git + cd central-python-workflows/msp-onboarding + ``` + +2. Create the environment and install the dependencies: + + ```bash + uv venv --python 3.12 + source .venv/bin/activate + uv pip install -r requirements.txt + ``` + +## Configuration + +### Credentials + +The sign-in screen asks for three values: + +- **Client ID** and **Client secret** — a GreenLake API client credential created at the MSP workspace level +- **Workspace ID** — the ID of the **MSP** workspace itself, not a tenant's + +> [!TIP] +> The MSP token exchange guide covers [how to create an API credential](https://developer.arubanetworks.com/new-central/docs/msp-token-exchange#how-to-create-an-api-credential) and [finding your MSP workspace ID](https://developer.arubanetworks.com/new-central/docs/msp-token-exchange#finding-your-msp-workspace-id). + +The CLI reads the same values from a `token.yaml` in the current directory (same unified GLP format as [`msp-tenant-monitoring/token.yaml.example`](../msp-tenant-monitoring/token.yaml.example)). + +### Demo Mode (No Credentials) + +Choose **Use demo mode** on the sign-in screen for a deterministic catalog — two tenants, eligible and ineligible services, and subscriptions covering valid, insufficient-capacity, and expired cases. Scenarios select the failure the run exercises: + +| Scenario | Exercises | +|----------|-----------| +| `success` | Every write completes | +| `partial-device-write` | One device in a batch fails | +| `ambiguous-write` | An ambiguous response is re-observed inline before retrying | +| `bulk-success` / `bulk-partial` | Multi-tenant runs, all or partially successful | +| `tenant-name-conflict` | A new tenant's name already exists | +| `tenant-creation-systemic` | Tenant creation fails for every tenant | + +## Execution + +```bash +uv run python server.py +# open http://127.0.0.1:8000/ +``` + +A run walks through: + +1. **Sign in** — live credentials, or demo mode +2. **Choose a journey** — onboard new tenants, or add devices to existing tenants +3. **Setup, Devices, Review** — pick tenants and services, map every device to a tenant and subscription key, then review the read-only preflight +4. **Confirm once** — the job starts, and the Review screen switches to live per-device and per-tenant results +5. **Stop safely if needed** — the in-flight batch finishes, the rest is skipped, nothing is rolled back + +> [!CAUTION] +> Outside demo mode, a confirmed run performs real writes against real tenants. The read-only preflight and the single confirmation gate exist for that reason — review the preflight before confirming. + +Drafts are stored only in the browser and restore an in-progress journey after a refresh; **Discard draft** removes the local draft. + +### Command Line Options + +| Flag | Description | Default | +|------|-------------|---------| +| `--port` | Port to serve on | `8000` | +| `--host` | Interface to bind | `127.0.0.1` | + +The same workflow runs from the terminal via `onboarding.py`, driven by a YAML manifest: + +```bash +uv run python onboarding.py --demo list tenants # also: services, devices, subscriptions +uv run python onboarding.py --demo plan samples/new_tenant.yaml +uv run python onboarding.py --demo run JOB_ID --yes # also: resume JOB_ID +``` + +Drop `--demo` for live runs. Sample manifests live in [`samples/`](samples/). + +## Output + +### On-Screen Output + +The web workflow surfaces the plan and the run in one place: + +- **Setup stage**: tenant picker with per-tenant service and region discovery; tenants without an eligible service are set aside with the reason shown. +- **Devices stage**: dense inventory table with CSV import, per-device or bulk subscription mapping, and seat capacity and expiry checks. +- **Preflight review**: every tenant, device, and seat assignment with its validation result, plus an impact ledger of what will and will not run. +- **Live run**: the same review switches to per-device status chips (Writing, Complete, Already satisfied, Failed) and per-tenant progress as the job runs. + + + + + + +The **CLI** prints the same plan and per-step results as tables, and masks subscription keys in `list subscriptions`. + +### Report Files + +- `GET /api/jobs/{id}/manifest`: exports the confirmed job as a YAML manifest (including subscription keys) that the CLI can `plan` and `run` again +- CLI `plan` and `run` write no files; runs are session-only and are re-run from the manifest after a server restart + +## Troubleshooting + +| Problem | Fix | +|---------|-----| +| **Sign-in fails** | Check the client ID, client secret, and that the workspace ID is the MSP workspace's, not a tenant's | +| **Port 8000 already in use** | Start the server with `--port`, e.g. `uv run python server.py --port 8001` | +| **A job disappears after a server restart** | Runs are session-only by design; re-run the manifest, whose pre-write validation absorbs completed work as already satisfied | +| **A step shows "Already satisfied" instead of "Complete"** | The write was re-observed as already in the desired state — usually because a previous run had applied it, or an async operation finished after polling ended | +| **UI returns 503** | The `static/` build is missing from your checkout; restore it from the repository (it is committed) | + +## Support + +- **Automation Team**: [aruba-automation@hpe.com](mailto:aruba-automation@hpe.com) +- **Workflow Issues**: [GitHub Issues](https://github.com/aruba/central-python-workflows/issues) +- **PyCentral Library**: [PyCentral Issues](https://github.com/aruba/pycentral/issues) diff --git a/msp-onboarding/docs/onboarding-flow.mmd b/msp-onboarding/docs/onboarding-flow.mmd new file mode 100644 index 0000000..78a674c --- /dev/null +++ b/msp-onboarding/docs/onboarding-flow.mmd @@ -0,0 +1,30 @@ +%%{init: {"theme": "dark", "themeVariables": {"background": "#0f172a", "fontFamily": "trebuchet ms, verdana, arial, sans-serif"}, "flowchart": {"wrappingWidth": 400, "nodeSpacing": 60, "rankSpacing": 80, "padding": 16}}}%% +flowchart LR + creds["MSP API Credentials"] + + subgraph ad["Assign devices to tenants"] + direction LR + b1["Select devices & subscription"] + b2[" Assign devices "] + b3[" Assign subscription "] + end + + subgraph ct["Create tenants"] + direction LR + a1[" Create tenant "] + a2[" Provision Central service "] + end + + creds -->|Authenticate| b1 + creds -->|Authenticate| a1 + a1 -->|" Token exchange "| a2 + b1 --> b2 --> b3 + + classDef msp fill:#0f2a1c,stroke:#22c55e,color:#86efac + classDef tenant fill:#0c2540,stroke:#3b82f6,color:#93c5fd + classDef root fill:#1e293b,stroke:#60a5fa,color:#bfdbfe + class a1,b1,b2,b3 msp + class a2 tenant + class creds root + style ct fill:#111827,stroke:#475569,color:#e2e8f0 + style ad fill:#111827,stroke:#475569,color:#e2e8f0 diff --git a/msp-onboarding/docs/onboarding-flow.svg b/msp-onboarding/docs/onboarding-flow.svg new file mode 100644 index 0000000..a63ad89 --- /dev/null +++ b/msp-onboarding/docs/onboarding-flow.svg @@ -0,0 +1 @@ +Create tenantsAssign devices to tenantsAuthenticateAuthenticate⇄ Token exchangeMSP API CredentialsSelect devices & subscription Assign devices Assign subscription Create tenant Provision Central service \ No newline at end of file diff --git a/msp-onboarding/msp_onboarding/__init__.py b/msp-onboarding/msp_onboarding/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/msp-onboarding/msp_onboarding/adapter.py b/msp-onboarding/msp_onboarding/adapter.py new file mode 100644 index 0000000..54e56dd --- /dev/null +++ b/msp-onboarding/msp_onboarding/adapter.py @@ -0,0 +1,118 @@ +"""Adapter protocol and error type for MSP onboarding Phase 1.""" +from __future__ import annotations + +from datetime import datetime +from typing import Literal, Optional, Protocol + +from .models import ( + AddressNew, + DeviceInfo, + ServiceInfo, + SubscriptionInfo, + TenantInfo, + TransactionResult, +) + + +# ponytail: 40/minute is an operator decision taken 2026-08-10, not an observed +# or published HPE limit; it is strictly better than today's unlimited effective +# rate. +WRITE_LIMITS = { + "v1": {"batch": 5, "requests_per_minute": 40}, +} + +WORKSPACE_NAME_CONFLICT_MESSAGE = ( + "This workspace name is already in use globally across HPE GreenLake. " + "Choose a more distinctive name and try again." +) + + +def write_batch_size() -> int: + return WRITE_LIMITS["v1"]["batch"] + + +def write_requests_per_minute() -> int: + return WRITE_LIMITS["v1"]["requests_per_minute"] + + +def write_endpoint_path() -> str: + return "devices/v1/devices" + + +class AdapterError(Exception): + def __init__( + self, + path: str, + code: str, + message: str, + transaction_id: Optional[str] = None, + retryable: bool = False, + retry_after: Optional[float] = None, + failure_scope: Literal["tenant", "systemic"] = "systemic", + ) -> None: + self.path = path + self.code = code + self.message = message + self.transaction_id = transaction_id + self.retryable = retryable + self.retry_after = retry_after + self.failure_scope = failure_scope + super().__init__(message) + + +class AdapterProtocol(Protocol): + def now(self) -> datetime: ... + def list_tenants(self) -> list[TenantInfo]: ... + def fresh_tenant_listing(self) -> list[TenantInfo]: ... + def resolve_tenant(self, workspace_id: str) -> TenantInfo: ... + def find_tenant_by_name(self, name: str) -> Optional[TenantInfo]: ... + def ensure_tenant( + self, + mode: str, + workspace_id: Optional[str], + workspace_name: str, + *, + country: str = "", + description: str = "", + email: str = "", + phone_number: str = "", + address: Optional[AddressNew] = None, + known_tenants: Optional[list[TenantInfo]] = None, + ) -> TenantInfo: ... + def list_eligible_services(self, workspace_id: Optional[str]) -> list[ServiceInfo]: ... + def services_for_tenants( + self, tenant_ids: list[str] + ) -> dict[str, list[ServiceInfo]]: ... + def submit_service_provisioning( + self, workspace_id: str, service_manager_id: str, region: str + ) -> None: ... + def observe_service_provisioning( + self, workspace_id: str, service_manager_id: str, region: str + ) -> str: ... + def resolve_devices( + self, + *, + serials: Optional[list[str]] = None, + glp_ids: Optional[list[str]] = None, + ) -> list[DeviceInfo]: ... + def resolve_device_by_serial(self, serial: str) -> DeviceInfo: ... + def resolve_device_by_mac(self, mac: str) -> DeviceInfo: ... + def resolve_device(self, glp_id: str) -> DeviceInfo: ... + def list_available_devices(self) -> list[DeviceInfo]: ... + def resolve_subscription(self, key: str) -> SubscriptionInfo: ... + def list_subscriptions(self) -> list[SubscriptionInfo]: ... + def assign_devices( + self, + device_ids: list[str], + tenant_workspace_id: str, + service_manager_id: str, + region: str, + ) -> str: ... + def assign_subscriptions( + self, + assignments: list[tuple[str, str]], + ) -> str: ... + def transaction_origin(self, transaction_id: str) -> Optional[str]: ... + def poll_transaction( + self, transaction_id: str, origin: Optional[str] = None + ) -> TransactionResult: ... diff --git a/msp-onboarding/msp_onboarding/demo_adapter.py b/msp-onboarding/msp_onboarding/demo_adapter.py new file mode 100644 index 0000000..70287c3 --- /dev/null +++ b/msp-onboarding/msp_onboarding/demo_adapter.py @@ -0,0 +1,680 @@ +"""DemoAdapter — deterministic, credential-free adapter backed by a static catalog. + +Catalog contents (per design spec): + • Two existing tenants: T1 (target) and T2 (owner of an unavailable device). + • Four fixed-name tenant identities available for bulk creation. + • Provisioned Central services for T1, Demo North, and the new-tenant context. + • Nine devices: D1-D3 and D5-D9 available; D4 assigned to T2. + • Six subscriptions: + KEY_A – valid, availableQuantity=10 + KEY_B – valid, availableQuantity=5 + KEY_C – insufficient capacity (availableQuantity=0) + KEY_D – expired (end_date before fixed clock) + KEY_E – ineligible productType (SOFTWARE) + KEY_SHARED – valid, availableQuantity=4 for aggregate bulk demand + +Fixed clock: 2025-01-15T12:00:00Z +""" +from __future__ import annotations + +from dataclasses import replace +from datetime import datetime, timedelta, timezone +from threading import Lock +from typing import Optional + +from .adapter import ( + AdapterError, + WORKSPACE_NAME_CONFLICT_MESSAGE, + write_batch_size, +) +from .models import ( + AddressNew, + DeviceInfo, + ServiceInfo, + SubscriptionInfo, + TenantInfo, + TransactionResult, +) + +# --------------------------------------------------------------------------- +# Fixed catalog IDs +# --------------------------------------------------------------------------- + +TENANT_T1_ID = "aaaaaaaa-0001-0001-0001-000000000001" +TENANT_T2_ID = "aaaaaaaa-0002-0002-0002-000000000002" +TENANT_NEW_ID = "aaaaaaaa-0003-0003-0003-000000000003" +TENANT_NORTH_ID = "aaaaaaaa-0004-0004-0004-000000000004" +TENANT_SOUTH_ID = "aaaaaaaa-0005-0005-0005-000000000005" +TENANT_EUROPE_ID = "aaaaaaaa-0006-0006-0006-000000000006" +TENANT_CLEAN_ID = "aaaaaaaa-0007-0007-0007-000000000007" + +SERVICE_S1_ID = "bbbbbbbb-0001-0001-0001-000000000001" +SERVICE_S2_ID = "bbbbbbbb-0002-0002-0002-000000000002" + +DEVICE_D1_ID = "cccccccc-0001-0001-0001-000000000001" +DEVICE_D2_ID = "cccccccc-0002-0002-0002-000000000002" +DEVICE_D3_ID = "cccccccc-0003-0003-0003-000000000003" +DEVICE_D4_ID = "cccccccc-0004-0004-0004-000000000004" +DEVICE_D5_ID = "cccccccc-0005-0005-0005-000000000005" +DEVICE_D6_ID = "cccccccc-0006-0006-0006-000000000006" +DEVICE_D7_ID = "cccccccc-0007-0007-0007-000000000007" +DEVICE_D8_ID = "cccccccc-0008-0008-0008-000000000008" +DEVICE_D9_ID = "cccccccc-0009-0009-0009-000000000009" + +SUB_KEY_A = "KEY_A" +SUB_KEY_B = "KEY_B" +SUB_KEY_C = "KEY_C" +SUB_KEY_D = "KEY_D" +SUB_KEY_E = "KEY_E" +SUB_KEY_SHARED = "KEY_SHARED" + +SUB_A_ID = "dddddddd-0001-0001-0001-000000000001" +SUB_B_ID = "dddddddd-0002-0002-0002-000000000002" +SUB_C_ID = "dddddddd-0003-0003-0003-000000000003" +SUB_D_ID = "dddddddd-0004-0004-0004-000000000004" +SUB_E_ID = "dddddddd-0005-0005-0005-000000000005" +SUB_SHARED_ID = "dddddddd-0006-0006-0006-000000000006" + +FIXED_CLOCK = datetime(2025, 1, 15, 12, 0, 0, tzinfo=timezone.utc) + + +class _DemoEngineClock: + def __init__(self) -> None: + self._lock = Lock() + self._value = 0.0 + + def now(self) -> float: + with self._lock: + return self._value + + def sleep(self, seconds: float) -> None: + with self._lock: + self._value += seconds + + def utcnow(self) -> datetime: + with self._lock: + return FIXED_CLOCK + timedelta(seconds=self._value) + +SUPPORTED_SCENARIOS = ( + "success", + "partial-device-write", + "ambiguous-write", + "bulk-success", + "bulk-partial", + "tenant-name-conflict", + "tenant-creation-systemic", +) + +# --------------------------------------------------------------------------- +# Static catalog (single source of truth; never mutated at runtime) +# --------------------------------------------------------------------------- + +CATALOG: dict = { + "tenants": { + TENANT_T1_ID: TenantInfo( + workspace_id=TENANT_T1_ID, + workspace_name="Acme Corp", + ownership="MSP_OWNED_INVENTORY", + ), + TENANT_T2_ID: TenantInfo( + workspace_id=TENANT_T2_ID, + workspace_name="Beta LLC", + ownership="MSP_OWNED_INVENTORY", + ), + }, + "creatable_tenants": { + "Demo North Tenant": TenantInfo( + workspace_id=TENANT_NORTH_ID, + workspace_name="Demo North Tenant", + ownership="MSP_OWNED_INVENTORY", + ), + "Demo South Tenant": TenantInfo( + workspace_id=TENANT_SOUTH_ID, + workspace_name="Demo South Tenant", + ownership="MSP_OWNED_INVENTORY", + ), + "Demo Europe Tenant": TenantInfo( + workspace_id=TENANT_EUROPE_ID, + workspace_name="Demo Europe Tenant", + ownership="MSP_OWNED_INVENTORY", + ), + "Demo Clean Tenant": TenantInfo( + workspace_id=TENANT_CLEAN_ID, + workspace_name="Demo Clean Tenant", + ownership="MSP_OWNED_INVENTORY", + ), + }, + # None key → new-tenant context. Two eligible applications at us-west + # (Central and Central Internal), mirroring live, so the service must be + # chosen (picker or CSV) rather than auto-selected. + # Clean is intentionally absent: successful lookup with no provisioned service. + "services": { + None: [ + ServiceInfo( + service_manager_id=SERVICE_S1_ID, + region="us-west", + name="HPE Aruba Networking Central", + region_display_name="US West", + ), + ServiceInfo( + service_manager_id=SERVICE_S2_ID, + region="us-west", + name="HPE Aruba Networking Central Internal", + region_display_name="US West", + ), + ], + TENANT_T1_ID: [ + ServiceInfo( + service_manager_id=SERVICE_S1_ID, + region="us-west", + name="HPE Aruba Networking Central", + region_display_name="US West", + ), + ], + TENANT_NORTH_ID: [ + ServiceInfo( + service_manager_id=SERVICE_S1_ID, + region="us-west", + name="HPE Aruba Networking Central", + region_display_name="US West", + ), + ServiceInfo( + service_manager_id=SERVICE_S1_ID, + region="eu-central", + name="HPE Aruba Networking Central", + region_display_name="EU Central", + ), + ], + }, + "devices_by_serial": { + "CNXA001": DeviceInfo( + glp_id=DEVICE_D1_ID, serial_number="CNXA001", mac_address="aa:bb:cc:00:00:01", + management="MSP", assigned_state="UNASSIGNED", device_type="", + ), + "CNXA002": DeviceInfo( + glp_id=DEVICE_D2_ID, serial_number="CNXA002", mac_address="aa:bb:cc:00:00:02", + management="MSP", assigned_state="UNASSIGNED", device_type="", + ), + "CNXA003": DeviceInfo( + glp_id=DEVICE_D3_ID, serial_number="CNXA003", mac_address="aa:bb:cc:00:00:03", + management="MSP", assigned_state="UNASSIGNED", device_type="", + ), + "CNXA004": DeviceInfo( + glp_id=DEVICE_D4_ID, serial_number="CNXA004", mac_address="aa:bb:cc:00:00:04", + management="MSP", assigned_state="ASSIGNED", device_type="GW", + in_use_workspace=TENANT_T2_ID, + ), + "CNXA005": DeviceInfo( + glp_id=DEVICE_D5_ID, serial_number="CNXA005", mac_address="aa:bb:cc:00:00:05", + management="MSP", assigned_state="UNASSIGNED", device_type="AP", + ), + "CNXA006": DeviceInfo( + glp_id=DEVICE_D6_ID, serial_number="CNXA006", mac_address="aa:bb:cc:00:00:06", + management="MSP", assigned_state="UNASSIGNED", device_type="AP", + ), + "CNXA007": DeviceInfo( + glp_id=DEVICE_D7_ID, serial_number="CNXA007", mac_address="aa:bb:cc:00:00:07", + management="MSP", assigned_state="UNASSIGNED", device_type="SWITCH", + ), + "CNXA008": DeviceInfo( + glp_id=DEVICE_D8_ID, serial_number="CNXA008", mac_address="aa:bb:cc:00:00:08", + management="MSP", assigned_state="UNASSIGNED", device_type="SWITCH", + ), + "CNXA009": DeviceInfo( + glp_id=DEVICE_D9_ID, serial_number="CNXA009", mac_address="aa:bb:cc:00:00:09", + management="MSP", assigned_state="UNASSIGNED", device_type="SWITCH", + ), + }, + # MAC → serial (look up by serial to get full DeviceInfo) + "devices_by_mac": { + "aa:bb:cc:00:00:01": "CNXA001", + "aa:bb:cc:00:00:02": "CNXA002", + "aa:bb:cc:00:00:03": "CNXA003", + "aa:bb:cc:00:00:04": "CNXA004", + "aa:bb:cc:00:00:05": "CNXA005", + "aa:bb:cc:00:00:06": "CNXA006", + "aa:bb:cc:00:00:07": "CNXA007", + "aa:bb:cc:00:00:08": "CNXA008", + "aa:bb:cc:00:00:09": "CNXA009", + }, + "subscriptions": { + SUB_KEY_A: SubscriptionInfo( + subscription_id=SUB_A_ID, key=SUB_KEY_A, + status="STARTED", product_type="DEVICE", + available_quantity="10", quantity="10", + start_date="2024-01-01", end_date="2027-12-31", + subscription_type="CENTRAL_AP", tier_description="Foundation AP", + ), + SUB_KEY_B: SubscriptionInfo( + subscription_id=SUB_B_ID, key=SUB_KEY_B, + status="STARTED", product_type="DEVICE", + available_quantity="5", quantity="5", + start_date="2024-01-01", end_date="2027-12-31", + subscription_type="CENTRAL_SWITCH", tier_description="Foundation-Switch-Class-3", + ), + SUB_KEY_C: SubscriptionInfo( + subscription_id=SUB_C_ID, key=SUB_KEY_C, + status="STARTED", product_type="DEVICE", + available_quantity="0", quantity="5", + start_date="2024-01-01", end_date="2027-12-31", + subscription_type="CENTRAL_AP", tier_description="Foundation AP", + ), + SUB_KEY_D: SubscriptionInfo( + subscription_id=SUB_D_ID, key=SUB_KEY_D, + status="STARTED", product_type="DEVICE", + available_quantity="10", quantity="10", + start_date="2024-01-01", end_date="2024-06-30", # expired + subscription_type="CENTRAL_GW", tier_description="Advanced-90/70xx", + ), + SUB_KEY_E: SubscriptionInfo( + subscription_id=SUB_E_ID, key=SUB_KEY_E, + status="STARTED", product_type="SOFTWARE", # wrong type + available_quantity="10", quantity="10", + start_date="2024-01-01", end_date="2027-12-31", + subscription_type="SERVICE", tier_description="", + ), + SUB_KEY_SHARED: SubscriptionInfo( + subscription_id=SUB_SHARED_ID, key=SUB_KEY_SHARED, + status="STARTED", product_type="DEVICE", + available_quantity="4", quantity="4", + start_date="2024-01-01", end_date="2027-12-31", + subscription_type="CENTRAL_AP", tier_description="Foundation AP", + ), + }, +} + + +# --------------------------------------------------------------------------- +# DemoAdapter +# --------------------------------------------------------------------------- + +class DemoAdapter: + """Deterministic adapter with an isolated mutable execution overlay.""" + + def __init__(self, scenario: str = "success") -> None: + if scenario not in SUPPORTED_SCENARIOS: + raise ValueError(f"Unknown demo scenario: {scenario}") + self._scenario = scenario + # Demo calls are in-memory, so exercise the production pacer without + # turning its intentional delays into wall-clock waits. + self._engine_clock = _DemoEngineClock() + self._device_states = { + info.glp_id: { + "assigned_state": info.assigned_state, + "in_use_workspace": info.in_use_workspace, + "tenant_workspace_id": info.tenant_workspace_id, + "service_manager_id": info.service_manager_id, + "subscription": info.subscription, + } + for info in CATALOG["devices_by_serial"].values() + } + self._subscription_assignments: dict[str, str] = {} + self._created_tenants: dict[str, TenantInfo] = {} + self._provisioned_services: dict[str, list[ServiceInfo]] = {} + self._provisioning_services: dict[tuple[str, str, str], ServiceInfo] = {} + # ponytail: session-lifetime cache has no TTL; assumes provisioning does not + # change within a session. Revisit if that stops holding. + self._services_by_tenant: dict[str, list[ServiceInfo]] = {} + self._transactions: dict[str, TransactionResult] = {} + self._next_transaction = 1 + self._partial_used = False + self._bulk_partial_used = False + self._ambiguous_used = False + self._tenant_creation_failure_name: Optional[str] = None + self.submitted_device_batches: list[list[str]] = [] + self.submitted_subscription_batches: list[list[tuple[str, str]]] = [] + + def now(self) -> datetime: + return FIXED_CLOCK + + def list_tenants(self) -> list[TenantInfo]: + return list(CATALOG["tenants"].values()) + list(self._created_tenants.values()) + + def fresh_tenant_listing(self) -> list[TenantInfo]: + return self.list_tenants() + + def resolve_tenant(self, workspace_id: str) -> TenantInfo: + tenant = self._created_tenants.get(workspace_id) or CATALOG["tenants"].get(workspace_id) + if tenant is None: + raise AdapterError( + path="tenant.workspace_id", + code="tenant_not_found", + message=f"Tenant not found: {workspace_id!r}", + ) + return tenant + + def find_tenant_by_name(self, name: str) -> Optional[TenantInfo]: + for tenant in self._created_tenants.values(): + if tenant.workspace_name == name: + return tenant + for tenant in CATALOG["tenants"].values(): + if tenant.workspace_name == name: + return tenant + return None + + @staticmethod + def _create_tenant_response(tenant: TenantInfo) -> dict[str, str]: + return {"id": tenant.workspace_id} + + def ensure_tenant( + self, + mode: str, + workspace_id: Optional[str], + workspace_name: str, + *, + country: str = "", + description: str = "", + email: str = "", + phone_number: str = "", + address: Optional[AddressNew] = None, + known_tenants: Optional[list[TenantInfo]] = None, + ) -> TenantInfo: + del country, description, email, phone_number, address + if mode == "existing": + if workspace_id is None: + raise AdapterError( + path="tenant.workspace_id", + code="tenant_not_found", + message="Existing tenant is missing a workspace ID", + ) + return self.resolve_tenant(workspace_id) + if self._scenario in {"tenant-name-conflict", "tenant-creation-systemic"}: + if self._tenant_creation_failure_name is None: + self._tenant_creation_failure_name = workspace_name + if workspace_name == self._tenant_creation_failure_name: + if self._scenario == "tenant-name-conflict": + raise AdapterError( + path="tenant", + code="request_failed", + message=WORKSPACE_NAME_CONFLICT_MESSAGE, + failure_scope="tenant", + ) + raise AdapterError( + path="tenant", + code="transport_error", + message="Tenant creation is unavailable", + retryable=True, + failure_scope="systemic", + ) + existing = next( + ( + tenant + for tenant in known_tenants + if tenant.workspace_name == workspace_name + ), + None, + ) if known_tenants is not None else self.find_tenant_by_name(workspace_name) + if existing is not None: + return existing + tenant = CATALOG["creatable_tenants"].get(workspace_name) + if tenant is None: + tenant = TenantInfo( + workspace_id=TENANT_NEW_ID, + workspace_name=workspace_name, + ownership="MSP_OWNED_INVENTORY", + ) + self._created_tenants[tenant.workspace_id] = tenant + response = self._create_tenant_response(tenant) + workspace_id = str(response.get("id") or "") + if workspace_id: + return tenant + resolved = self.find_tenant_by_name(workspace_name) + if resolved is None: + raise AdapterError( + path="tenant.workspace_name", + code="tenant_not_found", + message="Created tenant was not found by exact workspace name", + ) + return resolved + + def list_eligible_services(self, workspace_id: Optional[str]) -> list[ServiceInfo]: + return [ + *CATALOG["services"].get(workspace_id, []), + *self._provisioned_services.get(workspace_id or "", []), + ] + + def services_for_tenants( + self, tenant_ids: list[str] + ) -> dict[str, list[ServiceInfo]]: + for workspace_id in tenant_ids: + if workspace_id not in self._services_by_tenant: + self._services_by_tenant[workspace_id] = self.list_eligible_services( + workspace_id + ) + return { + workspace_id: self._services_by_tenant[workspace_id] + for workspace_id in tenant_ids + } + + def submit_service_provisioning( + self, workspace_id: str, service_manager_id: str, region: str + ) -> None: + service = next( + ( + service + for services in CATALOG["services"].values() + for service in services + if service.service_manager_id == service_manager_id + and service.region == region + ), + None, + ) + if service is None: + raise AdapterError( + path="service", + code="service_not_eligible", + message="Service is not eligible", + ) + known = self.list_eligible_services(workspace_id) + if not any( + candidate.service_manager_id == service_manager_id + and candidate.region == region + for candidate in known + ): + self._provisioning_services.setdefault( + (workspace_id, service_manager_id, region), service + ) + + def observe_service_provisioning( + self, workspace_id: str, service_manager_id: str, region: str + ) -> str: + known = self.list_eligible_services(workspace_id) + if any( + candidate.service_manager_id == service_manager_id + and candidate.region == region + for candidate in known + ): + return "provisioned" + service = self._provisioning_services.pop( + (workspace_id, service_manager_id, region), None + ) + if service is None: + return "not_started" + self._provisioned_services.setdefault(workspace_id, []).append(service) + self._services_by_tenant.pop(workspace_id, None) + return "provisioned" + + def resolve_devices( + self, + *, + serials: Optional[list[str]] = None, + glp_ids: Optional[list[str]] = None, + ) -> list[DeviceInfo]: + selectors = [values for values in (serials, glp_ids) if values is not None] + if len(selectors) != 1: + raise ValueError("Provide exactly one device identifier list") + resolver = ( + self.resolve_device_by_serial + if serials is not None + else self.resolve_device + ) + resolved = [] + for value in selectors[0]: + try: + resolved.append(resolver(value)) + except AdapterError as exc: + if exc.code != "device_not_found": + raise + return resolved + + def resolve_device_by_serial(self, serial: str) -> DeviceInfo: + device = CATALOG["devices_by_serial"].get(serial) + if device is None: + raise AdapterError( + path="devices", + code="device_not_found", + message=f"Device not found for serial: {serial!r}", + ) + return self.resolve_device(device.glp_id) + + def resolve_device_by_mac(self, mac: str) -> DeviceInfo: + serial = CATALOG["devices_by_mac"].get(mac) + if serial is None: + raise AdapterError( + path="devices", + code="device_not_found", + message=f"Device not found for MAC: {mac!r}", + ) + return self.resolve_device(CATALOG["devices_by_serial"][serial].glp_id) + + def resolve_device(self, glp_id: str) -> DeviceInfo: + for device in CATALOG["devices_by_serial"].values(): + if device.glp_id == glp_id: + state = self._device_states[glp_id] + return replace(device, **state) + raise AdapterError( + path="devices", + code="device_not_found", + message=f"Device not found: {glp_id!r}", + ) + + def list_available_devices(self) -> list[DeviceInfo]: + return [ + self.resolve_device(device.glp_id) + for device in CATALOG["devices_by_serial"].values() + if device.management == "MSP" + and self._device_states[device.glp_id]["assigned_state"] == "UNASSIGNED" + and not self._device_states[device.glp_id]["in_use_workspace"] + and not self._device_states[device.glp_id]["tenant_workspace_id"] + and not self._device_states[device.glp_id]["subscription"] + ] + + def resolve_subscription(self, key: str) -> SubscriptionInfo: + sub = CATALOG["subscriptions"].get(key) + if sub is None: + raise AdapterError( + path="devices", + code="subscription_not_found", + message="Subscription key not found in catalog", + ) + used = sum(1 for sub_id in self._subscription_assignments.values() + if sub_id == sub.subscription_id) + return replace(sub, available_quantity=str(int(sub.available_quantity) - used)) + + def list_subscriptions(self) -> list[SubscriptionInfo]: + return [self.resolve_subscription(key) for key in CATALOG["subscriptions"]] + + def _new_transaction( + self, succeeded_ids: list[str], failed_ids: list[str] + ) -> str: + transaction_id = f"demo-transaction-{self._next_transaction}" + self._next_transaction += 1 + self._transactions[transaction_id] = TransactionResult( + transaction_id=transaction_id, + succeeded_ids=succeeded_ids, + failed_ids=failed_ids, + ) + return transaction_id + + def _assign_device( + self, glp_id: str, tenant_workspace_id: str, service_manager_id: str + ) -> None: + state = self._device_states[glp_id] + state.update( + assigned_state="ASSIGNED", + in_use_workspace=tenant_workspace_id, + tenant_workspace_id=tenant_workspace_id, + service_manager_id=service_manager_id, + ) + + def assign_devices( + self, + device_ids: list[str], + tenant_workspace_id: str, + service_manager_id: str, + region: str, + ) -> str: + batch_size = write_batch_size() + if len(device_ids) > batch_size: + raise AdapterError( + "execution.devices", + "batch_too_large", + f"At most {batch_size} devices are allowed", + ) + del region + self.submitted_device_batches.append(list(device_ids)) + if self._scenario == "ambiguous-write" and not self._ambiguous_used: + for glp_id in device_ids: + self._assign_device(glp_id, tenant_workspace_id, service_manager_id) + self._ambiguous_used = True + transaction_id = self._new_transaction(list(device_ids), []) + raise AdapterError( + path="execution.devices", + code="ambiguous_write", + message="Demo transport timeout after applying device assignment", + transaction_id=transaction_id, + ) + if ( + self._scenario == "bulk-partial" + and tenant_workspace_id == TENANT_EUROPE_ID + and not self._bulk_partial_used + ): + self._bulk_partial_used = True + succeeded_ids = device_ids[:-1] + failed_ids = device_ids[-1:] + for glp_id in succeeded_ids: + self._assign_device(glp_id, tenant_workspace_id, service_manager_id) + return self._new_transaction(succeeded_ids, failed_ids) + if self._scenario == "partial-device-write" and not self._partial_used: + self._partial_used = True + succeeded_ids = device_ids[:1] + failed_ids = device_ids[1:] + for glp_id in succeeded_ids: + self._assign_device(glp_id, tenant_workspace_id, service_manager_id) + return self._new_transaction(succeeded_ids, failed_ids) + for glp_id in device_ids: + self._assign_device(glp_id, tenant_workspace_id, service_manager_id) + return self._new_transaction(list(device_ids), []) + + def assign_subscriptions( + self, + assignments: list[tuple[str, str]], + ) -> str: + batch_size = write_batch_size() + if len(assignments) > batch_size: + raise AdapterError( + "execution.subscriptions", + "batch_too_large", + f"At most {batch_size} subscriptions are allowed", + ) + self.submitted_subscription_batches.append(list(assignments)) + for glp_id, subscription_id in assignments: + self._subscription_assignments[glp_id] = subscription_id + self._device_states[glp_id]["subscription"] = subscription_id + return self._new_transaction([glp_id for glp_id, _ in assignments], []) + + def transaction_origin(self, transaction_id: str) -> None: + return None + + def poll_transaction( + self, transaction_id: str, origin: Optional[str] = None + ) -> TransactionResult: + del origin + result = self._transactions.get(transaction_id) + if result is None: + raise AdapterError( + path="execution.transaction", + code="transaction_not_found", + message=f"Transaction not found: {transaction_id!r}", + ) + return result diff --git a/msp-onboarding/msp_onboarding/engine.py b/msp-onboarding/msp_onboarding/engine.py new file mode 100644 index 0000000..2386383 --- /dev/null +++ b/msp-onboarding/msp_onboarding/engine.py @@ -0,0 +1,2138 @@ +"""Multi-tenant onboarding planning and session-only execution.""" +from __future__ import annotations + +import csv +import hashlib +from io import StringIO +import json +import logging +from concurrent.futures import ThreadPoolExecutor +from datetime import date, datetime, timedelta, timezone +from threading import Lock +from time import monotonic, sleep +from typing import Callable, Optional, Protocol, TypeVar, cast +from uuid import uuid4 + +from .adapter import ( + AdapterError, + AdapterProtocol, + write_batch_size, +) +from .models import ( + AddressNew, + DeviceInfo, + DevicePlan, + Manifest, + ManifestDevice, + Plan, + ServiceInfo, + SubscriptionInfo, + TenantGroupRollup, + TenantInfo, + TERMINAL_JOB_STATUSES, + ValidationError, +) +from .store import MemoryStore + +T = TypeVar("T") + +_log = logging.getLogger(__name__) + +# Live finding 2026-08-25: GLP windows are 2 provisioning POSTs and 10 tenant +# POSTs per ~30 s; the adapter gates writes on the ratelimit headers. Here: +# poll slowly, and at most five tenants provision at once. +_PROVISIONING_WINDOW = 5 +_PROVISION_POLL_INTERVAL_SECONDS = 30.0 +_PROVISION_POLL_ATTEMPTS = 10 # 5 min ceiling +_RATE_LIMIT_BACKOFF_SECONDS = (30.0, 60.0, 120.0) +# GLP async device operations can sit PENDING well past the write itself +# (R12/R61 live finding) — give a transaction ~30s before pausing as unknown. +# ponytail: 40 x 3s = 2 min ceiling; live subscription assignment exceeded the old 30 s. +_TRANSACTION_POLL_ATTEMPTS = 40 +_TRANSACTION_POLL_INTERVAL_SECONDS = 3.0 +_AMBIGUOUS_WRITE_ATTEMPTS = 3 +_READ_PACE_SECONDS = 0.25 +_WRITE_PACE_SECONDS = 1.0 +# ponytail: three clean calls gives a short recovery probe; revisit if live 429 +# bursts recur immediately after the normal floor returns. +_PACER_RECOVERY_CALLS = 3 + + +class _EngineClock(Protocol): + def now(self) -> float: ... + def sleep(self, seconds: float) -> None: ... + def utcnow(self) -> datetime: ... + + +class _SystemClock: + def now(self) -> float: + return monotonic() + + def sleep(self, seconds: float) -> None: + sleep(seconds) + + def utcnow(self) -> datetime: + return datetime.now(timezone.utc) + + +class _OutboundPacer: + def __init__(self, clock: _EngineClock) -> None: + self._clock = clock + self._last_at: Optional[float] = None + self._paused_until = 0.0 + self._degraded = False + self._clean_calls = 0 + self._lock = Lock() + + def wait(self, *, is_write: bool = False) -> None: + while True: + with self._lock: + now = self._clock.now() + interval = _WRITE_PACE_SECONDS if is_write else _READ_PACE_SECONDS + if self._degraded: + interval *= 2 + paced_at = ( + self._last_at + interval + if self._last_at is not None + else now + ) + allowed_at = max(self._paused_until, paced_at) + if allowed_at <= now: + self._last_at = now + return + delay = allowed_at - now + # A 429 must be able to extend the pause while siblings are waiting. + self._clock.sleep(delay) + + def pause(self, delay_seconds: float) -> str: + with self._lock: + now = self._clock.now() + self._degraded = True + self._clean_calls = 0 + self._paused_until = max( + self._paused_until, now + delay_seconds + ) + remaining = self._paused_until - now + return (self._clock.utcnow() + timedelta(seconds=remaining)).isoformat() + + def clean(self) -> None: + with self._lock: + if not self._degraded: + return + self._clean_calls += 1 + if self._clean_calls >= _PACER_RECOVERY_CALLS: + self._degraded = False + self._clean_calls = 0 + + +class _PacedAdapter: + _LOCAL_METHODS = {"call_stats", "now", "transaction_origin"} + _WRITE_METHODS = { + "assign_devices", + "assign_subscriptions", + "submit_service_provisioning", + } + + def __init__( + self, + adapter: AdapterProtocol, + pacer: _OutboundPacer, + *, + pace_methods: bool, + ) -> None: + self._adapter = adapter + self._pacer = pacer + self._pace_methods = pace_methods + + def __getattr__(self, name: str): + attribute = getattr(self._adapter, name) + if name in self._LOCAL_METHODS or not callable(attribute): + return attribute + + def paced(*args, **kwargs): + is_write = name in self._WRITE_METHODS or ( + name == "ensure_tenant" + and (args[0] if args else kwargs.get("mode")) == "new" + ) + if self._pace_methods: + self._pacer.wait(is_write=is_write) + try: + result = attribute(*args, **kwargs) + except AdapterError as exc: + if exc.code == "rate_limited": + delay = exc.retry_after if exc.retry_after is not None else 2.0 + self._pacer.pause(delay) + raise + if self._pace_methods: + self._pacer.clean() + return result + + return paced + + +def _parse_nonneg_int(value: str) -> Optional[int]: + if not isinstance(value, str): + return None + value = value.strip() + return int(value) if value and value.isascii() and value.isdigit() else None + + +def _provision_poll_delay(attempt: int) -> float: + return _PROVISION_POLL_INTERVAL_SECONDS + + +def _within_dates(subscription: SubscriptionInfo, now: datetime) -> bool: + today = now.date() + if subscription.start_date and today < date.fromisoformat(subscription.start_date): + return False + if subscription.end_date and today > date.fromisoformat(subscription.end_date): + return False + return True + + +_SUBSCRIPTION_DEVICE_TYPES = { + "CENTRAL_AP": "AP", + "CENTRAL_SWITCH": "SWITCH", + "CENTRAL_GW": "GW", +} + +_DEVICE_TYPE_LABELS = { + "AP": "AP", + "SWITCH": "Switch", + "GW": "Gateway", +} + + +def _subscription_type_mismatch( + subscription: SubscriptionInfo, device: DeviceInfo +) -> Optional[str]: + subscription_device_type = _SUBSCRIPTION_DEVICE_TYPES.get( + subscription.subscription_type.strip().upper() + ) + device_type = device.device_type.strip().upper() + if not subscription_device_type or device_type not in _DEVICE_TYPE_LABELS: + return None + if subscription_device_type == device_type: + return None + subscription_label = _DEVICE_TYPE_LABELS[subscription_device_type] + device_label = _DEVICE_TYPE_LABELS[device_type] + article = "an" if device_type == "AP" else "a" + return ( + f"Subscription is a {subscription_label} license; " + f"this device is {article} {device_label}." + ) + + +class OnboardingEngine: + _MAX_READ_RETRIES = 3 + + def __init__( + self, + adapter: AdapterProtocol, + store: MemoryStore, + *, + clock: Optional[_EngineClock] = None, + ) -> None: + self._clock = ( + clock or getattr(adapter, "_engine_clock", None) or _SystemClock() + ) + self._outbound_pacer = _OutboundPacer(self._clock) + install_pacer = getattr(adapter, "install_request_pacer", None) + if install_pacer is not None: + self._outbound_pacer = install_pacer(self._outbound_pacer) + self._adapter = cast( + AdapterProtocol, + _PacedAdapter( + adapter, + self._outbound_pacer, + pace_methods=install_pacer is None, + ), + ) + self._store = store + self._execution_subscription_cache: dict[str, SubscriptionInfo] = {} + + def plan(self, manifest: Manifest) -> Plan: + """Run read-only preflight for every tenant and save an immutable plan.""" + group_errors: dict[str, list[ValidationError]] = { + tenant.name: [] for tenant in manifest.tenants + } + tenant_infos: dict[str, TenantInfo] = {} + service_infos: dict[str, ServiceInfo] = {} + new_eligible_services: Optional[list[ServiceInfo]] = None + new_services_error: Optional[AdapterError] = None + devices_by_tenant: dict[str, list[tuple[int, ManifestDevice]]] = { + tenant.name: [] for tenant in manifest.tenants + } + for index, device in enumerate(manifest.devices): + devices_by_tenant[device.tenant].append((index, device)) + + for index, tenant in enumerate(manifest.tenants): + tenant_path = f"tenants[{index}]" + if manifest.mode == "existing" and not devices_by_tenant[tenant.name]: + group_errors[tenant.name].append( + ValidationError( + path=f"{tenant_path}.devices", + code="missing_devices", + message="At least one device is required for this tenant", + ) + ) + + tenant_info: Optional[TenantInfo] = None + if manifest.mode == "existing": + try: + tenant_info = self._retry_rate_limited( + lambda: self._adapter.resolve_tenant(tenant.workspace_id) + ) + tenant_infos[tenant.name] = tenant_info + if tenant_info.ownership != "MSP_OWNED_INVENTORY": + group_errors[tenant.name].append( + ValidationError( + path=tenant_path, + code="ownership_mismatch", + message=( + "Tenant must be MSP_OWNED_INVENTORY, " + f"got {tenant_info.ownership!r}" + ), + ) + ) + except AdapterError as exc: + group_errors[tenant.name].append( + ValidationError( + path=f"{tenant_path}.workspace_id", + code=exc.code, + message=exc.message, + ) + ) + else: + try: + existing = self._retry_rate_limited( + lambda: self._adapter.find_tenant_by_name(tenant.name) + ) + except AdapterError as exc: + group_errors[tenant.name].append( + ValidationError(tenant_path, exc.code, exc.message) + ) + existing = None + if existing is not None: + group_errors[tenant.name].append( + ValidationError( + path=f"{tenant_path}.name", + code="name_conflict", + message=f"A tenant named {tenant.name!r} already exists", + ) + ) + + if manifest.mode == "existing" and tenant_info is None: + continue + workspace_id = tenant_info.workspace_id if tenant_info else None + if manifest.mode == "new": + if new_eligible_services is None and new_services_error is None: + try: + new_eligible_services = self._retry_rate_limited( + lambda: self._adapter.list_eligible_services(None) + ) + except AdapterError as exc: + new_services_error = exc + if new_services_error is not None: + group_errors[tenant.name].append( + ValidationError( + f"{tenant_path}.service", + new_services_error.code, + new_services_error.message, + ) + ) + continue + eligible = new_eligible_services + else: + try: + eligible = self._retry_rate_limited( + lambda: self._adapter.list_eligible_services(workspace_id) + ) + except AdapterError as exc: + group_errors[tenant.name].append( + ValidationError(f"{tenant_path}.service", exc.code, exc.message) + ) + continue + + if tenant.service is None: + if len(eligible) == 1: + service_infos[tenant.name] = eligible[0] + elif not eligible: + group_errors[tenant.name].append( + ValidationError( + path=f"{tenant_path}.service", + code="service_not_found", + message="No eligible Central services found", + ) + ) + else: + group_errors[tenant.name].append( + ValidationError( + path=f"{tenant_path}.service", + code="service_required", + message=( + f"{len(eligible)} eligible services found; specify " + "service_manager_id and region" + ), + ) + ) + else: + service = next( + ( + candidate + for candidate in eligible + if candidate.service_manager_id == tenant.service.service_manager_id + and candidate.region == tenant.service.region + ), + None, + ) + if service is None: + group_errors[tenant.name].append( + ValidationError( + path=f"{tenant_path}.service", + code="service_not_eligible", + message=( + f"Service {tenant.service.service_manager_id!r} in region " + f"{tenant.service.region!r} is not eligible" + ), + ) + ) + else: + service_infos[tenant.name] = service + + resolved: list[tuple[int, ManifestDevice, DeviceInfo]] = [] + by_glp_id: dict[str, list[tuple[int, ManifestDevice, DeviceInfo]]] = {} + resolved_by_index: dict[int, DeviceInfo] = {} + serials = [ + (index, device, device.serial_number) + for index, device in enumerate(manifest.devices) + if device.serial_number + ] + if serials: + values = [value for _, _, value in serials] + try: + infos = self._retry_rate_limited( + lambda: self._adapter.resolve_devices(serials=values) + ) + except AdapterError as exc: + for index, device, _ in serials: + group_errors[device.tenant].append( + ValidationError( + path=f"devices[{index}]", + code=exc.code, + message=exc.message, + ) + ) + else: + found = {info.serial_number.strip().lower(): info for info in infos} + for index, device, value in serials: + info = found.get(value.strip().lower()) + if info is None: + group_errors[device.tenant].append( + ValidationError( + path=f"devices[{index}]", + code="device_not_found", + message="Device was not found", + ) + ) + else: + resolved_by_index[index] = info + + for index, device in enumerate(manifest.devices): + if not device.mac_address: + continue + try: + resolved_by_index[index] = self._retry_rate_limited( + lambda device=device: self._adapter.resolve_device_by_mac( + device.mac_address + ) + ) + except AdapterError as exc: + group_errors[device.tenant].append( + ValidationError( + path=f"devices[{index}]", code=exc.code, message=exc.message + ) + ) + + for index, device in enumerate(manifest.devices): + path = f"devices[{index}]" + info = resolved_by_index.get(index) + if info is None: + continue + if info.management != "MSP": + group_errors[device.tenant].append( + ValidationError( + path=path, + code="management_not_msp", + message=f"Device management must be MSP, got {info.management!r}", + ) + ) + continue + if ( + info.assigned_state != "UNASSIGNED" + or info.in_use_workspace + or info.tenant_workspace_id + or info.subscription + ): + group_errors[device.tenant].append( + ValidationError( + path=path, + code="device_not_available", + message=( + "Device is not available " + f"(assigned_state={info.assigned_state!r}, " + f"in_use_workspace={info.in_use_workspace!r}, " + f"tenant_workspace_id={info.tenant_workspace_id!r}, " + f"subscription_attached={bool(info.subscription)})" + ), + ) + ) + continue + item = (index, device, info) + resolved.append(item) + by_glp_id.setdefault(info.glp_id, []).append(item) + + duplicate_ids = {glp_id for glp_id, items in by_glp_id.items() if len(items) > 1} + for glp_id in duplicate_ids: + items = by_glp_id[glp_id] + positions = ", ".join(f"devices[{item[0]}]" for item in items) + for index, device, _ in items: + group_errors[device.tenant].append( + ValidationError( + path=f"devices[{index}]", + code="duplicate_device", + message=f"Same GLP device appears at {positions}", + ) + ) + + key_items: dict[str, list[tuple[int, ManifestDevice, DeviceInfo]]] = {} + for item in resolved: + index, device, info = item + if info.glp_id in duplicate_ids: + continue + if not device.subscription_key: + group_errors[device.tenant].append( + ValidationError( + path=f"devices[{index}].subscription_key", + code="missing_subscription", + message="subscription_key is required", + ) + ) + continue + key_items.setdefault(device.subscription_key, []).append(item) + + valid_subscriptions: dict[str, SubscriptionInfo] = {} + now = self._adapter.now() + for key, items in key_items.items(): + demand = len(items) + try: + subscription = self._retry_rate_limited( + lambda: self._adapter.resolve_subscription(key) + ) + except AdapterError as exc: + self._add_subscription_errors(group_errors, items, exc.code, exc.message) + continue + if subscription.status != "STARTED": + self._add_subscription_errors( + group_errors, + items, + "subscription_not_started", + f"Subscription is not active (status={subscription.status!r})", + ) + continue + if subscription.product_type != "DEVICE": + self._add_subscription_errors( + group_errors, + items, + "subscription_ineligible", + f"Subscription product type must be DEVICE, got {subscription.product_type!r}", + ) + continue + try: + in_dates = _within_dates(subscription, now) + except ValueError: + self._add_subscription_errors( + group_errors, + items, + "invalid_subscription_date", + "Subscription start_date or end_date is malformed", + ) + continue + if not in_dates: + self._add_subscription_errors( + group_errors, + items, + "subscription_expired", + "Subscription is outside its validity period", + ) + continue + for index, device, info in items: + mismatch = _subscription_type_mismatch(subscription, info) + if mismatch: + group_errors[device.tenant].append( + ValidationError( + path=f"devices[{index}].subscription_key", + code="subscription_type_mismatch", + message=mismatch, + ) + ) + available = _parse_nonneg_int(subscription.available_quantity) + quantity = _parse_nonneg_int(subscription.quantity) + if available is None or quantity is None: + self._add_subscription_errors( + group_errors, + items, + "invalid_quantity", + "Subscription quantity fields must be non-negative integer strings", + ) + continue + if available < demand: + self._add_subscription_errors( + group_errors, + items, + "insufficient_capacity", + ( + f"Insufficient aggregate capacity: {available} seats available, " + f"{demand} needed across all tenant groups" + ), + ) + continue + valid_subscriptions[key] = subscription + + device_plans = [ + DevicePlan( + tenant_name=device.tenant, + tenant_workspace_id=( + tenant_infos[device.tenant].workspace_id + if device.tenant in tenant_infos + else None + ), + glp_id=info.glp_id, + serial_number=device.serial_number or None, + mac_address=device.mac_address or None, + subscription_key=device.subscription_key, + subscription_id=valid_subscriptions[device.subscription_key].subscription_id, + ) + for _, device, info in resolved + if info.glp_id not in duplicate_ids + and device.subscription_key in valid_subscriptions + ] + + tenant_groups = [] + for tenant in manifest.tenants: + service = service_infos.get(tenant.name) + errors = group_errors[tenant.name] + tenant_groups.append( + TenantGroupRollup( + tenant_name=tenant.name, + tenant_workspace_id=( + tenant_infos[tenant.name].workspace_id + if tenant.name in tenant_infos + else None + ), + service_manager_id=service.service_manager_id if service else None, + service_region=service.region if service else None, + service_name=service.name if service else None, + service_region_display_name=( + service.region_display_name if service else None + ), + status="blocked" if errors else "pending", + device_count=len(devices_by_tenant[tenant.name]), + errors=errors, + ) + ) + + manifest_hash = manifest.canonical_hash() + errors: list[ValidationError] = [] + plan_hash = Plan.compute_hash( + manifest_hash=manifest_hash, + mode=manifest.mode, + tenant_groups=tenant_groups, + devices=device_plans, + errors=errors, + ) + plan = Plan( + job_id=str(uuid4()), + manifest_hash=manifest_hash, + plan_hash=plan_hash, + mode=manifest.mode, + tenant_groups=tenant_groups, + devices=device_plans, + errors=errors, + created_at=self._adapter.now().isoformat(), + ) + self._store.save_plan(plan.job_id, manifest, plan) + return plan + + @staticmethod + def _add_subscription_errors( + errors: dict[str, list[ValidationError]], + items: list[tuple[int, ManifestDevice, DeviceInfo]], + code: str, + message: str, + ) -> None: + for index, device, _ in items: + errors[device.tenant].append( + ValidationError(f"devices[{index}].subscription_key", code, message) + ) + + def get(self, job_id: str) -> dict: + job = self._store.get_job(job_id) + if job is None: + raise KeyError(f"Job not found: {job_id}") + job["plan"] = self._store.get_plan_dict(job_id) + job["steps"] = self._store.get_steps(job_id) + job["devices"] = self._store.get_devices(job_id) + return job + + def report_csv(self, job_id: str) -> str: + """Render a terminal job's device and step outcomes as a CSV export.""" + job = self.get(job_id) + if job["status"] not in TERMINAL_JOB_STATUSES: + raise ValueError("Reports are available only after a job reaches a terminal state") + + manifest = self._store.get_manifest_dict(job_id) + if manifest is None: + raise KeyError(f"Manifest not found for job: {job_id}") + subscription_keys = self._manifest_subscription_key_map(manifest) + subscription_ids = { + str(device["subscription_id"]) + for device in job["plan"]["devices"] + } + device_keys = { + (device["tenant_name"], device["glp_id"]): subscription_keys.get( + ( + device["tenant_name"], + device.get("serial_number") or "", + device.get("mac_address") or "", + ), + "", + ) + for device in job["plan"]["devices"] + } + fields = ( + "row_type", + "tenant", + "serial_number", + "model", + "device_status", + "subscription_key", + "subscription_status", + "error", + "skipped", + "step_scope", + "step_key", + "step_operation", + "step_status", + "step_attempts", + "step_transaction_id", + ) + output = StringIO(newline="") + writer = csv.DictWriter(output, fieldnames=fields) + writer.writeheader() + for device in job["devices"]: + key = device_keys.get((device["tenant_name"], device["glp_id"]), "") + writer.writerow( + { + "row_type": "device", + "tenant": device["tenant_name"], + "serial_number": device.get("serial_number") or "", + "model": device.get("model") or "", + "device_status": device["device_status"], + "subscription_key": key, + "subscription_status": device["subscription_status"], + "error": self._report_error(device.get("error"), subscription_ids), + "skipped": str( + "skipped" in ( + device["device_status"], + device["subscription_status"], + ) + ).lower(), + "step_scope": "", + "step_key": "", + "step_operation": "", + "step_status": "", + "step_attempts": "", + "step_transaction_id": "", + } + ) + for step in job["steps"]: + writer.writerow( + { + "row_type": "step", + "tenant": step["tenant_name"], + "serial_number": "", + "model": "", + "device_status": "", + "subscription_key": "", + "subscription_status": "", + "error": self._report_error(step.get("error"), subscription_ids), + "skipped": str(step["status"] == "skipped").lower(), + "step_scope": step["scope"], + "step_key": step["logical_key"], + "step_operation": step["operation"], + "step_status": step["status"], + "step_attempts": step["attempts"], + "step_transaction_id": step.get("transaction_id") or "", + } + ) + return output.getvalue() + + @staticmethod + def _report_error(error: Optional[dict], subscription_ids: set[str]) -> str: + if not error: + return "" + code = str(error.get("code", "")) + message = str(error.get("message", "")) + value = ": ".join(part for part in (code, message) if part) + for subscription_id in subscription_ids: + value = value.replace(subscription_id, "***") + return value + + def confirm(self, job_id: str) -> dict: + job = self.get(job_id) + self._assert_manifest_hash(job_id, job["manifest_hash"]) + self._assert_plan_hash(job_id, job["plan_hash"]) + self._store.enqueue_start(job_id) + return self.get(job_id) + + def stop(self, job_id: str) -> dict: + self._store.request_stop(job_id) + return self.get(job_id) + + def drain(self) -> None: + while (job_id := self._store.claim_next_job()) is not None: + try: + self._execute(job_id) + except Exception: + if self._store.get_job(job_id)["status"] == "running": + for group in self._store.get_job(job_id)["tenant_groups"]: + if group["status"] == "running": + self._fail_group( + job_id, + group["tenant_name"], + "execution_failed", + "Unexpected execution failure", + ) + self._store.finish_job(job_id, "failed") + raise + + def _assert_manifest_hash(self, job_id: str, expected_hash: str) -> None: + manifest = self._store.get_manifest_dict(job_id) + if manifest is None: + raise KeyError(f"Manifest not found for job: {job_id}") + canonical = dict(manifest) + canonical["devices"] = sorted( + canonical["devices"], + key=lambda device: ( + device["tenant"], + device["serial_number"], + device["mac_address"], + device["subscription_key"], + ), + ) + actual = hashlib.sha256( + json.dumps(canonical, sort_keys=True, separators=(",", ":")).encode() + ).hexdigest() + if actual != expected_hash: + raise ValueError("Stored manifest hash no longer matches the confirmed plan") + + def _assert_plan_hash(self, job_id: str, expected_hash: str) -> None: + plan = self._store.get_plan_dict(job_id) + manifest = self._store.get_manifest_dict(job_id) + if plan is None or manifest is None: + raise KeyError(f"Persisted plan or manifest not found for job: {job_id}") + try: + keys = self._manifest_subscription_key_map(manifest) + devices = [ + DevicePlan( + tenant_name=device["tenant_name"], + tenant_workspace_id=device.get("tenant_workspace_id"), + glp_id=device["glp_id"], + serial_number=device.get("serial_number"), + mac_address=device.get("mac_address"), + subscription_key=keys[ + ( + device["tenant_name"], + device.get("serial_number") or "", + device.get("mac_address") or "", + ) + ], + subscription_id=device["subscription_id"], + ) + for device in plan["devices"] + ] + groups = [ + TenantGroupRollup( + tenant_name=group["tenant_name"], + tenant_workspace_id=group.get("tenant_workspace_id"), + service_manager_id=group.get("service_manager_id"), + service_region=group.get("service_region"), + service_name=group.get("service_name"), + service_region_display_name=group.get( + "service_region_display_name" + ), + status=group["status"], + device_count=group["device_count"], + errors=[ValidationError(**error) for error in group["errors"]], + last_error=group.get("last_error"), + ) + for group in plan["tenant_groups"] + ] + errors = [ValidationError(**error) for error in plan["errors"]] + actual = Plan.compute_hash( + manifest_hash=plan["manifest_hash"], + mode=plan["mode"], + tenant_groups=groups, + devices=devices, + errors=errors, + ) + except (KeyError, TypeError): + actual = "" + if actual != expected_hash: + raise ValueError("Stored plan hash no longer matches the confirmed plan") + + @staticmethod + def _manifest_subscription_key_map( + manifest: dict, + ) -> dict[tuple[str, str, str], str]: + return { + (device["tenant"], device["serial_number"] or "", device["mac_address"] or ""): + device["subscription_key"] + for device in manifest["devices"] + } + + def _execute(self, job_id: str) -> None: + self._execution_subscription_cache = {} + plan = self._store.get_plan_dict(job_id) + manifest = self._store.get_manifest_dict(job_id) + if plan is None or manifest is None: + self._store.finish_job(job_id, "failed") + return + if plan["errors"]: + self._store.finish_job(job_id, "failed") + return + + plan_groups = {group["tenant_name"]: group for group in plan["tenant_groups"]} + runnable_names = { + group["tenant_name"] + for group in plan["tenant_groups"] + if group["status"] == "pending" + } + key_map = self._manifest_subscription_key_map(manifest) + self._store.save_execution_records( + job_id, + [ + { + **device, + "subscription_key": key_map.get( + ( + device["tenant_name"], + device.get("serial_number") or "", + device.get("mac_address") or "", + ), + "", + ), + } + for device in plan["devices"] + if device["tenant_name"] in runnable_names + ], + ) + + pending_tenants = [ + tenant_data + for tenant_data in manifest["tenants"] + if next( + group + for group in self._store.get_job(job_id)["tenant_groups"] + if group["tenant_name"] == tenant_data["name"] + )["status"] == "pending" + ] + if plan["mode"] == "existing": + for tenant_data in pending_tenants: + if self._store.stop_requested(job_id): + break + self._execute_pending_group( + job_id, + plan, + plan_groups[tenant_data["name"]], + tenant_data, + ) + if self._store.stop_requested(job_id): + break + else: + self._execute_new_tenant_pipeline( + job_id, + plan, + plan_groups, + pending_tenants, + ) + + if self._store.stop_requested(job_id): + self._store.stop_job(job_id) + return + + executed = [ + group + for group in self._store.get_job(job_id)["tenant_groups"] + if group["status"] in ("succeeded", "failed") + ] + succeeded = sum(group["status"] == "succeeded" for group in executed) + failed = sum(group["status"] == "failed" for group in executed) + if succeeded and failed: + status = "completed_with_errors" + elif succeeded and not failed: + status = "succeeded" + else: + status = "failed" + self._store.finish_job(job_id, status) + call_stats = getattr(self._adapter, "call_stats", None) + if callable(call_stats): + rows = call_stats() + if rows: + summary = "\n".join( + " %(method)s %(path_template)s count=%(count)d total_ms=%(total_ms).1f" + % row + for row in rows + ) + _log.info("GLP call summary for job %s:\n%s", job_id, summary) + + def _execute_new_tenant_pipeline( + self, + job_id: str, + plan: dict, + plan_groups: dict[str, dict], + pending_tenants: list[dict], + ) -> None: + if not pending_tenants: + return + try: + known_tenants = self._retry_rate_limited( + lambda: self._adapter.fresh_tenant_listing() + ) + except AdapterError as exc: + for tenant_data in pending_tenants: + tenant_name = tenant_data["name"] + self._store.record_step( + job_id, + tenant_name, + "tenant", + "ensure_tenant", + "failed", + scope="global", + error={"code": exc.code, "message": exc.message}, + ) + self._fail_group(job_id, tenant_name, exc.code, exc.message) + return + futures = [] + with ThreadPoolExecutor(max_workers=_PROVISIONING_WINDOW) as executor: + for tenant_data in pending_tenants: + if self._store.stop_requested(job_id): + break + tenant_name = tenant_data["name"] + group = plan_groups[tenant_name] + self._store.update_tenant_group(job_id, tenant_name, "running") + try: + tenant_id = self._ensure_tenant( + job_id, + plan["mode"], + group, + tenant_data, + known_tenants=known_tenants, + ) + except AdapterError: + break + if tenant_id is None: + continue + if self._store.stop_requested(job_id): + break + if self._step( + job_id, tenant_name, "service", "ensure_service" + ) is None: + self._store.record_step( + job_id, + tenant_name, + "service", + "ensure_service", + "pending", + tenant_workspace_id=tenant_id, + scope="global", + increment_attempt=False, + ) + futures.append( + ( + executor.submit( + self._ensure_service, + job_id, + group, + tenant_id, + ), + group, + tenant_id, + ) + ) + + # A future occupies the window only through submit, shared-pacer + # backoff, and observation. Terminal return releases the worker; + # created tenants awaiting submission consume no slot. + for future, group, tenant_id in futures: + if not future.result(): + continue + if self._store.stop_requested(job_id): + continue + if not self._verify_assignments(job_id, group, tenant_id): + continue + self._store.update_tenant_group( + job_id, + group["tenant_name"], + "succeeded", + tenant_workspace_id=tenant_id, + ) + + def _execute_pending_group( + self, + job_id: str, + plan: dict, + group: dict, + tenant_data: dict, + ) -> None: + tenant_name = group["tenant_name"] + self._store.update_tenant_group(job_id, tenant_name, "running") + completed = self._execute_group(job_id, plan, group, tenant_data) + if not completed: + return + workspace_id = next( + current.get("tenant_workspace_id") + for current in self._store.get_job(job_id)["tenant_groups"] + if current["tenant_name"] == tenant_name + ) + self._store.update_tenant_group( + job_id, + tenant_name, + "succeeded", + tenant_workspace_id=workspace_id, + ) + + def _execute_group( + self, + job_id: str, + plan: dict, + group: dict, + tenant_data: dict, + ) -> bool: + try: + tenant_id = self._ensure_tenant( + job_id, plan["mode"], group, tenant_data + ) + except AdapterError: + return False + if tenant_id is None or self._store.stop_requested(job_id): + return False + return self._execute_group_after_tenant(job_id, plan, group, tenant_id) + + def _execute_group_after_tenant( + self, + job_id: str, + plan: dict, + group: dict, + tenant_id: str, + ) -> bool: + tenant_name = group["tenant_name"] + if not self._ensure_service(job_id, group, tenant_id): + return False + if self._store.stop_requested(job_id): + return False + + group_devices = [ + device + for device in self._store.get_devices(job_id) + if device["tenant_name"] == tenant_name + ] + if not group_devices: + if self._store.stop_requested(job_id): + return False + return self._verify_assignments(job_id, group, tenant_id) + subscription_keys = self._subscription_keys(plan, job_id, tenant_name) + if subscription_keys is None: + return False + if not self._run_device_assignments(job_id, group, tenant_id): + return False + if self._store.stop_requested(job_id): + return False + if not self._run_subscription_assignments(job_id, group, tenant_id, subscription_keys): + return False + if self._store.stop_requested(job_id): + return False + return self._verify_assignments(job_id, group, tenant_id) + + def _ensure_tenant( + self, + job_id: str, + mode: str, + group: dict, + tenant_data: dict, + *, + known_tenants: Optional[list[TenantInfo]] = None, + ) -> Optional[str]: + tenant_name = group["tenant_name"] + previous = self._step(job_id, tenant_name, "tenant", "ensure_tenant") + current_group = next( + item + for item in self._store.get_job(job_id)["tenant_groups"] + if item["tenant_name"] == tenant_name + ) + if previous and previous["status"] == "succeeded" and current_group.get("tenant_workspace_id"): + return current_group["tenant_workspace_id"] + if previous and previous["status"] == "waiting_rate_limit" and previous.get("wait_until"): + remaining = ( + datetime.fromisoformat(previous["wait_until"]) - self._clock.utcnow() + ).total_seconds() + self._outbound_pacer.pause(max(0.0, remaining)) + self._store.record_step( + job_id, tenant_name, "tenant", "ensure_tenant", "running", scope="global" + ) + attributes = {} + if mode == "new": + try: + attributes = { + "country": tenant_data["country"], + "address": AddressNew(**(tenant_data.get("address") or {})), + "description": tenant_data.get("description", ""), + "email": tenant_data.get("email", ""), + "phone_number": tenant_data.get("phone_number", ""), + } + except (KeyError, TypeError): + self._fail_group(job_id, tenant_name, "invalid_manifest", "Persisted tenant manifest is invalid") + return None + try: + tenant = self._retry_rate_limited( + lambda: self._adapter.ensure_tenant( + mode, + group.get("tenant_workspace_id"), + tenant_name, + **attributes, + known_tenants=known_tenants, + ), + on_exhausted=lambda exc, wait_until: self._store.record_step( + job_id, + tenant_name, + "tenant", + "ensure_tenant", + "waiting_rate_limit", + scope="global", + error={"code": exc.code, "message": exc.message}, + wait_until=wait_until, + increment_attempt=False, + ), + ) + except AdapterError as exc: + if exc.code == "rate_limited": + self._fail_group( + job_id, tenant_name, exc.code, exc.message, exc.retry_after + ) + if exc.failure_scope == "systemic": + raise + return None + self._store.record_step( + job_id, + tenant_name, + "tenant", + "ensure_tenant", + "failed", + scope="global", + error={"code": exc.code, "message": exc.message}, + ) + self._fail_group(job_id, tenant_name, exc.code, exc.message) + if exc.failure_scope == "systemic": + raise + return None + self._store.set_tenant_workspace_id(job_id, tenant_name, tenant.workspace_id) + self._store.record_step( + job_id, + tenant_name, + "tenant", + "ensure_tenant", + "succeeded", + tenant_workspace_id=tenant.workspace_id, + scope="global", + ) + return tenant.workspace_id + + def _ensure_service(self, job_id: str, group: dict, tenant_id: str) -> bool: + tenant_name = group["tenant_name"] + service_id = group["service_manager_id"] + region = group["service_region"] + previous = self._step(job_id, tenant_name, "service", "ensure_service") + if previous and previous["status"] == "succeeded": + return True + if not service_id or not region: + self._fail_group(job_id, tenant_name, "service_not_found", "Plan has no eligible service") + return False + self._store.record_step( + job_id, + tenant_name, + "service", + "ensure_service", + "submitting", + tenant_workspace_id=tenant_id, + scope="global", + increment_attempt=False, + ) + try: + self._retry_rate_limited( + lambda: self._adapter.submit_service_provisioning( + tenant_id, service_id, region + ), + on_exhausted=lambda exc, wait_until: self._record_service_wait( + job_id, + tenant_name, + tenant_id, + exc, + wait_until, + "submit", + ), + ) + except AdapterError as exc: + if exc.code != "rate_limited": + self._store.record_step( + job_id, + tenant_name, + "service", + "ensure_service", + "failed", + tenant_workspace_id=tenant_id, + scope="global", + error={"code": exc.code, "message": exc.message}, + ) + self._fail_group( + job_id, tenant_name, exc.code, exc.message, exc.retry_after + ) + return False + self._store.record_step( + job_id, + tenant_name, + "service", + "ensure_service", + "submitted", + tenant_workspace_id=tenant_id, + scope="global", + increment_attempt=False, + ) + try: + for attempt in range(_PROVISION_POLL_ATTEMPTS): + self._store.record_step( + job_id, + tenant_name, + "service", + "ensure_service", + "running", + tenant_workspace_id=tenant_id, + scope="global", + ) + self._store.record_step( + job_id, + tenant_name, + "service", + "ensure_service", + "submitted", + tenant_workspace_id=tenant_id, + scope="global", + increment_attempt=False, + ) + state = self._retry_adapter_read( + lambda: self._adapter.observe_service_provisioning( + tenant_id, service_id, region + ), + on_rate_limit_exhausted=lambda exc, wait_until: self._record_service_wait( + job_id, + tenant_name, + tenant_id, + exc, + wait_until, + "observe", + ), + ) + if state == "provisioned": + break + if state == "failed": + self._fail_service_provisioning( + job_id, tenant_name, tenant_id + ) + return False + if attempt < _PROVISION_POLL_ATTEMPTS - 1: + self._clock.sleep(_provision_poll_delay(attempt)) + else: + raise AdapterError( + "service", "service_not_ready", "Service did not become ready" + ) + except AdapterError as exc: + self._store.record_step( + job_id, + tenant_name, + "service", + "ensure_service", + "failed", + tenant_workspace_id=tenant_id, + scope="global", + error={"code": exc.code, "message": exc.message}, + ) + self._fail_group( + job_id, tenant_name, exc.code, exc.message, exc.retry_after + ) + return False + self._store.record_step( + job_id, + tenant_name, + "service", + "ensure_service", + "succeeded", + tenant_workspace_id=tenant_id, + scope="global", + ) + return True + + def _fail_service_provisioning( + self, job_id: str, tenant_name: str, tenant_id: str + ) -> None: + error = { + "code": "provisioning_failed", + "message": "Central provisioning failed", + } + self._store.record_step( + job_id, + tenant_name, + "service", + "ensure_service", + "failed", + tenant_workspace_id=tenant_id, + scope="global", + error=error, + ) + self._fail_group(job_id, tenant_name, error["code"], error["message"]) + + def _record_service_wait( + self, + job_id: str, + tenant_name: str, + tenant_id: str, + exc: AdapterError, + wait_until: str, + phase: str, + ) -> None: + self._store.record_step( + job_id, + tenant_name, + "service", + "ensure_service", + "waiting_rate_limit", + tenant_workspace_id=tenant_id, + scope="global", + error={"code": exc.code, "message": exc.message, "phase": phase}, + wait_until=wait_until, + increment_attempt=False, + ) + + def _step( + self, job_id: str, tenant_name: str, logical_key: str, operation: str + ) -> Optional[dict]: + return next( + ( + step + for step in self._store.get_steps(job_id) + if step["tenant_name"] == tenant_name + and step["logical_key"] == logical_key + and step["operation"] == operation + ), + None, + ) + + def _subscription_keys( + self, plan: dict, job_id: str, tenant_name: str + ) -> Optional[dict[str, str]]: + manifest = self._store.get_manifest_dict(job_id) + if manifest is None: + self._fail_group(job_id, tenant_name, "manifest_not_found", "Persisted manifest was not found") + return None + keys = self._manifest_subscription_key_map(manifest) + try: + return { + device["glp_id"]: keys[ + ( + tenant_name, + device.get("serial_number") or "", + device.get("mac_address") or "", + ) + ] + for device in plan["devices"] + if device["tenant_name"] == tenant_name + } + except KeyError: + self._fail_group(job_id, tenant_name, "invalid_manifest", "Persisted device mapping is invalid") + return None + + def _poll_step(self, job_id: str, tenant_name: str, step: dict) -> bool: + for attempt in range(_TRANSACTION_POLL_ATTEMPTS): + try: + # Not _retry_adapter_read: transaction_not_complete is retryable, + # which turned every poll into three back-to-back GETs. + result = self._retry_rate_limited( + lambda: ( + self._adapter.poll_transaction( + step["transaction_id"], step["transaction_origin"] + ) + if step.get("transaction_origin") + else self._adapter.poll_transaction(step["transaction_id"]) + ) + ) + break + except AdapterError as exc: + if ( + exc.code != "transaction_not_complete" + or attempt == _TRANSACTION_POLL_ATTEMPTS - 1 + ): + raise + self._clock.sleep(_TRANSACTION_POLL_INTERVAL_SECONDS) + for glp_id in result.succeeded_ids: + self._store.update_device_status( + job_id, tenant_name, glp_id, step["operation"], "succeeded" + ) + for glp_id in result.failed_ids: + self._store.update_device_status( + job_id, + tenant_name, + glp_id, + step["operation"], + "failed", + {"code": "batch_failed", "message": "Batch reported this device failed"}, + ) + if result.failed_ids: + error = {"code": "partial_write", "message": "One or more devices failed"} + self._store.record_step( + job_id, + tenant_name, + step["logical_key"], + step["operation"], + "failed", + scope=step["scope"], + error=error, + ) + self._fail_group(job_id, tenant_name, error["code"], error["message"]) + return False + self._store.record_step( + job_id, + tenant_name, + step["logical_key"], + step["operation"], + "succeeded", + scope=step["scope"], + ) + return True + + def _run_device_assignments(self, job_id: str, group: dict, tenant_id: str) -> bool: + tenant_name = group["tenant_name"] + device_ids = sorted( + device["glp_id"] + for device in self._store.get_devices(job_id) + if device["tenant_name"] == tenant_name + ) + for batch in self._batches(device_ids, write_batch_size()): + pending = [] + try: + observed_batch = self._retry_adapter_read( + lambda batch=batch: self._adapter.resolve_devices(glp_ids=batch) + ) + except AdapterError as exc: + self._fail_group(job_id, tenant_name, exc.code, exc.message) + return False + observed_by_id = {device.glp_id: device for device in observed_batch} + missing = [glp_id for glp_id in batch if glp_id not in observed_by_id] + if missing: + error = {"code": "device_not_found", "message": "Device was not found"} + for glp_id in missing: + self._store.update_device_status( + job_id, tenant_name, glp_id, "assign_devices", "failed", error + ) + self._fail_group(job_id, tenant_name, error["code"], error["message"]) + return False + for glp_id in batch: + observed = observed_by_id[glp_id] + if ( + observed.tenant_workspace_id == tenant_id + and observed.service_manager_id == group["service_manager_id"] + ): + self._store.update_device_status( + job_id, tenant_name, glp_id, "assign_devices", "already_satisfied" + ) + elif self._available_device(observed): + self._store.update_device_status( + job_id, tenant_name, glp_id, "assign_devices", "pending" + ) + pending.append(glp_id) + else: + error = {"code": "device_conflict", "message": "Device is not available for target tenant"} + self._store.update_device_status( + job_id, tenant_name, glp_id, "assign_devices", "failed", error + ) + self._fail_group(job_id, tenant_name, error["code"], error["message"]) + return False + if not pending: + continue + logical_key = f"devices:{','.join(pending)}" + if not self._submit_assignment_batch( + job_id, + tenant_name, + logical_key, + "assign_devices", + pending, + lambda ids: self._adapter.assign_devices( + ids, + tenant_id, + group["service_manager_id"], + group["service_region"], + ), + lambda ids: self._reobserve_device_assignment( + group, tenant_id, ids + ), + ): + return False + if self._store.stop_requested(job_id): + return False + return True + + @staticmethod + def _available_device(device: DeviceInfo) -> bool: + return ( + device.management == "MSP" + and device.assigned_state == "UNASSIGNED" + and not device.in_use_workspace + and not device.tenant_workspace_id + and not device.subscription + ) + + def _run_subscription_assignments( + self, + job_id: str, + group: dict, + tenant_id: str, + subscription_keys: dict[str, str], + ) -> bool: + tenant_name = group["tenant_name"] + by_key: dict[str, list[tuple[str, str]]] = {} + for device in self._store.get_devices(job_id): + if device["tenant_name"] != tenant_name: + continue + by_key.setdefault(subscription_keys[device["glp_id"]], []).append( + (device["glp_id"], device["subscription_id"]) + ) + + for key, assignments in by_key.items(): + for batch in self._batches( + sorted(assignments), write_batch_size() + ): + pending = [] + batch_ids = [glp_id for glp_id, _ in batch] + try: + observed_batch = self._retry_adapter_read( + lambda batch_ids=batch_ids: self._adapter.resolve_devices( + glp_ids=batch_ids + ) + ) + except AdapterError as exc: + self._fail_group(job_id, tenant_name, exc.code, exc.message) + return False + observed_by_id = {device.glp_id: device for device in observed_batch} + missing = [glp_id for glp_id in batch_ids if glp_id not in observed_by_id] + if missing: + error = {"code": "device_not_found", "message": "Device was not found"} + for glp_id in missing: + self._store.update_device_status( + job_id, + tenant_name, + glp_id, + "assign_subscriptions", + "failed", + error, + ) + self._fail_group(job_id, tenant_name, error["code"], error["message"]) + return False + for glp_id, subscription_id in batch: + observed = observed_by_id[glp_id] + if ( + observed.tenant_workspace_id != tenant_id + or observed.service_manager_id != group["service_manager_id"] + ): + self._fail_group(job_id, tenant_name, "device_conflict", "Device is not assigned to target service") + return False + if observed.subscription == subscription_id: + self._store.update_device_status( + job_id, tenant_name, glp_id, "assign_subscriptions", "already_satisfied" + ) + elif observed.subscription: + error = {"code": "subscription_conflict", "message": "Device has another subscription"} + self._store.update_device_status( + job_id, tenant_name, glp_id, "assign_subscriptions", "failed", error + ) + self._fail_group(job_id, tenant_name, error["code"], error["message"]) + return False + else: + self._store.update_device_status( + job_id, tenant_name, glp_id, "assign_subscriptions", "pending" + ) + pending.append((glp_id, subscription_id)) + if not pending: + continue + try: + subscription = self._execution_subscription_cache.get(key) + if subscription is None: + subscription = self._retry_adapter_read( + lambda: self._adapter.resolve_subscription(key) + ) + self._execution_subscription_cache[key] = subscription + available = _parse_nonneg_int(subscription.available_quantity) + quantity = _parse_nonneg_int(subscription.quantity) + remaining = self._remaining_key_demand(job_id, key) + eligible = ( + subscription.status == "STARTED" + and subscription.product_type == "DEVICE" + and _within_dates(subscription, self._adapter.now()) + and available is not None + and quantity is not None + and available >= remaining + ) + except (AdapterError, ValueError) as exc: + if isinstance(exc, AdapterError): + self._fail_group(job_id, tenant_name, exc.code, exc.message) + return False + eligible = False + if not eligible: + self._fail_group( + job_id, + tenant_name, + "subscription_not_eligible", + "Subscription cannot satisfy remaining aggregate demand", + ) + return False + ids = [glp_id for glp_id, _ in pending] + logical_key = f"subscriptions:{','.join(ids)}" + if not self._submit_assignment_batch( + job_id, + tenant_name, + logical_key, + "assign_subscriptions", + ids, + lambda batch_ids: self._adapter.assign_subscriptions( + [ + (glp_id, subscription_id) + for glp_id, subscription_id in pending + if glp_id in batch_ids + ], + ), + lambda batch_ids: self._reobserve_subscription_assignment( + group, + tenant_id, + dict(pending), + batch_ids, + ), + ): + return False + if self._store.stop_requested(job_id): + return False + return True + + def _reobserve_device_assignment( + self, group: dict, tenant_id: str, device_ids: list[str] + ) -> set[str]: + observed = self._retry_adapter_read( + lambda: self._adapter.resolve_devices(glp_ids=device_ids) + ) + by_id = {device.glp_id: device for device in observed} + satisfied = set() + for glp_id in device_ids: + device = by_id.get(glp_id) + if device is None: + raise AdapterError( + "execution.devices", "device_not_found", "Device was not found" + ) + if ( + device.tenant_workspace_id == tenant_id + and device.service_manager_id == group["service_manager_id"] + ): + satisfied.add(glp_id) + elif not self._available_device(device): + raise AdapterError( + "execution.devices", + "device_conflict", + "Device is not available for target tenant", + ) + return satisfied + + def _reobserve_subscription_assignment( + self, + group: dict, + tenant_id: str, + subscriptions: dict[str, str], + device_ids: list[str], + ) -> set[str]: + observed = self._retry_adapter_read( + lambda: self._adapter.resolve_devices(glp_ids=device_ids) + ) + by_id = {device.glp_id: device for device in observed} + satisfied = set() + for glp_id in device_ids: + device = by_id.get(glp_id) + if device is None: + raise AdapterError( + "execution.devices", "device_not_found", "Device was not found" + ) + if ( + device.tenant_workspace_id != tenant_id + or device.service_manager_id != group["service_manager_id"] + ): + raise AdapterError( + "execution.devices", + "device_conflict", + "Device is not assigned to target service", + ) + if device.subscription == subscriptions[glp_id]: + satisfied.add(glp_id) + elif device.subscription: + raise AdapterError( + "execution.subscriptions", + "subscription_conflict", + "Device has another subscription", + ) + return satisfied + + def _submit_assignment_batch( + self, + job_id: str, + tenant_name: str, + logical_key: str, + operation: str, + device_ids: list[str], + write: Callable[[list[str]], str], + reobserve: Callable[[list[str]], set[str]], + ) -> bool: + remaining = list(device_ids) + for attempt in range(_AMBIGUOUS_WRITE_ATTEMPTS): + self._store.record_step( + job_id, tenant_name, logical_key, operation, "running" + ) + transaction_id = None + origin = None + try: + transaction_id = write(remaining) + origin = self._adapter.transaction_origin(transaction_id) + self._store.record_step( + job_id, + tenant_name, + logical_key, + operation, + "running", + transaction_id=transaction_id, + transaction_origin=origin, + increment_attempt=False, + ) + return self._poll_step( + job_id, + tenant_name, + { + "logical_key": logical_key, + "operation": operation, + "scope": "batch", + "transaction_id": transaction_id, + "transaction_origin": origin, + }, + ) + except AdapterError as exc: + ambiguous = bool( + transaction_id + or exc.transaction_id + or exc.code in {"ambiguous_write", "transaction_not_complete"} + ) + if not ambiguous: + wait_until = None + status = "failed" + if exc.code == "rate_limited": + delay = exc.retry_after if exc.retry_after is not None else 2.0 + wait_until = self._outbound_pacer.pause(delay) + status = "waiting_rate_limit" + error = {"code": exc.code, "message": exc.message} + if exc.retry_after is not None: + error["retry_after"] = exc.retry_after + for glp_id in remaining: + self._store.update_device_status( + job_id, tenant_name, glp_id, operation, "failed", error + ) + self._store.record_step( + job_id, + tenant_name, + logical_key, + operation, + status, + transaction_id=exc.transaction_id, + error=error, + wait_until=wait_until, + ) + self._fail_group( + job_id, + tenant_name, + exc.code, + exc.message, + exc.retry_after, + ) + return False + + error = {"code": exc.code, "message": exc.message} + for glp_id in remaining: + self._store.update_device_status( + job_id, tenant_name, glp_id, operation, "unknown", error + ) + self._store.record_step( + job_id, + tenant_name, + logical_key, + operation, + "unknown", + transaction_id=transaction_id or exc.transaction_id, + transaction_origin=origin, + error=error, + ) + self._clock.sleep(_TRANSACTION_POLL_INTERVAL_SECONDS) + try: + satisfied = reobserve(remaining) + except AdapterError as observed_error: + error = { + "code": observed_error.code, + "message": observed_error.message, + } + for glp_id in remaining: + self._store.update_device_status( + job_id, + tenant_name, + glp_id, + operation, + "failed", + error, + ) + self._store.record_step( + job_id, + tenant_name, + logical_key, + operation, + "failed", + error=error, + ) + self._fail_group( + job_id, + tenant_name, + observed_error.code, + observed_error.message, + ) + return False + for glp_id in satisfied: + self._store.update_device_status( + job_id, + tenant_name, + glp_id, + operation, + "already_satisfied", + ) + remaining = [ + glp_id for glp_id in remaining if glp_id not in satisfied + ] + if not remaining: + self._store.record_step( + job_id, + tenant_name, + logical_key, + operation, + "already_satisfied", + ) + return True + if attempt < _AMBIGUOUS_WRITE_ATTEMPTS - 1: + continue + error = {"code": exc.code, "message": exc.message} + for glp_id in remaining: + self._store.update_device_status( + job_id, tenant_name, glp_id, operation, "failed", error + ) + self._store.record_step( + job_id, + tenant_name, + logical_key, + operation, + "failed", + error=error, + ) + self._fail_group(job_id, tenant_name, exc.code, exc.message) + return False + raise AssertionError("Ambiguous write retry loop unexpectedly completed") + + def _remaining_key_demand(self, job_id: str, key: str) -> int: + plan = self._store.get_plan_dict(job_id) + manifest = self._store.get_manifest_dict(job_id) + job = self._store.get_job(job_id) + if plan is None or manifest is None or job is None: + return 0 + active_groups = { + group["tenant_name"] + for group in job["tenant_groups"] + if group["status"] in ("pending", "running") + } + identifiers = self._manifest_subscription_key_map(manifest) + planned = { + device["glp_id"] + for device in plan["devices"] + if device["tenant_name"] in active_groups + and identifiers[ + ( + device["tenant_name"], + device.get("serial_number") or "", + device.get("mac_address") or "", + ) + ] == key + } + return sum( + device["glp_id"] in planned + and device["subscription_status"] not in ("succeeded", "already_satisfied") + for device in self._store.get_devices(job_id) + ) + + def _verify_assignments(self, job_id: str, group: dict, tenant_id: str) -> bool: + tenant_name = group["tenant_name"] + self._store.record_step( + job_id, tenant_name, "verify", "verify", "running", scope="global" + ) + mismatched = False + devices = [ + device + for device in self._store.get_devices(job_id) + if device["tenant_name"] == tenant_name + ] + device_ids = [device["glp_id"] for device in devices] + try: + observed_devices = self._retry_adapter_read( + lambda: self._adapter.resolve_devices(glp_ids=device_ids) + ) + except AdapterError as exc: + self._store.record_step( + job_id, + tenant_name, + "verify", + "verify", + "failed", + scope="global", + error={"code": exc.code, "message": exc.message}, + ) + self._fail_group(job_id, tenant_name, exc.code, exc.message) + return False + observed_by_id = {device.glp_id: device for device in observed_devices} + for device in devices: + observed = observed_by_id.get(device["glp_id"]) + if observed is None: + mismatched = True + self._store.update_device_status( + job_id, + tenant_name, + device["glp_id"], + "assign_subscriptions", + "failed", + {"code": "device_not_found", "message": "Device was not found"}, + ) + continue + if ( + observed.tenant_workspace_id != tenant_id + or observed.service_manager_id != group["service_manager_id"] + or observed.subscription != device["subscription_id"] + ): + mismatched = True + self._store.update_device_status( + job_id, + tenant_name, + device["glp_id"], + "assign_subscriptions", + "failed", + {"code": "verification_failed", "message": "Device does not match the planned final assignment"}, + ) + if mismatched: + error = {"code": "verification_failed", "message": "One or more devices do not match the planned final assignment"} + self._store.record_step( + job_id, + tenant_name, + "verify", + "verify", + "failed", + scope="global", + error=error, + ) + self._fail_group(job_id, tenant_name, error["code"], error["message"]) + return False + self._store.record_step( + job_id, tenant_name, "verify", "verify", "succeeded", scope="global" + ) + return True + + def _fail_group( + self, + job_id: str, + tenant_name: str, + code: str, + message: str, + retry_after: Optional[float] = None, + ) -> None: + error = {"code": code, "message": message} + if retry_after is not None: + error["retry_after"] = retry_after + self._store.update_tenant_group( + job_id, + tenant_name, + "failed", + error=error, + ) + + def _retry_adapter_read( + self, + read: Callable[[], T], + *, + on_rate_limit_exhausted: Optional[ + Callable[[AdapterError, str], None] + ] = None, + ) -> T: + for attempt in range(self._MAX_READ_RETRIES): + try: + return self._retry_rate_limited( + read, on_exhausted=on_rate_limit_exhausted + ) + except AdapterError as exc: + if not exc.retryable or attempt == self._MAX_READ_RETRIES - 1: + raise + raise AssertionError("Retry loop unexpectedly completed") + + def _retry_rate_limited( + self, + call: Callable[[], T], + *, + on_exhausted: Optional[Callable[[AdapterError, str], None]] = None, + ) -> T: + for attempt in range(3): + try: + return call() + except AdapterError as exc: + if exc.code != "rate_limited": + raise + delay = ( + exc.retry_after + if exc.retry_after is not None + else _RATE_LIMIT_BACKOFF_SECONDS[attempt] + ) + wait_until = self._outbound_pacer.pause(delay) + if attempt == 2: + if on_exhausted is not None: + on_exhausted(exc, wait_until) + raise + raise AssertionError("Rate-limit retry loop unexpectedly completed") + + @staticmethod + def _batches(items: list, size: int) -> list[list]: + return [items[index:index + size] for index in range(0, len(items), size)] diff --git a/msp-onboarding/msp_onboarding/models.py b/msp-onboarding/msp_onboarding/models.py new file mode 100644 index 0000000..2a5b3af --- /dev/null +++ b/msp-onboarding/msp_onboarding/models.py @@ -0,0 +1,256 @@ +"""Shared data models for MSP onboarding.""" +from __future__ import annotations + +import hashlib +import json +from dataclasses import asdict, dataclass +from typing import Optional + + +TERMINAL_JOB_STATUSES = ("succeeded", "completed_with_errors", "failed", "stopped") + + +@dataclass +class ValidationError: + path: str + code: str + message: str + + +@dataclass +class AddressNew: + street_address: str = "" + street_address_complement: str = "" + city: str = "" + state_or_region: str = "" + postal_code: str = "" + + +@dataclass +class ServiceRef: + service_manager_id: str + region: str + + +@dataclass +class TenantNew: + name: str + country: str + service: Optional[ServiceRef] = None + description: str = "" + email: str = "" + phone_number: str = "" + address: Optional[AddressNew] = None + + +@dataclass +class TenantExisting: + name: str + workspace_id: str + service: Optional[ServiceRef] = None + + +@dataclass +class ManifestDevice: + tenant: str + subscription_key: str + serial_number: str = "" # normalized uppercase + mac_address: str = "" # normalized lowercase colon-separated + + +@dataclass +class Manifest: + version: int + mode: str # "new" or "existing" + tenants: list[TenantNew | TenantExisting] + devices: list[ManifestDevice] + + def canonical_hash(self) -> str: + """SHA-256 over normalized v2 data, retaining tenant manifest order.""" + data = asdict(self) + data["devices"].sort( + key=lambda d: ( + d["tenant"], + d["serial_number"], + d["mac_address"], + d["subscription_key"], + ) + ) + return hashlib.sha256( + json.dumps(data, sort_keys=True, separators=(",", ":")).encode() + ).hexdigest() + + +# --------------------------------------------------------------------------- +# Adapter data types (returned by AdapterProtocol implementations) +# --------------------------------------------------------------------------- + +@dataclass +class TenantInfo: + workspace_id: str + workspace_name: str + ownership: str + + +@dataclass +class ServiceInfo: + service_manager_id: str + region: str + name: str = "" + region_display_name: str = "" + + def __post_init__(self) -> None: + if not self.region_display_name: + self.region_display_name = self.region + + +@dataclass +class DeviceInfo: + glp_id: str + serial_number: str + mac_address: str + management: str + assigned_state: str + device_type: str = "" + in_use_workspace: Optional[str] = None + tenant_workspace_id: Optional[str] = None + service_manager_id: Optional[str] = None + subscription: Optional[str] = None + + +@dataclass +class SubscriptionInfo: + subscription_id: str + key: str + status: str # e.g. "STARTED" + product_type: str # e.g. "DEVICE" + available_quantity: str # decimal string per GLP API + quantity: str + start_date: Optional[str] = None # ISO date YYYY-MM-DD + end_date: Optional[str] = None + subscription_type: str = "" # e.g. "CENTRAL_AP" — AP/SWITCH/GW text identifies device type + tier_description: str = "" # e.g. "Foundation AP" + + +@dataclass +class TransactionResult: + transaction_id: str + succeeded_ids: list[str] + failed_ids: list[str] + + +# --------------------------------------------------------------------------- +# Plan and job models +# --------------------------------------------------------------------------- + +@dataclass +class DevicePlan: + tenant_name: str + tenant_workspace_id: Optional[str] + glp_id: str + serial_number: Optional[str] + mac_address: Optional[str] + subscription_key: str # full key stored internally; always redacted in to_dict() + subscription_id: str + + def to_dict(self) -> dict: + return { + "tenant_name": self.tenant_name, + "tenant_workspace_id": self.tenant_workspace_id, + "glp_id": self.glp_id, + "serial_number": self.serial_number, + "mac_address": self.mac_address, + "subscription_key": "***", + "subscription_id": self.subscription_id, + } + + +@dataclass +class TenantGroupRollup: + tenant_name: str + tenant_workspace_id: Optional[str] + service_manager_id: Optional[str] + service_region: Optional[str] + status: str + device_count: int + errors: list[ValidationError] + last_error: Optional[dict] = None + service_name: Optional[str] = None + service_region_display_name: Optional[str] = None + + def to_dict(self) -> dict: + return { + "tenant_name": self.tenant_name, + "tenant_workspace_id": self.tenant_workspace_id, + "service_manager_id": self.service_manager_id, + "service_region": self.service_region, + "service_name": self.service_name, + "service_region_display_name": self.service_region_display_name, + "status": self.status, + "device_count": self.device_count, + "errors": [asdict(error) for error in self.errors], + "last_error": self.last_error, + } + + +@dataclass +class Plan: + job_id: str + manifest_hash: str + plan_hash: str + mode: str + tenant_groups: list[TenantGroupRollup] + devices: list[DevicePlan] + errors: list[ValidationError] + created_at: str + + def to_dict(self) -> dict: + return { + "job_id": self.job_id, + "manifest_hash": self.manifest_hash, + "plan_hash": self.plan_hash, + "mode": self.mode, + "tenant_groups": [group.to_dict() for group in self.tenant_groups], + "devices": [device.to_dict() for device in self.devices], + "errors": [asdict(error) for error in self.errors], + "created_at": self.created_at, + } + + @staticmethod + def compute_hash( + manifest_hash: str, + mode: str, + tenant_groups: list[TenantGroupRollup], + devices: list[DevicePlan], + errors: list[ValidationError], + ) -> str: + groups = [] + for group in tenant_groups: + group_data = asdict(group) + group_data["errors"].sort( + key=lambda error: ( + error["path"], + error["code"], + error["message"], + ) + ) + groups.append(group_data) + data = { + "manifest_hash": manifest_hash, + "mode": mode, + "tenant_groups": groups, + "devices": sorted( + [asdict(device) for device in devices], + key=lambda device: (device["tenant_name"], device["glp_id"]), + ), + "errors": sorted( + [asdict(error) for error in errors], + key=lambda error: ( + error["path"], + error["code"], + error["message"], + ), + ), + } + canonical = json.dumps(data, sort_keys=True, separators=(",", ":")) + return hashlib.sha256(canonical.encode()).hexdigest() diff --git a/msp-onboarding/msp_onboarding/parser.py b/msp-onboarding/msp_onboarding/parser.py new file mode 100644 index 0000000..cbc2ec5 --- /dev/null +++ b/msp-onboarding/msp_onboarding/parser.py @@ -0,0 +1,745 @@ +"""YAML manifest and CSV parsing with strict validation and normalization.""" +from __future__ import annotations + +import csv +from dataclasses import dataclass +import io +import re +from typing import Iterable, Optional + +import yaml + +from .models import ( + AddressNew, + Manifest, + ManifestDevice, + ServiceRef, + TenantExisting, + TenantNew, + ValidationError, +) + + +class ParseError(Exception): + def __init__(self, errors: list[ValidationError]): + self.errors = errors + super().__init__(f"{len(errors)} validation error(s)") + + +_TOP_FIELDS = frozenset({"version", "mode", "tenants", "devices"}) +_TENANT_NEW_FIELDS = frozenset( + {"name", "country", "description", "email", "phone_number", "address", "service"} +) +_TENANT_EXISTING_FIELDS = frozenset({"name", "workspace_id", "service"}) +_ADDRESS_FIELDS = frozenset( + { + "street_address", + "street_address_complement", + "city", + "state_or_region", + "postal_code", + } +) +_SERVICE_FIELDS = frozenset({"service_manager_id", "region"}) +_DEVICE_FIELDS = frozenset( + {"serial_number", "mac_address", "subscription_key", "tenant"} +) +_TENANT_CSV_REQUIRED_COLUMNS = frozenset({"name", "country"}) +_TENANT_CSV_OPTIONAL_COLUMNS = frozenset( + { + "description", + "email", + "phone_number", + "street_address", + "street_address_complement", + "city", + "state_or_region", + "postal_code", + "application", + "region", + } +) +_TENANT_CSV_COLUMNS = _TENANT_CSV_REQUIRED_COLUMNS | _TENANT_CSV_OPTIONAL_COLUMNS +_DEVICE_CSV_COLUMNS = ( + "serial_number", + "mac_address", + "subscription_key", + "tenant", +) +_WORKSPACE_ID = re.compile( + r"[0-9A-Fa-f]{8}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-" + r"[0-9A-Fa-f]{4}-[0-9A-Fa-f]{12}" +) +_REGION_SEPARATOR = re.compile(r"[\s_-]+") + + +@dataclass +class TenantCsvService: + application: str + region: str + + +@dataclass +class TenantCsvImport: + tenants: list[TenantNew] + services: list[TenantCsvService] + + +def normalize_serial(serial: str) -> str: + return serial.strip().upper() + + +def normalize_mac(mac: str) -> str: + cleaned = mac.strip().lower().replace(":", "").replace("-", "").replace(".", "") + if len(cleaned) != 12 or not re.fullmatch(r"[0-9a-f]{12}", cleaned): + raise ValueError(f"Invalid MAC address: {mac!r}") + return ":".join(cleaned[i : i + 2] for i in range(0, 12, 2)) + + +def normalize_region(region: str) -> str: + """Normalize API codes and GLP display labels to the manifest code shape.""" + return _REGION_SEPARATOR.sub("-", region.strip().casefold()).strip("-") + + +def _check_unknown( + raw: dict, allowed: frozenset[str], path: str, errors: list[ValidationError] +) -> None: + for key in raw: + if key not in allowed: + full_path = f"{path}.{key}" if path else str(key) + errors.append( + ValidationError( + path=full_path, + code="unknown_field", + message=f"Unknown field: {key!r}", + ) + ) + + +def _req_str(raw: dict, field: str, path: str, errors: list[ValidationError]) -> str: + value = raw.get(field) + field_path = f"{path}.{field}" + if value is None: + errors.append( + ValidationError(field_path, "missing_field", f"{field} is required") + ) + return "" + if not isinstance(value, str): + errors.append( + ValidationError( + field_path, + "type_error", + f"{field} must be a string, got {type(value).__name__}", + ) + ) + return "" + value = value.strip() + if not value: + errors.append( + ValidationError(field_path, "missing_field", f"{field} is required") + ) + return value + + +def _opt_str(raw: dict, field: str, path: str, errors: list[ValidationError]) -> str: + value = raw.get(field) + if value is None: + return "" + if not isinstance(value, str): + errors.append( + ValidationError( + f"{path}.{field}", + "type_error", + f"{field} must be a string, got {type(value).__name__}", + ) + ) + return "" + return value.strip() + + +def _parse_address( + raw: object, path: str, errors: list[ValidationError] +) -> Optional[AddressNew]: + if raw is None: + return None + if not isinstance(raw, dict): + errors.append( + ValidationError(path, "invalid_address", "address must be a mapping") + ) + return None + before = len(errors) + _check_unknown(raw, _ADDRESS_FIELDS, path, errors) + address = AddressNew( + street_address=_opt_str(raw, "street_address", path, errors), + street_address_complement=_opt_str( + raw, "street_address_complement", path, errors + ), + city=_opt_str(raw, "city", path, errors), + state_or_region=_opt_str(raw, "state_or_region", path, errors), + postal_code=_opt_str(raw, "postal_code", path, errors), + ) + return address if len(errors) == before else None + + +def _parse_service( + raw: object, path: str, errors: list[ValidationError] +) -> Optional[ServiceRef]: + if raw is None: + return None + if not isinstance(raw, dict): + errors.append( + ValidationError(path, "invalid_service", "service must be a mapping") + ) + return None + before = len(errors) + _check_unknown(raw, _SERVICE_FIELDS, path, errors) + region = _req_str(raw, "region", path, errors) + service = ServiceRef( + service_manager_id=_req_str(raw, "service_manager_id", path, errors), + region=normalize_region(region) if region else "", + ) + return service if len(errors) == before else None + + +def _parse_tenant_new( + raw: dict, index: int, errors: list[ValidationError] +) -> Optional[TenantNew]: + path = f"tenants[{index}]" + before = len(errors) + _check_unknown(raw, _TENANT_NEW_FIELDS, path, errors) + name = _req_str(raw, "name", path, errors) + country = _req_str(raw, "country", path, errors) + if country and not re.fullmatch(r"[A-Z]{2}", country): + errors.append( + ValidationError( + f"{path}.country", + "invalid_country_code", + "country must be a 2-letter uppercase ISO code", + ) + ) + tenant = TenantNew( + name=name, + country=country, + service=_parse_service(raw.get("service"), f"{path}.service", errors), + description=_opt_str(raw, "description", path, errors), + email=_opt_str(raw, "email", path, errors), + phone_number=_opt_str(raw, "phone_number", path, errors), + address=_parse_address(raw.get("address"), f"{path}.address", errors), + ) + return tenant if len(errors) == before else None + + +def _parse_tenant_existing( + raw: dict, index: int, errors: list[ValidationError] +) -> Optional[TenantExisting]: + path = f"tenants[{index}]" + before = len(errors) + _check_unknown(raw, _TENANT_EXISTING_FIELDS, path, errors) + name = _req_str(raw, "name", path, errors) + workspace_id = _req_str(raw, "workspace_id", path, errors) + if workspace_id and not _WORKSPACE_ID.fullmatch(workspace_id): + errors.append( + ValidationError( + f"{path}.workspace_id", + "invalid_workspace_id", + "workspace_id must be a 36-character hyphenated UUID", + ) + ) + tenant = TenantExisting( + name=name, + workspace_id=workspace_id, + service=_parse_service(raw.get("service"), f"{path}.service", errors), + ) + return tenant if len(errors) == before else None + + +def _parse_device( + raw: dict, + index: int, + tenant_names: set[str], + seen_serials: dict[str, int], + seen_macs: dict[str, int], + errors: list[ValidationError], +) -> Optional[ManifestDevice]: + path = f"devices[{index}]" + before = len(errors) + _check_unknown(raw, _DEVICE_FIELDS, path, errors) + serial_raw = _opt_str(raw, "serial_number", path, errors) + mac_raw = _opt_str(raw, "mac_address", path, errors) + key = _opt_str(raw, "subscription_key", path, errors) + tenant = _req_str(raw, "tenant", path, errors) + if tenant and tenant not in tenant_names: + errors.append( + ValidationError( + f"{path}.tenant", + "unknown_tenant_reference", + f"tenant must match a manifest tenant name, got {tenant!r}", + ) + ) + if len(errors) > before: + return None + + serial, mac = _parse_identifiers( + serial_raw, + mac_raw, + path, + index, + "devices", + seen_serials, + seen_macs, + errors, + ) + if len(errors) > before: + return None + return ManifestDevice( + tenant=tenant, + subscription_key=key, + serial_number=serial, + mac_address=mac, + ) + + +def _parse_identifiers( + serial_raw: str, + mac_raw: str, + path: str, + source_index: int, + source_label: str, + seen_serials: dict[str, int], + seen_macs: dict[str, int], + errors: list[ValidationError], +) -> tuple[str, str]: + if serial_raw and mac_raw: + errors.append( + ValidationError( + path, + "identifier_conflict", + "Provide either serial_number or mac_address, not both", + ) + ) + return "", "" + if not serial_raw and not mac_raw: + errors.append( + ValidationError( + path, + "missing_identifier", + "Provide either serial_number or mac_address", + ) + ) + return "", "" + + if serial_raw: + serial = normalize_serial(serial_raw) + if serial in seen_serials: + original = seen_serials[serial] + location = ( + f"devices[{original}]" if source_label == "devices" else f"row {original}" + ) + errors.append( + ValidationError( + f"{path}.serial_number", + "duplicate_identifier", + f"Duplicate serial_number (also at {location})", + ) + ) + return "", "" + seen_serials[serial] = source_index + return serial, "" + + try: + mac = normalize_mac(mac_raw) + except ValueError: + errors.append( + ValidationError( + f"{path}.mac_address", + "invalid_mac", + f"Invalid MAC address: {mac_raw!r}", + ) + ) + return "", "" + if mac in seen_macs: + original = seen_macs[mac] + location = ( + f"devices[{original}]" if source_label == "devices" else f"row {original}" + ) + errors.append( + ValidationError( + f"{path}.mac_address", + "duplicate_identifier", + f"Duplicate mac_address (also at {location})", + ) + ) + return "", "" + seen_macs[mac] = source_index + return "", mac + + +def parse_yaml_manifest(yaml_text: str) -> Manifest: + """Parse and validate a strict schema-v2 YAML manifest.""" + errors: list[ValidationError] = [] + try: + raw = yaml.safe_load(yaml_text) + except yaml.YAMLError as exc: + raise ParseError([ValidationError("", "yaml_parse_error", str(exc))]) + if not isinstance(raw, dict): + raise ParseError( + [ValidationError("", "invalid_manifest", "Manifest must be a YAML mapping")] + ) + + _check_unknown(raw, _TOP_FIELDS, "", errors) + version = raw.get("version") + if not ( + isinstance(version, int) + and not isinstance(version, bool) + and version == 2 + ): + errors.append( + ValidationError( + "version", "invalid_version", f"version must be 2, got {version!r}" + ) + ) + + mode = raw.get("mode") + if mode not in ("new", "existing"): + errors.append( + ValidationError( + "mode", + "invalid_mode", + f"mode must be 'new' or 'existing', got {mode!r}", + ) + ) + + tenants: list[TenantNew | TenantExisting] = [] + tenant_names: set[str] = set() + name_indices: dict[str, int] = {} + tenants_raw = raw.get("tenants") + if not isinstance(tenants_raw, list): + errors.append( + ValidationError("tenants", "invalid_tenants", "tenants must be a list") + ) + else: + if not tenants_raw: + errors.append( + ValidationError( + "tenants", "missing_tenants", "At least one tenant is required" + ) + ) + if len(tenants_raw) > 50: + errors.append( + ValidationError( + "tenants", + "too_many_tenants", + "At most 50 tenants are allowed", + ) + ) + for index, tenant_raw in enumerate(tenants_raw): + if not isinstance(tenant_raw, dict): + errors.append( + ValidationError( + f"tenants[{index}]", + "invalid_tenant", + "tenant must be a mapping", + ) + ) + continue + tenant = None + if mode == "new": + tenant = _parse_tenant_new(tenant_raw, index, errors) + elif mode == "existing": + tenant = _parse_tenant_existing(tenant_raw, index, errors) + name = tenant_raw.get("name") + if isinstance(name, str): + name = name.strip() + if name and name in name_indices: + errors.append( + ValidationError( + f"tenants[{index}].name", + "duplicate_tenant_name", + f"Duplicate tenant name (also at tenants[{name_indices[name]}].name)", + ) + ) + elif name: + name_indices[name] = index + tenant_names.add(name) + if tenant is not None: + tenants.append(tenant) + + devices: list[ManifestDevice] = [] + devices_raw = raw.get("devices", []) + if not isinstance(devices_raw, list): + errors.append( + ValidationError("devices", "invalid_devices", "devices must be a list") + ) + else: + if len(devices_raw) > 1000: + errors.append( + ValidationError( + "devices", "too_many_devices", "At most 1000 devices are allowed" + ) + ) + if mode == "new" and devices_raw: + errors.append( + ValidationError( + "devices", + "devices_not_allowed", + "devices must be empty for mode 'new'", + ) + ) + seen_serials: dict[str, int] = {} + seen_macs: dict[str, int] = {} + for index, device_raw in enumerate(devices_raw): + if not isinstance(device_raw, dict): + errors.append( + ValidationError( + f"devices[{index}]", + "invalid_device", + "device must be a mapping", + ) + ) + continue + device = _parse_device( + device_raw, + index, + tenant_names, + seen_serials, + seen_macs, + errors, + ) + if device is not None: + devices.append(device) + + if errors: + raise ParseError(errors) + return Manifest(version=2, mode=mode, tenants=tenants, devices=devices) + + +def _csv_header(csv_text: str | bytes) -> tuple[list[str], list[list[str]]]: + if isinstance(csv_text, bytes): + try: + csv_text = csv_text.decode("utf-8-sig") + except UnicodeError as exc: + raise ParseError( + [ + ValidationError( + "csv", + "invalid_encoding", + "CSV must be valid UTF-8 text", + ) + ] + ) from exc + normalized = ( + csv_text.removeprefix("\ufeff") + .replace("\r\n", "\n") + .replace("\r", "\n") + ) + if not normalized.strip(): + raise ParseError([ValidationError("csv", "empty_csv", "CSV is empty")]) + reader = csv.reader(io.StringIO(normalized, newline=""), strict=True) + try: + header = next(reader) + rows = list(reader) + except StopIteration: + raise ParseError([ValidationError("csv", "empty_csv", "CSV is empty")]) + except csv.Error as exc: + raise ParseError( + [ + ValidationError( + "csv", + "invalid_csv", + f"CSV could not be parsed: {exc}", + ) + ] + ) from exc + while rows and all(not value.strip() for value in rows[-1]): + rows.pop() + return [column.strip() for column in header], rows + + +def parse_csv_tenant_import(csv_text: str | bytes) -> TenantCsvImport: + """Parse new-tenant CSV rows and their optional Central service hints.""" + header, rows = _csv_header(csv_text) + duplicates = sorted({column for column in header if header.count(column) > 1}) + unknown = sorted(set(header) - _TENANT_CSV_COLUMNS) + missing = sorted(_TENANT_CSV_REQUIRED_COLUMNS - set(header)) + if duplicates or unknown or missing: + details = [] + if missing: + details.append(f"missing: {', '.join(missing)}") + if unknown: + details.append(f"unknown: {', '.join(unknown)}") + if duplicates: + details.append(f"duplicate: {', '.join(duplicates)}") + raise ParseError( + [ + ValidationError( + "csv.header", + "invalid_header", + "Tenant CSV requires name,country and only supported optional columns; " + + "; ".join(details), + ) + ] + ) + + errors: list[ValidationError] = [] + tenants: list[TenantNew] = [] + services: list[TenantCsvService] = [] + seen_names: dict[str, int] = {} + for row_number, row in enumerate(rows, start=2): + path = f"csv.rows[{row_number}]" + if len(row) != len(header): + errors.append( + ValidationError( + path, + "invalid_row", + f"Row {row_number} must have exactly {len(header)} columns", + ) + ) + continue + raw = {column: value for column, value in zip(header, row)} + before = len(errors) + name = _req_str(raw, "name", path, errors) + country = _req_str(raw, "country", path, errors) + if country and not re.fullmatch(r"[A-Z]{2}", country): + errors.append( + ValidationError( + f"{path}.country", + "invalid_country_code", + "country must be a 2-letter uppercase ISO code", + ) + ) + if name in seen_names: + errors.append( + ValidationError( + f"{path}.name", + "duplicate_tenant_name", + f"Duplicate tenant name (also at row {seen_names[name]})", + ) + ) + elif name: + seen_names[name] = row_number + + application = _opt_str(raw, "application", path, errors) + region = _opt_str(raw, "region", path, errors) + if bool(application) != bool(region): + errors.append( + ValidationError( + f"{path}.service", + "service_incomplete", + "application and region must both be provided or both be blank", + ) + ) + + address = AddressNew( + street_address=_opt_str(raw, "street_address", path, errors), + street_address_complement=_opt_str( + raw, "street_address_complement", path, errors + ), + city=_opt_str(raw, "city", path, errors), + state_or_region=_opt_str(raw, "state_or_region", path, errors), + postal_code=_opt_str(raw, "postal_code", path, errors), + ) + if len(errors) == before: + services.append( + TenantCsvService( + application=application, + region=normalize_region(region) if region else "", + ) + ) + tenants.append( + TenantNew( + name=name, + country=country, + description=_opt_str(raw, "description", path, errors), + email=_opt_str(raw, "email", path, errors), + phone_number=_opt_str(raw, "phone_number", path, errors), + address=address, + ) + ) + if errors: + raise ParseError(errors) + return TenantCsvImport(tenants=tenants, services=services) + + +def parse_csv_tenants(csv_text: str | bytes) -> list[TenantNew]: + """Parse new-tenant CSV rows into normalized TenantNew values.""" + return parse_csv_tenant_import(csv_text).tenants + + +def parse_csv_devices( + csv_text: str | bytes, tenant_names: Optional[Iterable[str]] = None +) -> list[ManifestDevice]: + """Parse device CSV rows, optionally validating tenant names in context.""" + header, rows = _csv_header(csv_text) + required = [c for c in _DEVICE_CSV_COLUMNS if c != "mac_address"] + valid = header in (list(_DEVICE_CSV_COLUMNS), required) + if not valid: + raise ParseError( + [ + ValidationError( + "csv.header", + "invalid_header", + f"CSV must have columns: {', '.join(required)} " + "(mac_address optional after serial_number); " + f"got: {', '.join(header)}", + ) + ] + ) + + allowed_tenants = set(tenant_names) if tenant_names is not None else None + errors: list[ValidationError] = [] + devices: list[ManifestDevice] = [] + seen_serials: dict[str, int] = {} + seen_macs: dict[str, int] = {} + for row_number, row in enumerate(rows, start=2): + path = f"csv.rows[{row_number}]" + if len(row) != len(header): + errors.append( + ValidationError( + path, + "invalid_row", + f"Row {row_number} must have exactly {len(header)} columns", + ) + ) + continue + raw = {column: value.strip() for column, value in zip(header, row)} + serial_raw = raw["serial_number"] + mac_raw = raw.get("mac_address", "") + key = raw["subscription_key"] + tenant = raw["tenant"] + before = len(errors) + if not tenant: + errors.append( + ValidationError( + f"{path}.tenant", "missing_field", "tenant is required" + ) + ) + elif allowed_tenants is not None and tenant not in allowed_tenants: + errors.append( + ValidationError( + f"{path}.tenant", + "unknown_tenant_reference", + f"tenant must match a manifest tenant name, got {tenant!r}", + ) + ) + serial, mac = _parse_identifiers( + serial_raw, + mac_raw, + path, + row_number, + "csv", + seen_serials, + seen_macs, + errors, + ) + if len(errors) == before: + devices.append( + ManifestDevice( + tenant=tenant, + subscription_key=key, + serial_number=serial, + mac_address=mac, + ) + ) + if errors: + raise ParseError(errors) + return devices diff --git a/msp-onboarding/msp_onboarding/pycentral_adapter.py b/msp-onboarding/msp_onboarding/pycentral_adapter.py new file mode 100644 index 0000000..10fb93c --- /dev/null +++ b/msp-onboarding/msp_onboarding/pycentral_adapter.py @@ -0,0 +1,1238 @@ +"""Live GreenLake adapter implemented solely through pycentral.""" +from __future__ import annotations + +from copy import deepcopy +from concurrent.futures import ThreadPoolExecutor +from datetime import datetime, timedelta, timezone +from email.utils import parsedate_to_datetime +import json +import logging +import math +import os +import time +from threading import Lock +from typing import Any, Callable, Mapping, Optional + +from pycentral import MSPBase + +from .adapter import ( + AdapterError, + WORKSPACE_NAME_CONFLICT_MESSAGE, + write_batch_size, + write_endpoint_path, +) +from .models import ( + AddressNew, + DeviceInfo, + ServiceInfo, + SubscriptionInfo, + TenantInfo, + TransactionResult, +) + + +CENTRAL_SERVICE_NAME = "Central" +_DEVICE_READ_BATCH_SIZE = 20 +_TENANT_CACHE_SECONDS = 60 +_DEVICE_CACHE_SECONDS = 60 + +# R1 contract verification: MSP_API_LOG= captures every live request and +# raw response (headers included). Unredacted — subscription keys land in this +# file — so it stays opt-in and must never be on for a routine run. +_api_log = logging.getLogger("msp_onboarding.api") +if os.environ.get("MSP_API_LOG"): + _handler = logging.FileHandler(os.environ["MSP_API_LOG"]) + _handler.setFormatter(logging.Formatter("%(asctime)s %(message)s")) + _api_log.addHandler(_handler) + _api_log.setLevel(logging.DEBUG) + + +# ponytail: cap an untrusted Retry-After at one day, in either of its two legal +# forms, so a malformed or hostile header cannot freeze writes indefinitely. +# Revisit if a quota is ever published with a longer window than this. +MAX_RETRY_AFTER_SECONDS = 86_400.0 +# ponytail: 50 pages makes truncation impossible to miss while bounding a +# malformed total. Revisit if a legitimate GLP collection can exceed the cap. +MAX_LIST_PAGES = 50 + + +def _capped_delay(seconds: float) -> float: + return min(seconds, MAX_RETRY_AFTER_SECONDS) + + +def _parse_nonneg_int(value: str) -> Optional[int]: + value = value.strip() + return int(value) if value.isdigit() else None + + +class _RateLimitedResponse(Exception): + def __init__(self, response: Any) -> None: + super().__init__("rate limited") + self.response = response + + +def _without_inner_429_retry(connection: Any) -> Any: + """Make pycentral surface a 429 immediately instead of retrying it itself. + + pycentral's ``command()`` hard-codes three attempts one second apart on 429, + which turns one throttled write into three and extends the window. The engine + owns backoff, so the transport must not retry. + """ + # ponytail: instance patch on request_url; replace when pycentral exposes a + # retry setting for 429. + original = getattr(connection, "request_url", None) + if original is None or getattr(connection, "_msp_no_429_retry", False): + return connection + + def request_url(*args: Any, **kwargs: Any) -> Any: + response = original(*args, **kwargs) + if getattr(response, "status_code", None) == 429: + raise _RateLimitedResponse(response) + return response + + connection.request_url = request_url + connection._msp_no_429_retry = True + return connection + + +# Live 2026-08-25: provisioning quota is 2 POSTs per 60 s window, so one every +# 30 s never touches the ceiling; the ratelimit headers still gate on top. +MIN_WRITE_INTERVAL_SECONDS: dict[tuple[str, str], float] = { + ("POST", "service-catalog/v1/service-manager-provisions"): 30.0, +} + + +class PycentralAdapter: + """AdapterProtocol implementation for unified GreenLake MSP credentials.""" + + def __init__( + self, + token_info: dict[str, Any] | str, + msp_factory: Callable[..., Any] = MSPBase, + clock: Callable[[], datetime] | None = None, + ) -> None: + self._msp_factory = msp_factory + self._root = _without_inner_429_retry(msp_factory(token_info=token_info)) + self._connections: dict[str, Any] = {} + root_url = self._base_url(self._root) + if root_url: + self._connections[root_url] = self._root + self._transactions: dict[str, str] = {} + # ponytail: a 60-second TTL covers the sub-minute assignment target; + # replace it with explicit job lifecycle hooks if jobs routinely exceed it. + self._tenant_cache: Optional[tuple[datetime, list[TenantInfo]]] = None + self._available_devices_cache: Optional[tuple[datetime, list[DeviceInfo]]] = None + self._tenant_base_urls: dict[str, str] = {} + self._created_tenants: set[str] = set() + # Live 2026-08-25: GLP answers every call with ratelimit-limit / + # ratelimit-remaining / ratelimit-reset (fixed windows: 2 provisioning + # POSTs and 10 tenant POSTs per window). Writes to one path are + # serialized and wait for the reset once the window is spent. + self._quota_reset_at: dict[tuple[str, str], float] = {} + self._last_write_at: dict[tuple[str, str], float] = {} + self._quota_locks: dict[tuple[str, str], Lock] = {} + self._quota_lock = Lock() + self._first_service_observations: dict[ + tuple[str, str, str], list[dict[str, Any]] + ] = {} + self._service_manager_names_cache: dict[str, str] | None = None + self._region_display_names_cache: dict[str, str] | None = None + # ponytail: session-lifetime cache has no TTL; assumes provisioning does not + # change within a session. Revisit if that stops holding. + self._services_by_tenant: dict[str, list[ServiceInfo]] = {} + self._clock = clock or (lambda: datetime.now(timezone.utc)) + self._request_pacer: Any = None + self._call_stats: dict[tuple[str, str], dict[str, Any]] = {} + self._call_stats_lock = Lock() + + def install_request_pacer(self, pacer: Any) -> Any: + if self._request_pacer is None: + self._request_pacer = pacer + return self._request_pacer + + def now(self) -> datetime: + return self._clock() + + def _record_call(self, method: str, path_template: str, elapsed: float) -> None: + with self._call_stats_lock: + stats = self._call_stats.setdefault( + (method, path_template), {"count": 0, "total_ms": 0.0} + ) + stats["count"] += 1 + stats["total_ms"] += elapsed * 1000 + + def call_stats(self) -> list[dict[str, Any]]: + with self._call_stats_lock: + rows = [ + {"method": method, "path_template": path, **stats} + for (method, path), stats in self._call_stats.items() + ] + return sorted( + rows, + key=lambda row: (-row["total_ms"], row["method"], row["path_template"]), + ) + + @staticmethod + def _compact_id(workspace_id: str) -> str: + return workspace_id.replace("-", "") + + @staticmethod + def _base_url(connection: Any) -> str: + token_info = getattr(connection, "token_info", {}) + if isinstance(token_info, Mapping): + unified = token_info.get("unified", {}) + if isinstance(unified, Mapping): + return str(unified.get("base_url") or "") + return "" + + @staticmethod + def _body(response: Any) -> dict[str, Any]: + if not isinstance(response, Mapping): + return {} + body = response.get("msg", response) + return body if isinstance(body, dict) else {} + + @classmethod + def _items(cls, response: Any) -> list[dict[str, Any]]: + body = cls._body(response) + items = body.get("items", body.get("data", [])) + if isinstance(items, list): + return [item for item in items if isinstance(item, dict)] + return [] + + def _paged_items( + self, + connection: Any, + path: str, + *, + app_name: str, + error_path: str, + default_error: str, + params: Optional[dict[str, Any]] = None, + ) -> list[dict[str, Any]]: + items: list[dict[str, Any]] = [] + offset = int((params or {}).get("offset", 0)) + page_size: Optional[int] = None + for page in range(MAX_LIST_PAGES): + page_params = dict(params or {}) + if page: + page_params.update({"limit": page_size, "offset": offset}) + response = self._command( + connection, + path, + "GET", + app_name=app_name, + params=page_params, + ) + if response.get("code") != 200: + raise self._error(error_path, response, default_error) + body = self._body(response) + page_items = self._items(response) + items.extend(page_items) + + try: + total = int(body["total"]) + except (KeyError, TypeError, ValueError): + return items + offset += len(page_items) + if offset >= total: + return items + if not page_items: + raise AdapterError( + error_path, + "pagination_stalled", + f"Pagination stalled before all {total} items were returned", + ) + if page_size is None: + try: + page_size = int(body.get("count")) + except (TypeError, ValueError): + page_size = len(page_items) + if page_size <= 0: + page_size = len(page_items) + + raise AdapterError( + error_path, + "pagination_limit", + f"Pagination exceeded the {MAX_LIST_PAGES}-page safety limit", + ) + + def _error(self, path: str, response: Any, default: str) -> AdapterError: + if response.get("code") == 429: + retry_after = None + for name, value in (response.get("headers") or {}).items(): + if str(name).lower() == "retry-after": + raw_value = str(value).strip() + try: + parsed = float(raw_value) + if parsed >= 0 and math.isfinite(parsed): + retry_after = _capped_delay(parsed) + except (TypeError, ValueError): + try: + deadline = parsedate_to_datetime(raw_value) + if deadline.tzinfo is None: + deadline = deadline.replace(tzinfo=timezone.utc) + delay = max(0.0, (deadline - self.now()).total_seconds()) + retry_after = _capped_delay(delay) + except (TypeError, ValueError, OverflowError): + pass + break + if retry_after is None: + reset = _parse_nonneg_int( + self._headers(response).get("ratelimit-reset", "") + ) + if reset is not None: + retry_after = _capped_delay(float(reset) + 1.0) + return AdapterError( + path, + "rate_limited", + "Request was rate limited", + retry_after=retry_after, + ) + body = PycentralAdapter._body(response) + return AdapterError( + path, + str(body.get("code") or body.get("status") or "request_failed"), + str(body.get("message") or body.get("msg") or default), + transaction_id=body.get("transactionId"), + ) + + def _tenant_creation_error(self, response: Any) -> AdapterError: + body = self._body(response) + message = str(body.get("message") or body.get("msg") or "") + if ( + isinstance(response, Mapping) + and response.get("code") == 412 + and message.strip().casefold() == "workspace name already exists" + ): + return AdapterError( + "tenant", + str(body.get("code") or body.get("status") or "request_failed"), + WORKSPACE_NAME_CONFLICT_MESSAGE, + failure_scope="tenant", + ) + return self._error("tenant", response, "Could not create tenant") + + def _command( + self, + connection: Any, + path: str, + method: str, + *, + app_name: str, + params: Optional[dict[str, Any]] = None, + data: Optional[dict[str, Any]] = None, + stats_path: Optional[str] = None, + ) -> Any: + kwargs: dict[str, Any] = { + "api_method": method, + "api_path": path, + "api_params": params or {}, + "app_name": app_name, + } + if data is not None: + kwargs["api_data"] = data + _api_log.debug( + "request %s %s params=%s data=%s", + method, + path, + json.dumps(params or {}, default=str), + json.dumps(data, default=str) if data is not None else "-", + ) + started_at: float | None = None + quota_key = (method, stats_path or path) + is_write = method in {"PATCH", "POST"} + write_lock = self._quota_write_lock(quota_key) if is_write else None + if write_lock is not None: + write_lock.acquire() + try: + self._wait_for_quota(quota_key) + if self._request_pacer is not None: + self._request_pacer.wait(is_write=is_write) + started_at = time.monotonic() + response = connection.command(**kwargs) + except Exception as exc: + elapsed = time.monotonic() - started_at if started_at is not None else 0.0 + if started_at is not None: + self._record_call(method, stats_path or path, elapsed) + limited = next( + ( + arg + for arg in (exc, exc.__cause__, *getattr(exc, "args", ())) + if isinstance(arg, _RateLimitedResponse) + ), + None, + ) + if limited is not None: + response = { + "code": 429, + "msg": limited.response.text, + "headers": dict(limited.response.headers), + } + else: + _api_log.debug( + "transport %s %s error=%s duration_ms=%.1f", + method, + path, + exc, + elapsed * 1000, + ) + raise AdapterError( + path, "transport_error", str(exc), retryable=True + ) from exc + else: + elapsed = time.monotonic() - started_at + finally: + if write_lock is not None: + self._note_quota(quota_key, response if "response" in locals() else None) + write_lock.release() + self._record_call(method, stats_path or path, elapsed) + _api_log.debug( + "response %s %s code=%s duration_ms=%.1f %s", + method, + path, + response.get("code") if isinstance(response, Mapping) else "-", + elapsed * 1000, + json.dumps(response, default=str), + ) + if ( + self._request_pacer is not None + and (not isinstance(response, Mapping) or response.get("code") != 429) + ): + self._request_pacer.clean() + return response + + def _quota_write_lock(self, key: tuple[str, str]) -> Lock: + with self._quota_lock: + return self._quota_locks.setdefault(key, Lock()) + + def _wait_for_quota(self, key: tuple[str, str]) -> None: + with self._quota_lock: + reset_at = self._quota_reset_at.get(key) + last_at = self._last_write_at.get(key) + floor = MIN_WRITE_INTERVAL_SECONDS.get(key) + candidates = [reset_at] + if floor is not None and last_at is not None: + candidates.append(last_at + floor) + allowed_at = max((t for t in candidates if t is not None), default=None) + if allowed_at is not None: + delay = allowed_at - time.monotonic() + if delay > 0: + _api_log.debug("write %s %s paced; waiting %.1fs", key[0], key[1], delay) + time.sleep(delay) + if floor is not None: + with self._quota_lock: + self._last_write_at[key] = time.monotonic() + + def _note_quota(self, key: tuple[str, str], response: Any) -> None: + headers = self._headers(response) + remaining = _parse_nonneg_int(headers.get("ratelimit-remaining", "")) + reset = _parse_nonneg_int(headers.get("ratelimit-reset", "")) + with self._quota_lock: + if remaining == 0 and reset: + # +1 s: reset is whole seconds, rounded down by the gateway. + self._quota_reset_at[key] = time.monotonic() + reset + 1.0 + elif remaining is not None: + self._quota_reset_at.pop(key, None) + + @staticmethod + def _headers(response: Any) -> dict[str, str]: + if not isinstance(response, Mapping): + return {} + return { + str(name).lower(): str(value) + for name, value in (response.get("headers") or {}).items() + } + + def _connection(self, base_url: str) -> Any: + if base_url in self._connections: + return self._connections[base_url] + token_info = deepcopy(getattr(self._root, "token_info", {})) + try: + token_info["unified"]["base_url"] = base_url + except (KeyError, TypeError): + raise AdapterError( + "tenant.cluster", + "cluster_not_found", + "Unified credentials do not include a Central base URL", + ) + connection = _without_inner_429_retry(self._msp_factory(token_info=token_info)) + self._connections[base_url] = connection + return connection + + def _remember_tenant_base_url( + self, workspace_id: str, item: Mapping[str, Any] + ) -> None: + base_url = str( + item.get("baseUrl") + or item.get("base_url") + or item.get("centralBaseUrl") + or "" + ) + if base_url: + self._tenant_base_urls[workspace_id] = base_url + + def _list_tenants(self) -> list[TenantInfo]: + now = self.now() + if self._tenant_cache is not None and now < self._tenant_cache[0]: + return list(self._tenant_cache[1]) + tenant_items = self._paged_items( + self._root, + "workspaces/v1/msp-tenants", + app_name="glp", + error_path="tenant.workspace_id", + default_error="Could not list tenants", + ) + tenants = [] + for item in tenant_items: + workspace_id = str(item.get("id") or item.get("workspaceId") or "") + workspace_name = str(item.get("workspaceName") or item.get("name") or "") + if not workspace_id or not workspace_name: + continue + self._remember_tenant_base_url(workspace_id, item) + tenant = TenantInfo( + workspace_id=workspace_id, + workspace_name=workspace_name, + ownership=str( + item.get("ownership") or item.get("inventoryOwnership") or "" + ), + ) + tenants.append(tenant) + self._tenant_cache = ( + now + timedelta(seconds=_TENANT_CACHE_SECONDS), + tenants, + ) + return list(tenants) + + def list_tenants(self) -> list[TenantInfo]: + return self._list_tenants() + + def fresh_tenant_listing(self) -> list[TenantInfo]: + self._tenant_cache = None + return self._list_tenants() + + def resolve_tenant(self, workspace_id: str) -> TenantInfo: + for tenant in self._list_tenants(): + if tenant.workspace_id == workspace_id: + return tenant + raise AdapterError( + "tenant.workspace_id", "tenant_not_found", f"Tenant not found: {workspace_id!r}" + ) + + def find_tenant_by_name(self, name: str) -> Optional[TenantInfo]: + return next( + (tenant for tenant in self._list_tenants() if tenant.workspace_name == name), None + ) + + def ensure_tenant( + self, + mode: str, + workspace_id: Optional[str], + workspace_name: str, + *, + country: str = "", + description: str = "", + email: str = "", + phone_number: str = "", + address: Optional[AddressNew] = None, + known_tenants: Optional[list[TenantInfo]] = None, + ) -> TenantInfo: + if mode == "existing": + if not workspace_id: + raise AdapterError( + "tenant.workspace_id", "tenant_not_found", "Existing tenant is missing a workspace ID" + ) + return self.resolve_tenant(workspace_id) + tenant = next( + ( + candidate + for candidate in known_tenants + if candidate.workspace_name == workspace_name + ), + None, + ) if known_tenants is not None else self.find_tenant_by_name(workspace_name) + if tenant: + return tenant + payload = {"workspaceName": workspace_name} + for key, value in ( + ("description", description), + ("email", email), + ("phoneNumber", phone_number), + ): + if value: + payload[key] = value + address_fields = ( + ( + ("streetAddress", address.street_address), + ("streetAddressComplement", address.street_address_complement), + ("city", address.city), + ("stateOrRegion", address.state_or_region), + ("postalCode", address.postal_code), + ) + if address is not None + else () + ) + # GLP nests countryCode inside address, but v2 manifests carry country on + # the tenant, so it arrives separately and is folded back in here. + address_payload = { + key: value + for key, value in (*address_fields, ("countryCode", country)) + if value + } + if address_payload: + payload["address"] = address_payload + response = self._command( + self._root, + "workspaces/v1/msp-tenants", + "POST", + app_name="glp", + data=payload, + ) + if response.get("code") != 201: + raise self._tenant_creation_error(response) + self._tenant_cache = None + body = self._body(response) + created = next( + ( + candidate + for candidate in ( + body, + body.get("tenant"), + body.get("workspace"), + body.get("data"), + body.get("result"), + ) + if isinstance(candidate, Mapping) + and ( + candidate.get("id") + or candidate.get("workspaceId") + or candidate.get("tenantWorkspaceId") + ) + ), + None, + ) + if created is not None: + workspace_id = str( + created.get("id") + or created.get("workspaceId") + or created.get("tenantWorkspaceId") + or "" + ) + response_name = str( + created.get("workspaceName") or created.get("name") or workspace_name + ) + if workspace_id and response_name == workspace_name: + self._remember_tenant_base_url(workspace_id, created) + self._created_tenants.add(workspace_id) + return TenantInfo( + workspace_id=workspace_id, + workspace_name=workspace_name, + ownership=str( + created.get("ownership") + or created.get("inventoryOwnership") + or "" + ), + ) + # Live 2026-08-25: the 201 body is {"message": "Tenant created"}; the new + # workspace ID arrives only in the Location header. + location = next( + ( + str(value) + for name, value in (response.get("headers") or {}).items() + if str(name).lower() == "location" + ), + "", + ) + located_id = location.rstrip("/").rsplit("/", 1)[-1] + if len(located_id) == 36 and located_id.count("-") == 4: + self._created_tenants.add(located_id) + return TenantInfo( + workspace_id=located_id, + workspace_name=workspace_name, + ownership="MSP_OWNED_INVENTORY", + ) + tenant = self.find_tenant_by_name(workspace_name) + if tenant is None: + raise AdapterError( + "tenant.workspace_name", + "tenant_not_found", + "Created tenant was not found by exact workspace name", + ) + self._created_tenants.add(tenant.workspace_id) + return tenant + + def _tenant_connection(self, workspace_id: str) -> Any: + base_url = self._tenant_base_urls.get(workspace_id) + connection = self._connection(base_url) if base_url else self._root + compact_id = self._compact_id(workspace_id) + pool = getattr(connection, "_tenant_connections", None) + cached = pool.get(compact_id) if isinstance(pool, dict) else None + if cached is not None: + return _without_inner_429_retry(cached) + if self._request_pacer is not None: + self._request_pacer.wait(is_write=False) + started_at = time.monotonic() + try: + tenant_connection = connection.get_tenant_connection( + tenant_workspace_id=compact_id + ) + except Exception as exc: + elapsed = time.monotonic() - started_at + self._record_call("POST", "token-exchange", elapsed) + _api_log.debug( + "transport POST token-exchange error=%s duration_ms=%.1f", + exc, + elapsed * 1000, + ) + raise + elapsed = time.monotonic() - started_at + self._record_call("POST", "token-exchange", elapsed) + _api_log.debug( + "response POST token-exchange code=- duration_ms=%.1f", elapsed * 1000 + ) + if self._request_pacer is not None: + self._request_pacer.clean() + return _without_inner_429_retry(tenant_connection) + + @staticmethod + def _service_manager_id(item: dict[str, Any]) -> str: + service_manager = item.get("serviceManager") + if isinstance(service_manager, Mapping): + return str(service_manager.get("id") or "") + return str(item.get("serviceManagerId") or "") + + @staticmethod + def _provision_status(item: dict[str, Any]) -> str: + return str(item.get("provisionStatus") or item.get("status") or "") + + def _service_items(self, workspace_id: Optional[str]) -> list[dict[str, Any]]: + connection = self._tenant_connection(workspace_id) if workspace_id else self._root + return self._service_items_for_connection(connection) + + def _service_items_for_connection(self, connection: Any) -> list[dict[str, Any]]: + return self._paged_items( + connection, + "service-catalog/v1/service-manager-provisions", + app_name="glp", + error_path="service", + default_error="Could not list services", + ) + + @staticmethod + def _eligible_services_from_items( + items: list[dict[str, Any]], + names: Mapping[str, str], + region_names: Mapping[str, str], + ) -> list[ServiceInfo]: + services = [] + for item in items: + service_manager_id = PycentralAdapter._service_manager_id(item) + if not service_manager_id or PycentralAdapter._provision_status(item) != "PROVISIONED": + continue + inline = item.get("serviceManager") + name = names.get(service_manager_id) or str( + (inline.get("name") if isinstance(inline, Mapping) else None) + or item.get("name") + or "" + ) + # ponytail: Central identified by catalog name; switch to a service-manager + # capability flag if GLP ever exposes one. + if CENTRAL_SERVICE_NAME.lower() not in name.lower(): + continue + services.append( + ServiceInfo( + service_manager_id=service_manager_id, + region=str(item.get("region") or ""), + name=name, + region_display_name=region_names.get( + str(item.get("region") or ""), + str(item.get("region") or ""), + ), + ) + ) + return services + + def _service_manager_names(self) -> dict[str, str]: + """Provisions carry only a service-manager id; names live in the catalog.""" + if self._service_manager_names_cache is None: + items = self._paged_items( + self._root, + "service-catalog/v1/service-managers", + app_name="glp", + error_path="service", + default_error="Could not list service managers", + ) + self._service_manager_names_cache = { + str(item.get("id") or ""): str(item.get("name") or "") + for item in items + if item.get("id") + } + return self._service_manager_names_cache + + def _region_display_names(self) -> dict[str, str]: + if self._region_display_names_cache is None: + try: + items = self._paged_items( + self._root, + "service-catalog/v1/per-region-service-managers", + app_name="glp", + error_path="service", + default_error="Could not list service regions", + ) + except AdapterError as exc: + if exc.code in { + "rate_limited", + "pagination_limit", + "pagination_stalled", + }: + raise + items = [] + self._region_display_names_cache = { + str(item.get("id") or ""): str( + item.get("regionName") or item.get("id") or "" + ) + for item in items + if item.get("id") + } + return self._region_display_names_cache + + def list_eligible_services(self, workspace_id: Optional[str]) -> list[ServiceInfo]: + names = self._service_manager_names() + region_names = self._region_display_names() + return self._eligible_services_from_items( + self._service_items(workspace_id), names, region_names + ) + + def services_for_tenants( + self, tenant_ids: list[str] + ) -> dict[str, list[ServiceInfo]]: + missing = list( + dict.fromkeys( + workspace_id + for workspace_id in tenant_ids + if workspace_id not in self._services_by_tenant + ) + ) + if len(missing) == 1: + workspace_id = missing[0] + self._services_by_tenant[workspace_id] = self.list_eligible_services( + workspace_id + ) + elif missing: + # Fill lazy caches and exchange tenant tokens before workers run: both + # paths can mutate adapter-level state. + names = self._service_manager_names() + region_names = self._region_display_names() + connections = { + workspace_id: self._tenant_connection(workspace_id) + for workspace_id in missing + } + # ponytail: four concurrent tenant provision reads balance preflight + # latency against the shared GLP read budget; revisit with API quotas. + with ThreadPoolExecutor(max_workers=4) as executor: + futures = { + workspace_id: executor.submit( + self._service_items_for_connection, connections[workspace_id] + ) + for workspace_id in missing + } + for workspace_id in missing: + self._services_by_tenant[workspace_id] = ( + self._eligible_services_from_items( + futures[workspace_id].result(), names, region_names + ) + ) + return { + workspace_id: self._services_by_tenant[workspace_id] + for workspace_id in tenant_ids + } + + def submit_service_provisioning( + self, workspace_id: str, service_manager_id: str, region: str + ) -> None: + observation_key = (workspace_id, service_manager_id, region) + # A tenant this session just created has no provisions; skip the read. + matching = [] if workspace_id in self._created_tenants else [ + item + for item in self._service_items(workspace_id) + if self._service_manager_id(item) == service_manager_id + and item.get("region") == region + ] + if matching: + self._first_service_observations[observation_key] = matching + self._services_by_tenant.pop(workspace_id, None) + return + connection = self._tenant_connection(workspace_id) + response = self._command( + connection, + "service-catalog/v1/service-manager-provisions", + "POST", + app_name="glp", + data={"serviceManagerId": service_manager_id, "region": region}, + ) + if response.get("code") != 201: + raise self._error("service", response, "Could not provision service") + self._first_service_observations[observation_key] = [ + { + "serviceManagerId": service_manager_id, + "region": region, + "provisionStatus": "PROVISION_INITIATED", + } + ] + + def observe_service_provisioning( + self, workspace_id: str, service_manager_id: str, region: str + ) -> str: + observation_key = (workspace_id, service_manager_id, region) + items = self._first_service_observations.pop(observation_key, None) + if items is None: + items = self._service_items(workspace_id) + for item in items: + if ( + self._service_manager_id(item) == service_manager_id + and item.get("region") == region + ): + status = self._provision_status(item) + if status == "PROVISIONED": + self._services_by_tenant.pop(workspace_id, None) + return "provisioned" + if status in {"FAILED", "ERROR"}: + return "failed" + return "pending" + return "not_started" + + @staticmethod + def _hyphenate(value: Optional[str]) -> Optional[str]: + if value and len(value) == 32 and "-" not in value: + return (f"{value[:8]}-{value[8:12]}-{value[12:16]}-" + f"{value[16:20]}-{value[20:]}") + return value + + @staticmethod + def _ref_id(value: Any) -> Optional[str]: + # Live GLP returns reference objects ({"id": ...}) — and for device + # subscriptions a LIST of them — where demo fixtures used bare strings + # (R1 audit finding); the engine compares strings. + if isinstance(value, list): + value = value[0] if value else None + if isinstance(value, Mapping): + value = value.get("id") + return str(value) if value else None + + @staticmethod + def _device_info(item: dict[str, Any]) -> DeviceInfo: + ref = PycentralAdapter._ref_id + raw_device_type = str(item.get("deviceType") or "").strip().upper() + device_type = { + "IAP": "AP", + "AP": "AP", + "SWITCH": "SWITCH", + "GATEWAY": "GW", + "GW": "GW", + "CONTROLLER": "GW", + }.get(raw_device_type, "") + return DeviceInfo( + glp_id=str(item.get("id") or item.get("glpId") or ""), + serial_number=str(item.get("serialNumber") or ""), + mac_address=str(item.get("macAddress") or ""), + management=str(item.get("management") or ""), + assigned_state=str(item.get("assignedState") or ""), + device_type=device_type, + in_use_workspace=ref(item.get("inUseWorkspace")), + # Live records carry the 32-char dehyphenated form (R1 finding); + # the engine compares against hyphenated tenant listing IDs. + tenant_workspace_id=PycentralAdapter._hyphenate( + ref(item.get("tenantWorkspaceId")) + ), + # Live payloads carry the application as application:{"id": ...} + # (R1 finding); serviceManagerId was never observed live. + service_manager_id=ref(item.get("serviceManagerId") or item.get("application")), + subscription=ref(item.get("subscription")), + ) + + def resolve_devices( + self, + *, + serials: Optional[list[str]] = None, + glp_ids: Optional[list[str]] = None, + ) -> list[DeviceInfo]: + # The live API silently ignores unknown query params and returns the + # full list (R1 finding), so filter server-side with the GLP `filter` + # expression AND match locally — never trust the returned order. + selectors = [ + ("serialNumber", "serial_number", serials), + ("id", "glp_id", glp_ids), + ] + selected = [selector for selector in selectors if selector[2] is not None] + if len(selected) != 1: + raise ValueError("Provide exactly one device identifier list") + field, attr, values = selected[0] + assert values is not None + found: dict[str, DeviceInfo] = {} + for index in range(0, len(values), _DEVICE_READ_BATCH_SIZE): + batch = values[index:index + _DEVICE_READ_BATCH_SIZE] + escaped = ( + value.replace("\\", "\\\\").replace("'", "\\'") + for value in batch + ) + literals = ",".join( + f"'{value}'" for value in escaped + ) + items = self._paged_items( + self._root, + "devices/v1/devices", + app_name="glp", + error_path="devices", + default_error="Could not resolve devices", + params={"filter": f"{field} in ({literals})"}, + ) + wanted = {value.strip().lower() for value in batch} + for item in items: + device = self._device_info(item) + key = getattr(device, attr).strip().lower() + if key in wanted: + found[key] = device + return [ + found[value.strip().lower()] + for value in values + if value.strip().lower() in found + ] + + def _resolve_device(self, field: str, value: str) -> DeviceInfo: + kwargs = { + "serialNumber": {"serials": [value]}, + "id": {"glp_ids": [value]}, + }.get(field) + if kwargs is not None: + devices = self.resolve_devices(**kwargs) + if devices: + return devices[0] + else: + items = self._paged_items( + self._root, + "devices/v1/devices", + app_name="glp", + error_path="devices", + default_error="Could not resolve device", + params={"filter": f"macAddress eq '{value}'"}, + ) + wanted = value.strip().lower() + for item in items: + device = self._device_info(item) + if device.mac_address.strip().lower() == wanted: + return device + raise AdapterError("devices", "device_not_found", "Device was not found") + + def resolve_device_by_serial(self, serial: str) -> DeviceInfo: + return self._resolve_device("serialNumber", serial) + + def resolve_device_by_mac(self, mac: str) -> DeviceInfo: + return self._resolve_device("macAddress", mac) + + def resolve_device(self, glp_id: str) -> DeviceInfo: + return self._resolve_device("id", glp_id) + + def list_available_devices(self) -> list[DeviceInfo]: + now = self.now() + if ( + self._available_devices_cache is not None + and now < self._available_devices_cache[0] + ): + return list(self._available_devices_cache[1]) + items = self._paged_items( + self._root, + "devices/v1/devices", + app_name="glp", + error_path="devices", + default_error="Could not list devices", + ) + devices = [ + device + for device in (self._device_info(item) for item in items) + if device.management == "MSP" + and device.assigned_state == "UNASSIGNED" + and not device.in_use_workspace + and not device.tenant_workspace_id + and not device.subscription + ] + self._available_devices_cache = ( + now + timedelta(seconds=_DEVICE_CACHE_SECONDS), + devices, + ) + return list(devices) + + @staticmethod + def _date_only(value: Any) -> Optional[str]: + # Live GLP sends RFC3339 timestamps; the engine's date checks expect + # bare YYYY-MM-DD (R1 finding). Normalize here, at the seam. + return str(value)[:10] if value else None + + @staticmethod + def _subscription_info(item: dict[str, Any]) -> SubscriptionInfo: + return SubscriptionInfo( + subscription_id=str(item.get("id") or item.get("subscriptionId") or ""), + key=str(item.get("key") or ""), + # Live payloads carry subscriptionStatus/startTime/endTime; the + # bare names were never observed on a real workspace (R1 finding). + status=str(item.get("status") or item.get("subscriptionStatus") or ""), + product_type=str(item.get("productType") or ""), + # `or ""` would turn a legitimate 0 into "" (R1 audit finding). + available_quantity=( + "" if item.get("availableQuantity") is None + else str(item.get("availableQuantity")) + ), + quantity="" if item.get("quantity") is None else str(item.get("quantity")), + start_date=PycentralAdapter._date_only( + item.get("startDate") or item.get("startTime") + ), + end_date=PycentralAdapter._date_only( + item.get("endDate") or item.get("endTime") + ), + subscription_type=str(item.get("subscriptionType") or ""), + tier_description=str(item.get("tierDescription") or ""), + ) + + def resolve_subscription(self, key: str) -> SubscriptionInfo: + # Same silent-ignore hazard as devices (R1 finding): filter server-side + # and match the key locally — never trust items[0]. + items = self._paged_items( + self._root, + "subscriptions/v1/subscriptions", + app_name="glp", + error_path="subscriptions", + default_error="Could not resolve subscription", + params={"filter": f"key eq '{key}'"}, + ) + wanted = key.strip().lower() + for item in items: + subscription = self._subscription_info(item) + if subscription.key.strip().lower() == wanted: + subscription.key = key + return subscription + raise AdapterError( + "subscriptions", "subscription_not_found", "Subscription was not found" + ) + + def list_subscriptions(self) -> list[SubscriptionInfo]: + items = self._paged_items( + self._root, + "subscriptions/v1/subscriptions", + app_name="glp", + error_path="subscriptions", + default_error="Could not list subscriptions", + ) + return [self._subscription_info(item) for item in items] + + def _accepted_transaction(self, response: Any, connection: Any) -> str: + if response.get("code") != 202: + raise self._error("execution", response, "Assignment request was rejected") + body = self._body(response) + transaction_id = body.get("transactionId") + if not body.get("code") or not body.get("status") or not transaction_id: + raise AdapterError( + "execution", "invalid_response", "Accepted response is missing transaction details" + ) + transaction_id = str(transaction_id) + self._transactions[transaction_id] = self._base_url(connection) + return transaction_id + + def assign_devices( + self, + device_ids: list[str], + tenant_workspace_id: str, + service_manager_id: str, + region: str, + ) -> str: + batch_size = write_batch_size() + if len(device_ids) > batch_size: + raise AdapterError( + "execution.devices", + "batch_too_large", + f"At most {batch_size} devices are allowed", + ) + # Live tenant listings and provisions carry no base URL (R1 finding), + # so the per-cluster connection can never resolve. The PATCH is + # MSP-scoped anyway — tenantPlatformCustomerId in the body carries the + # tenant, mirroring assign_subscriptions. + connection = self._root + response = self._command( + connection, + write_endpoint_path(), + "PATCH", + app_name="glp", + params={"id": device_ids}, + data={ + "application": {"id": service_manager_id}, + "region": region, + "tenantPlatformCustomerId": self._compact_id(tenant_workspace_id), + }, + ) + transaction_id = self._accepted_transaction(response, connection) + self._available_devices_cache = None + return transaction_id + + def assign_subscriptions( + self, + assignments: list[tuple[str, str]], + ) -> str: + batch_size = write_batch_size() + if len(assignments) > batch_size: + raise AdapterError( + "execution.subscriptions", + "batch_too_large", + f"At most {batch_size} subscriptions are allowed", + ) + subscription_ids = {subscription_id for _, subscription_id in assignments} + if len(subscription_ids) != 1: + raise AdapterError( + "execution.subscriptions", + "mixed_subscription_batch", + "All devices in a subscription batch must use one subscription", + ) + response = self._command( + self._root, + write_endpoint_path(), + "PATCH", + app_name="glp", + params={"id": [glp_id for glp_id, _ in assignments]}, + data={"subscription": [{"id": str(next(iter(subscription_ids)))}]}, + ) + transaction_id = self._accepted_transaction(response, self._root) + self._available_devices_cache = None + return transaction_id + + def transaction_origin(self, transaction_id: str) -> Optional[str]: + return self._transactions.get(transaction_id) + + def poll_transaction( + self, transaction_id: str, origin: Optional[str] = None + ) -> TransactionResult: + base_url = origin or self._transactions.get(transaction_id) + connection = self._connection(base_url) if base_url else self._root + response = self._command( + connection, + f"devices/v1/async-operations/{transaction_id}", + "GET", + app_name="glp", + stats_path="devices/v1/async-operations/{transaction_id}", + ) + if response.get("code") != 200: + raise self._error("execution.transaction", response, "Could not poll transaction") + body = self._body(response) + # FAILED is terminal (R1 finding: status vocabulary includes FAILED + # with failedDevices in result) — retrying it would spin forever. + if body.get("status") not in {"SUCCEEDED", "FAILED"}: + raise AdapterError( + "execution.transaction", + "transaction_not_complete", + "Transaction is not complete", + retryable=True, + ) + result = body.get("result", {}) + if not isinstance(result, dict): + result = {} + return TransactionResult( + transaction_id=transaction_id, + succeeded_ids=[str(item) for item in result.get("succeededDevices", [])], + failed_ids=[str(item) for item in result.get("failedDevices", [])], + ) diff --git a/msp-onboarding/msp_onboarding/store.py b/msp-onboarding/msp_onboarding/store.py new file mode 100644 index 0000000..2509f46 --- /dev/null +++ b/msp-onboarding/msp_onboarding/store.py @@ -0,0 +1,371 @@ +"""Session-only in-memory state for onboarding plans and execution.""" +from __future__ import annotations + +from copy import deepcopy +from dataclasses import asdict +from datetime import datetime, timezone +from threading import RLock +from typing import Optional + +from .models import Manifest, Plan, TERMINAL_JOB_STATUSES + + +class MemoryStore: + """Thread-safe process-local state shared by the API and sole worker.""" + + def __init__(self) -> None: + self._lock = RLock() + self._jobs: dict[str, dict] = {} + self._plans: dict[str, dict] = {} + self._manifests: dict[str, dict] = {} + self._steps: dict[str, list[dict]] = {} + self._devices: dict[str, dict[str, dict]] = {} + self._active_job_id: Optional[str] = None + + @staticmethod + def _now() -> str: + return datetime.now(timezone.utc).isoformat() + + def save_plan(self, job_id: str, manifest: Manifest, plan: Plan) -> None: + runnable = any(group.status == "pending" for group in plan.tenant_groups) + status = "ready" if not plan.errors and runnable else "draft" + with self._lock: + self._jobs[job_id] = { + "id": job_id, + "manifest_hash": plan.manifest_hash, + "plan_hash": plan.plan_hash, + "mode": plan.mode, + "tenant_groups": [group.to_dict() for group in plan.tenant_groups], + "status": status, + "created_at": plan.created_at, + "updated_at": plan.created_at, + "last_error": None, + "stop_requested": False, + } + self._plans[job_id] = plan.to_dict() + self._manifests[job_id] = asdict(manifest) + self._steps[job_id] = [] + self._devices[job_id] = {} + + def save_execution_records(self, job_id: str, devices: list[dict]) -> None: + now = self._now() + with self._lock: + records = self._devices[job_id] + for device in devices: + records.setdefault( + device["glp_id"], + { + "tenant_name": device["tenant_name"], + "tenant_workspace_id": device.get("tenant_workspace_id"), + "glp_id": device["glp_id"], + "serial_number": device.get("serial_number"), + "subscription_id": device["subscription_id"], + "subscription_key": device.get("subscription_key", ""), + "device_status": "pending", + "subscription_status": "pending", + "error": None, + "updated_at": now, + }, + ) + + def enqueue_start(self, job_id: str) -> None: + with self._lock: + job = self._job(job_id) + if job["status"] != "ready": + raise ValueError( + f"Only ready jobs can start (got {job['status']!r})" + ) + if any( + other["status"] in {"queued", "running"} + for other_id, other in self._jobs.items() + if other_id != job_id + ): + raise ValueError("Another onboarding job is already active") + job.update( + status="queued", + updated_at=self._now(), + last_error=None, + stop_requested=False, + ) + + def record_step( + self, + job_id: str, + tenant_name: str, + logical_key: str, + operation: str, + status: str, + *, + tenant_workspace_id: Optional[str] = None, + scope: str = "batch", + transaction_id: Optional[str] = None, + transaction_origin: Optional[str] = None, + error: Optional[dict] = None, + wait_until: Optional[str] = None, + increment_attempt: bool = True, + ) -> None: + now = self._now() + with self._lock: + steps = self._steps[job_id] + current = next( + ( + step + for step in steps + if step["tenant_name"] == tenant_name + and step["logical_key"] == logical_key + and step["operation"] == operation + ), + None, + ) + if current is None: + steps.append( + { + "tenant_name": tenant_name, + "tenant_workspace_id": tenant_workspace_id, + "scope": scope, + "logical_key": logical_key, + "operation": operation, + "status": status, + "attempts": 1 if status == "running" and increment_attempt else 0, + "transaction_id": transaction_id, + "transaction_origin": transaction_origin, + "created_at": now, + "updated_at": now, + "error": deepcopy(error), + "wait_until": wait_until, + } + ) + return + if ( + status == "running" + and current["status"] != "running" + and increment_attempt + ): + current["attempts"] += 1 + current.update( + scope=scope, + status=status, + updated_at=now, + error=deepcopy(error), + wait_until=wait_until, + ) + if tenant_workspace_id is not None: + current["tenant_workspace_id"] = tenant_workspace_id + if transaction_id is not None: + current["transaction_id"] = transaction_id + if transaction_origin is not None: + current["transaction_origin"] = transaction_origin + + def update_device_status( + self, + job_id: str, + tenant_name: str, + glp_id: str, + operation: str, + status: str, + error: Optional[dict] = None, + ) -> None: + if operation not in ("assign_devices", "assign_subscriptions"): + raise ValueError(f"Unknown device operation: {operation}") + column = ( + "device_status" + if operation == "assign_devices" + else "subscription_status" + ) + with self._lock: + device = self._devices[job_id].get(glp_id) + if device is None or device["tenant_name"] != tenant_name: + return + device[column] = status + device["error"] = deepcopy(error) + device["updated_at"] = self._now() + + def update_tenant_group( + self, + job_id: str, + tenant_name: str, + status: str, + *, + tenant_workspace_id: Optional[str] = None, + error: Optional[dict] = None, + ) -> None: + with self._lock: + job = self._job(job_id) + group = next( + ( + group + for group in job["tenant_groups"] + if group["tenant_name"] == tenant_name + ), + None, + ) + if group is None: + raise KeyError(f"Tenant group not found: {tenant_name}") + group["status"] = status + group["last_error"] = deepcopy(error) + if tenant_workspace_id is not None: + group["tenant_workspace_id"] = tenant_workspace_id + job["updated_at"] = self._now() + + def set_tenant_workspace_id( + self, job_id: str, tenant_name: str, workspace_id: str + ) -> None: + with self._lock: + group = next( + ( + group + for group in self._job(job_id)["tenant_groups"] + if group["tenant_name"] == tenant_name + ), + None, + ) + if group is None: + raise KeyError(f"Tenant group not found: {tenant_name}") + group["tenant_workspace_id"] = workspace_id + now = self._now() + self._job(job_id)["updated_at"] = now + for step in self._steps[job_id]: + if step["tenant_name"] == tenant_name: + step["tenant_workspace_id"] = workspace_id + step["updated_at"] = now + for device in self._devices[job_id].values(): + if device["tenant_name"] == tenant_name: + device["tenant_workspace_id"] = workspace_id + device["updated_at"] = now + + def claim_next_job(self) -> Optional[str]: + with self._lock: + if self._active_job_id is not None: + return None + queued = [job for job in self._jobs.values() if job["status"] == "queued"] + if not queued: + return None + job = min(queued, key=lambda item: (item["created_at"], item["id"])) + job["status"] = "running" + job["updated_at"] = self._now() + self._active_job_id = job["id"] + return job["id"] + + def finish_job(self, job_id: str, status: str) -> None: + if status not in TERMINAL_JOB_STATUSES: + raise ValueError(f"Invalid terminal job status: {status!r}") + with self._lock: + if self._job(job_id)["stop_requested"]: + self.stop_job(job_id) + return + self._complete_job(job_id, status) + + def request_stop(self, job_id: str) -> None: + with self._lock: + job = self._job(job_id) + if job["status"] != "running": + raise ValueError( + f"Only running jobs can stop (got {job['status']!r})" + ) + job["stop_requested"] = True + job["updated_at"] = self._now() + + def stop_requested(self, job_id: str) -> bool: + with self._lock: + return bool(self._job(job_id)["stop_requested"]) + + def stop_job(self, job_id: str) -> None: + error = {"code": "stopped", "message": "Stopped by operator"} + with self._lock: + job = self._job(job_id) + if job["status"] != "running": + raise ValueError( + f"Only running jobs can stop (got {job['status']!r})" + ) + for group in job["tenant_groups"]: + if group["status"] in {"pending", "running"}: + group["status"] = "skipped" + group["last_error"] = deepcopy(error) + now = self._now() + for device in self._devices[job_id].values(): + for column in ("device_status", "subscription_status"): + if device[column] == "pending": + device[column] = "skipped" + device["updated_at"] = now + self._complete_job(job_id, "stopped") + + def _complete_job(self, job_id: str, status: str) -> None: + job = self._job(job_id) + if job["status"] != "running": + raise ValueError( + f"Only running jobs can {status} (got {job['status']!r})" + ) + job.update( + status=status, + updated_at=self._now(), + last_error=None, + stop_requested=False, + ) + if self._active_job_id == job_id: + self._active_job_id = None + + def get_plan_dict(self, job_id: str) -> Optional[dict]: + with self._lock: + value = self._plans.get(job_id) + return deepcopy(value) if value is not None else None + + def get_manifest_dict(self, job_id: str) -> Optional[dict]: + with self._lock: + value = self._manifests.get(job_id) + return deepcopy(value) if value is not None else None + + def get_job(self, job_id: str) -> Optional[dict]: + with self._lock: + value = self._jobs.get(job_id) + if value is None: + return None + result = deepcopy(value) + result.pop("stop_requested", None) + return result + + def has_active_job(self) -> bool: + with self._lock: + return any( + job["status"] in {"queued", "running"} + for job in self._jobs.values() + ) + + def list_jobs(self) -> list[dict]: + with self._lock: + return sorted( + [ + { + key: deepcopy(value) + for key, value in job.items() + if key != "stop_requested" + } + for job in self._jobs.values() + ], + key=lambda item: (item["created_at"], item["id"]), + ) + + def get_steps(self, job_id: str) -> list[dict]: + with self._lock: + return deepcopy(self._steps.get(job_id, [])) + + def get_devices(self, job_id: str) -> list[dict]: + with self._lock: + return sorted( + deepcopy(list(self._devices.get(job_id, {}).values())), + key=lambda item: (item["tenant_name"], item["glp_id"]), + ) + + def _job(self, job_id: str) -> dict: + job = self._jobs.get(job_id) + if job is None: + raise KeyError(f"Job not found: {job_id}") + return job + + def close(self) -> None: + return None + + def __enter__(self) -> "MemoryStore": + return self + + def __exit__(self, *_: object) -> None: + self.close() diff --git a/msp-onboarding/onboarding.py b/msp-onboarding/onboarding.py new file mode 100644 index 0000000..8b4b6dd --- /dev/null +++ b/msp-onboarding/onboarding.py @@ -0,0 +1,107 @@ +#!/usr/bin/env python3 +"""Command-line entry point for MSP onboarding.""" +from __future__ import annotations + +import argparse +from dataclasses import asdict +import json +import os +import sys + +from msp_onboarding.adapter import AdapterError +from msp_onboarding.demo_adapter import DemoAdapter +from msp_onboarding.engine import OnboardingEngine +from msp_onboarding.parser import ParseError, parse_yaml_manifest +from msp_onboarding.pycentral_adapter import PycentralAdapter +from msp_onboarding.store import MemoryStore + + +def _parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(description="MSP onboarding workflow") + parser.add_argument("--demo", action="store_true", help="Use deterministic demo data") + commands = parser.add_subparsers(dest="command", required=True) + list_parser = commands.add_parser("list") + list_parser.add_argument( + "selector", choices=("tenants", "services", "devices", "subscriptions") + ) + plan_parser = commands.add_parser("plan") + plan_parser.add_argument("manifest") + run_parser = commands.add_parser("run") + run_parser.add_argument("manifest") + run_parser.add_argument("--yes", action="store_true", help="Confirm execution") + return parser + + +def _adapter(demo: bool): + if demo: + return DemoAdapter() + if not os.path.isfile("token.yaml"): + raise FileNotFoundError("Live mode requires token.yaml in the current directory") + return PycentralAdapter("token.yaml") + + +def _print(value: object) -> None: + print(json.dumps(value, sort_keys=True)) + + +def _list(adapter, selector: str) -> list[dict]: + if selector == "tenants": + return [asdict(item) for item in adapter.list_tenants()] + if selector == "services": + return [asdict(item) for item in adapter.list_eligible_services(None)] + if selector == "devices": + return [asdict(item) for item in adapter.list_available_devices()] + subscriptions = [asdict(item) for item in adapter.list_subscriptions()] + for subscription in subscriptions: + subscription["key"] = "***" + return subscriptions + + +def main(argv: list[str] | None = None) -> int: + args = _parser().parse_args(argv) + try: + adapter = _adapter(args.demo) + if args.command == "list": + _print(_list(adapter, args.selector)) + return 0 + with MemoryStore() as store: + engine = OnboardingEngine(adapter, store) + if args.command == "plan": + with open(args.manifest, encoding="utf-8") as manifest_file: + plan = engine.plan(parse_yaml_manifest(manifest_file.read())) + _print(plan.to_dict()) + return 0 + with open(args.manifest, encoding="utf-8") as manifest_file: + plan = engine.plan(parse_yaml_manifest(manifest_file.read())) + if not args.yes: + try: + print( + f"Run onboarding job {plan.job_id}? [y/N] ", + end="", + file=sys.stderr, + flush=True, + ) + confirmed = input() + except EOFError: + confirmed = "" + if confirmed.lower() not in ("y", "yes"): + print("Run requires confirmation.", file=sys.stderr) + return 1 + engine.confirm(plan.job_id) + engine.drain() + call_stats = getattr(adapter, "call_stats", None) + if callable(call_stats): + print( + "GLP call summary: " + + json.dumps(call_stats(), sort_keys=True), + file=sys.stderr, + ) + _print(engine.get(plan.job_id)) + return 0 + except (AdapterError, FileNotFoundError, KeyError, ParseError, ValueError) as exc: + print(f"Error: {exc}", file=sys.stderr) + return 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/msp-onboarding/requirements.txt b/msp-onboarding/requirements.txt new file mode 100644 index 0000000..a2752d1 --- /dev/null +++ b/msp-onboarding/requirements.txt @@ -0,0 +1,4 @@ +pyyaml==6.0.3 +pycentral==2.0a22 +fastapi==0.136.1 +uvicorn[standard]==0.46.0 diff --git a/msp-onboarding/run_samples.py b/msp-onboarding/run_samples.py new file mode 100644 index 0000000..69d9b2d --- /dev/null +++ b/msp-onboarding/run_samples.py @@ -0,0 +1,183 @@ +#!/usr/bin/env python3 +"""Print read-only plans and bounded execution scenarios using DemoAdapter. + +Run from the msp-onboarding/ directory: + python run_samples.py + python run_samples.py --execute-demo +""" +from __future__ import annotations + +import os +import sys + +# Ensure the package is importable when run directly +sys.path.insert(0, os.path.dirname(__file__)) + +from msp_onboarding.demo_adapter import ( + DemoAdapter, SERVICE_S1_ID, TENANT_CLEAN_ID, TENANT_EUROPE_ID, + TENANT_NORTH_ID, TENANT_SOUTH_ID, TENANT_T1_ID, +) +from msp_onboarding.engine import OnboardingEngine +from msp_onboarding.models import ( + Manifest, + ManifestDevice, + Plan, + ServiceRef, + TenantExisting, +) +from msp_onboarding.parser import parse_csv_devices, parse_yaml_manifest +from msp_onboarding.store import MemoryStore + +SAMPLES = os.path.join(os.path.dirname(__file__), "samples") + + +def _print_plan(label: str, plan: Plan) -> None: + d = plan.to_dict() + print(f"\n{'=' * 62}") + print(f" {label}") + print(f"{'=' * 62}") + print(f" job_id : {d['job_id']}") + print(f" mode : {d['mode']}") + print(f" tenant groups ({len(d['tenant_groups'])}):") + for group in d["tenant_groups"]: + print( + f" {group['tenant_name']}: {group['status']} " + f"tenant_id={group['tenant_workspace_id']} " + f"service={group['service_manager_id']} [{group['service_region']}]" + ) + print(f" manifest_hash : {d['manifest_hash'][:16]}…") + print(f" plan_hash : {d['plan_hash'][:16]}…") + print(f" created_at : {d['created_at']}") + print(f" devices : {len(d['devices'])} (identifiers redacted)") + if d["errors"]: + print(f" errors ({len(d['errors'])}):") + for err in d["errors"]: + print(f" [{err['code']}] {err['path']}: {err['message']}") + else: + print(" errors : none — plan is ready") + + +def _read(filename: str) -> str: + with open(os.path.join(SAMPLES, filename)) as f: + return f.read() + +def _execution_manifest() -> Manifest: + return Manifest( + version=2, + mode="existing", + tenants=[TenantExisting( + name="Acme Corp", + workspace_id=TENANT_T1_ID, + service=ServiceRef(SERVICE_S1_ID, "us-west"), + )], + devices=[ + ManifestDevice(tenant="Acme Corp", serial_number="CNXA001", subscription_key="KEY_A"), + ManifestDevice(tenant="Acme Corp", serial_number="CNXA002", subscription_key="KEY_A"), + ], + ) + + +def _bulk_partial_manifest(adapter: DemoAdapter) -> Manifest: + names = ( + "Demo North Tenant", + "Demo South Tenant", + "Demo Europe Tenant", + "Demo Clean Tenant", + ) + ids = (TENANT_NORTH_ID, TENANT_SOUTH_ID, TENANT_EUROPE_ID, TENANT_CLEAN_ID) + service = ServiceRef(SERVICE_S1_ID, "us-west") + for name in names: + tenant = adapter.ensure_tenant("new", None, name) + adapter.submit_service_provisioning( + tenant.workspace_id, SERVICE_S1_ID, "us-west" + ) + adapter.observe_service_provisioning( + tenant.workspace_id, SERVICE_S1_ID, "us-west" + ) + return Manifest( + version=2, + mode="existing", + tenants=[TenantExisting(name, workspace_id, service) for name, workspace_id in zip(names, ids)], + devices=[ + *( + ManifestDevice("Demo North Tenant", "KEY_SHARED", serial_number=serial) + for serial in ("CNXA001", "CNXA002", "CNXA003") + ), + *( + ManifestDevice("Demo South Tenant", "KEY_SHARED", serial_number=serial) + for serial in ("CNXA005", "CNXA006") + ), + *( + ManifestDevice("Demo Europe Tenant", "KEY_B", serial_number=serial) + for serial in ("CNXA007", "CNXA008") + ), + ManifestDevice("Demo Clean Tenant", "KEY_B", serial_number="CNXA009"), + ], + ) + + +def _run_execution_demo() -> None: + with MemoryStore() as store: + adapter = DemoAdapter() + engine = OnboardingEngine(adapter, store) + job_id = engine.plan(_execution_manifest()).job_id + engine.confirm(job_id) + engine.drain() + print(f"success: {engine.get(job_id)['status']}") + + with MemoryStore() as store: + adapter = DemoAdapter("bulk-partial") + engine = OnboardingEngine(adapter, store) + job_id = engine.plan(_bulk_partial_manifest(adapter)).job_id + engine.confirm(job_id) + engine.drain() + print(f"bulk partial: {engine.get(job_id)['status']}") + + with MemoryStore() as store: + adapter = DemoAdapter("ambiguous-write") + engine = OnboardingEngine(adapter, store) + job_id = engine.plan(_execution_manifest()).job_id + engine.confirm(job_id) + engine.drain() + print(f"ambiguous inline: {engine.get(job_id)['status']}") + + +def main() -> None: + if sys.argv[1:] == ["--execute-demo"]: + _run_execution_demo() + return + adapter = DemoAdapter() + + # ── new-tenant plan ──────────────────────────────────────────────── + with MemoryStore() as store: + manifest1 = parse_yaml_manifest(_read("new_tenant.yaml")) + plan1 = OnboardingEngine(adapter, store).plan(manifest1) + _print_plan("new_tenant.yaml → new-tenant-success", plan1) + + # ── existing-tenant plan ─────────────────────────────────────────── + with MemoryStore() as store: + manifest2 = parse_yaml_manifest(_read("existing_tenant.yaml")) + plan2 = OnboardingEngine(adapter, store).plan(manifest2) + _print_plan("existing_tenant.yaml → existing-tenant-success", plan2) + + # ── CSV devices → plan ───────────────────────────────────────────── + csv_devices = parse_csv_devices(_read("devices.csv")) + manifest3 = Manifest( + version=2, + mode="existing", + tenants=[TenantExisting( + name="Demo West Tenant", + workspace_id=TENANT_T1_ID, + service=ServiceRef(SERVICE_S1_ID, "us-west"), + )], + devices=csv_devices, + ) + with MemoryStore() as store: + plan3 = OnboardingEngine(adapter, store).plan(manifest3) + _print_plan("devices.csv → csv-devices-plan (existing T1, S1)", plan3) + + print() + + +if __name__ == "__main__": + main() diff --git a/msp-onboarding/samples/devices.csv b/msp-onboarding/samples/devices.csv new file mode 100644 index 0000000..cf462e4 --- /dev/null +++ b/msp-onboarding/samples/devices.csv @@ -0,0 +1,3 @@ +serial_number,mac_address,subscription_key,tenant +CNXA001,,KEY_A,Demo West Tenant +,aa:bb:cc:00:00:02,KEY_B,Demo West Tenant diff --git a/msp-onboarding/samples/existing_tenant.yaml b/msp-onboarding/samples/existing_tenant.yaml new file mode 100644 index 0000000..0c6b277 --- /dev/null +++ b/msp-onboarding/samples/existing_tenant.yaml @@ -0,0 +1,18 @@ +version: 2 +mode: existing +tenants: + - name: Demo West Tenant + workspace_id: aaaaaaaa-0001-0001-0001-000000000001 + service: + service_manager_id: bbbbbbbb-0001-0001-0001-000000000001 + region: us-west +devices: + - serial_number: CNXA001 + tenant: Demo West Tenant + subscription_key: KEY_A + - mac_address: "aa:bb:cc:00:00:02" + tenant: Demo West Tenant + subscription_key: KEY_B + - serial_number: CNXA003 + tenant: Demo West Tenant + subscription_key: KEY_B diff --git a/msp-onboarding/samples/new_tenant.yaml b/msp-onboarding/samples/new_tenant.yaml new file mode 100644 index 0000000..70f012b --- /dev/null +++ b/msp-onboarding/samples/new_tenant.yaml @@ -0,0 +1,9 @@ +version: 2 +mode: new +tenants: + - name: Demo New Tenant + country: US + service: + service_manager_id: bbbbbbbb-0001-0001-0001-000000000001 + region: us-west +devices: [] diff --git a/msp-onboarding/server.py b/msp-onboarding/server.py new file mode 100644 index 0000000..5563280 --- /dev/null +++ b/msp-onboarding/server.py @@ -0,0 +1,377 @@ +"""FastAPI entry point for the self-contained MSP onboarding workflow.""" +from __future__ import annotations + +import argparse +from concurrent.futures import ThreadPoolExecutor +from contextlib import asynccontextmanager +from dataclasses import asdict +from pathlib import Path +from threading import RLock +from typing import Any, Callable + +import yaml +from fastapi import Body, FastAPI, HTTPException +from fastapi.middleware.cors import CORSMiddleware +from fastapi.responses import FileResponse, JSONResponse, Response +from fastapi.staticfiles import StaticFiles + +from msp_onboarding.adapter import AdapterError +from msp_onboarding.demo_adapter import DemoAdapter +from msp_onboarding.engine import OnboardingEngine +from msp_onboarding.parser import ( + ParseError, + parse_csv_devices, + parse_csv_tenant_import, + parse_yaml_manifest, +) +from msp_onboarding.pycentral_adapter import PycentralAdapter +from msp_onboarding.store import MemoryStore + + +BASE_DIR = Path(__file__).parent +STATIC_DIR = BASE_DIR / "static" + + +def browser_job(value: Any) -> Any: + # Store and model reads are detached; in-place redaction avoids rebuilding the graph. + pending = [value] + while pending: + item = pending.pop() + if isinstance(item, list): + pending.extend(item) + continue + if not isinstance(item, dict): + continue + item.pop("subscription_id", None) + # Device rows show the operator their own key; everything else (errors, + # plan rows) stays redacted. + if "subscription_key" in item and "glp_id" not in item: + item["subscription_key"] = "***" + pending.extend(item.values()) + return value + + +def create_app() -> FastAPI: + @asynccontextmanager + async def lifespan(app: FastAPI): + app.state.store = MemoryStore() + app.state.adapter = None + app.state.engine = None + app.state.workspace_id = None + app.state.executor = ThreadPoolExecutor( + max_workers=1, thread_name_prefix="onboarding" + ) + app.state.auth_lock = RLock() + try: + yield + finally: + app.state.executor.shutdown(wait=True, cancel_futures=True) + + app = FastAPI(title="MSP Onboarding API", lifespan=lifespan) + app.add_middleware( + CORSMiddleware, + allow_origins=["http://localhost:5173"], + allow_methods=["GET", "POST"], + allow_headers=["*"], + ) + if STATIC_DIR.exists(): + app.mount("/static", StaticFiles(directory=str(STATIC_DIR)), name="static") + + def engine() -> OnboardingEngine: + active = app.state.engine + if active is None: + raise HTTPException(status_code=401, detail="Not authenticated") + return active + + def adapter() -> Any: + active = app.state.adapter + if active is None: + raise HTTPException(status_code=401, detail="Not authenticated") + return active + + def upstream(exc: AdapterError) -> HTTPException: + return HTTPException( + status_code=502, + detail={"path": exc.path, "code": exc.code, "message": exc.message}, + ) + + def has_active_job() -> bool: + return app.state.store.has_active_job() + + def reject_auth_change_while_active() -> None: + if has_active_job(): + raise HTTPException( + status_code=409, + detail="Cannot change authentication while jobs are active", + ) + + def drain_queued(worker_adapter: Any, worker_store: MemoryStore) -> None: + OnboardingEngine(worker_adapter, worker_store).drain() + + def accepted(queue: Callable[[OnboardingEngine], dict]) -> JSONResponse: + with app.state.auth_lock: + active = engine() + worker_adapter = adapter() + queued = queue(active) + queued_job_id = queued["id"] + if queued["status"] == "queued": + app.state.executor.submit( + drain_queued, worker_adapter, app.state.store + ) + return JSONResponse( + status_code=202, + content={"job_id": queued_job_id, "status": queued["status"]}, + ) + + @app.post("/api/auth/login") + def login(token_info: dict[str, Any]) -> dict: + with app.state.auth_lock: + reject_auth_change_while_active() + required = {"client_id", "client_secret", "workspace_id"} + missing = required - token_info.keys() + if missing: + raise HTTPException( + status_code=400, + detail=f"Missing required fields: {', '.join(sorted(missing))}", + ) + try: + adapter = PycentralAdapter({"unified": token_info}) + adapter.list_tenants() + except AdapterError as exc: + raise upstream(exc) + with app.state.auth_lock: + reject_auth_change_while_active() + app.state.adapter = adapter + app.state.engine = OnboardingEngine(adapter, app.state.store) + app.state.workspace_id = token_info["workspace_id"] + return {"ok": True} + + @app.post("/api/auth/demo") + def demo(scenario: str = "success") -> dict: + with app.state.auth_lock: + reject_auth_change_while_active() + try: + adapter = DemoAdapter(scenario) + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) + app.state.adapter = adapter + app.state.engine = OnboardingEngine(adapter, app.state.store) + app.state.workspace_id = None + return {"ok": True, "demo": True} + + @app.post("/api/auth/logout") + def logout() -> dict: + with app.state.auth_lock: + reject_auth_change_while_active() + app.state.adapter = None + app.state.engine = None + app.state.workspace_id = None + return {"ok": True} + + @app.get("/api/status") + def status() -> dict: + with app.state.auth_lock: + adapter = app.state.adapter + return { + "authenticated": adapter is not None, + "demo": isinstance(adapter, DemoAdapter), + "workspace_id": app.state.workspace_id, + "has_active_job": has_active_job(), + } + + @app.get("/api/discovery/tenants") + def tenants() -> list[dict]: + try: + return [ + asdict(item) + for item in adapter().list_tenants() + if item.ownership == "MSP_OWNED_INVENTORY" + ] + except AdapterError as exc: + raise upstream(exc) + + @app.get("/api/discovery/services") + def services( + workspace_id: str | None = None, tenant_ids: str | None = None + ) -> list[dict] | dict[str, list[dict]]: + try: + if tenant_ids is not None: + ids = [item.strip() for item in tenant_ids.split(",") if item.strip()] + services_by_tenant = adapter().services_for_tenants(ids) + return { + tenant_id: [asdict(item) for item in tenant_services] + for tenant_id, tenant_services in services_by_tenant.items() + } + return [ + asdict(item) + for item in adapter().list_eligible_services(workspace_id) + ] + except AdapterError as exc: + raise upstream(exc) + + @app.get("/api/discovery/devices") + def devices() -> list[dict]: + try: + return [asdict(item) for item in adapter().list_available_devices()] + except AdapterError as exc: + raise upstream(exc) + + @app.get("/api/discovery/subscriptions") + def subscriptions() -> list[dict]: + try: + return [ + { + "key": item.key, + "status": item.status, + "product_type": item.product_type, + "available_quantity": item.available_quantity, + "quantity": item.quantity, + "start_date": item.start_date, + "end_date": item.end_date, + "subscription_type": item.subscription_type, + "tier_description": item.tier_description, + } + for item in adapter().list_subscriptions() + ] + except AdapterError as exc: + raise upstream(exc) + + @app.post("/api/import/csv") + def import_csv( + csv_text: str = Body(embed=True), + csv_type: str = Body(default="devices"), + tenant_names: list[str] | None = Body(default=None), + ) -> dict: + try: + engine() + if csv_type == "tenants": + return asdict(parse_csv_tenant_import(csv_text)) + return { + "devices": [ + asdict(item) + for item in parse_csv_devices(csv_text, tenant_names=tenant_names) + ] + } + except HTTPException: + raise + except ParseError as exc: + raise HTTPException( + status_code=422, detail={"errors": [asdict(error) for error in exc.errors]} + ) + except Exception as exc: + raise HTTPException( + status_code=500, + detail={ + "errors": [ + { + "path": "csv", + "code": "import_failed", + "message": "CSV import could not be completed", + } + ] + }, + ) from exc + + @app.post("/api/jobs/plan") + def plan(manifest: dict[str, Any]) -> dict: + active = engine() + try: + parsed = parse_yaml_manifest(yaml.safe_dump(manifest, sort_keys=False)) + except ParseError as exc: + raise HTTPException( + status_code=422, detail={"errors": [asdict(error) for error in exc.errors]} + ) + try: + return browser_job(active.plan(parsed).to_dict()) + except AdapterError as exc: + raise upstream(exc) + + @app.post("/api/jobs/{job_id}/confirm") + def confirm(job_id: str) -> JSONResponse: + try: + return accepted( + lambda active: active.confirm(job_id), + ) + except KeyError as exc: + raise HTTPException(status_code=404, detail=str(exc)) + except ValueError as exc: + raise HTTPException(status_code=409, detail=str(exc)) + except AdapterError as exc: + raise upstream(exc) + + @app.get("/api/jobs") + def list_jobs() -> list[dict]: + engine() + return browser_job(app.state.store.list_jobs()) + + @app.get("/api/jobs/{job_id}") + def get_job(job_id: str) -> dict: + try: + return browser_job(engine().get(job_id)) + except KeyError as exc: + raise HTTPException(status_code=404, detail=str(exc)) + + @app.post("/api/jobs/{job_id}/stop") + def stop(job_id: str) -> JSONResponse: + try: + stopped = engine().stop(job_id) + return JSONResponse( + status_code=202, + content={"job_id": stopped["id"], "status": stopped["status"]}, + ) + except KeyError as exc: + raise HTTPException(status_code=404, detail=str(exc)) + except ValueError as exc: + raise HTTPException(status_code=409, detail=str(exc)) + + @app.get("/api/jobs/{job_id}/manifest") + def manifest(job_id: str) -> dict: + engine() + value = app.state.store.get_manifest_dict(job_id) + if value is None: + raise HTTPException(status_code=404, detail=f"Job not found: {job_id}") + return value + + @app.get("/api/jobs/{job_id}/report.csv") + def report(job_id: str) -> Response: + try: + csv_report = engine().report_csv(job_id) + except KeyError as exc: + raise HTTPException(status_code=404, detail=str(exc)) + except ValueError as exc: + raise HTTPException(status_code=409, detail=str(exc)) + return Response( + content=csv_report, + media_type="text/csv", + headers={ + "Content-Disposition": f'attachment; filename="onboarding-job-{job_id}.csv"' + }, + ) + + @app.get("/{full_path:path}") + def spa_fallback(full_path: str) -> FileResponse: + index = STATIC_DIR / "index.html" + if not index.exists(): + raise HTTPException( + status_code=503, + detail="UI not built. Run `cd frontend && npm install && npm run build`.", + ) + return FileResponse(index) + + return app + + +app = create_app() + + +if __name__ == "__main__": + parser = argparse.ArgumentParser(description="MSP onboarding API") + parser.add_argument("--host", default="127.0.0.1") + parser.add_argument("--port", type=int, default=8000) + args = parser.parse_args() + + import logging + import uvicorn + + logging.basicConfig(level=logging.INFO) + uvicorn.run(create_app(), host=args.host, port=args.port) diff --git a/msp-onboarding/static/assets/AddDevicesPage-CA5mdvdK.js b/msp-onboarding/static/assets/AddDevicesPage-CA5mdvdK.js new file mode 100644 index 0000000..4c3ef64 --- /dev/null +++ b/msp-onboarding/static/assets/AddDevicesPage-CA5mdvdK.js @@ -0,0 +1,3 @@ +import{a as e,i as t,n,t as r}from"./RunSurface-DZb15gFL.js";import{t as i}from"./CsvImportPanel-B8vKd-O9.js";import{b as a,i as o,l as s,n as c,o as l,p as ee,r as te,s as ne,t as re,u as ie,v as u}from"./index-CeYmaMoQ.js";var d=a(u(),1),f=e=>(e.serial_number??``).trim().toUpperCase(),p=e=>(e.mac_address??``).trim().toLowerCase().replace(/[:.\-\s]/g,``),m=e=>f(e)?`serial:${f(e)}`:`mac:${p(e)}`,ae=(e,t)=>f(e)!==``&&f(e)===f(t)||p(e)!==``&&p(e)===p(t),h=c(),oe=`serial_number,mac_address,subscription_key,tenant +SERIAL_001,,SUBSCRIPTION_KEY_A,Example tenant +`,g=()=>({devices:l().then(e=>({data:e})).catch(e=>({error:e})),subscriptions:s().then(e=>({data:e})).catch(e=>({error:e}))}),_=e=>e instanceof te||e instanceof Error&&e.message.trim()&&!(e instanceof TypeError)?e.message:`Unable to reach onboarding. Check the server and try again.`;function v(e){let t=Number(e.available_quantity);return Number.isSafeInteger(t)&&t>=0?t:0}function y(e){let t=e?.trim().toUpperCase();return t===`AP`||t===`SWITCH`||t===`GW`?t:``}function b(e){return{AP:`AP`,SWITCH:`Switch`,GW:`GW`,"":`Unknown`}[y(e)]}function x(e){let t=e.subscription_type?.trim().toUpperCase();return t===`CENTRAL_SWITCH`?`SWITCH`:t===`CENTRAL_GW`?`GW`:t===`CENTRAL_AP`?`AP`:``}function S(e,t){let n=x(e),r=y(t);if(!n||!r||n===r)return``;let i=b(n),a=b(r);return`${i} subscription; this device is ${r===`AP`?`an`:`a`} ${a}`}function C(e){if(e.status!==`STARTED`)return`Status is ${e.status}; only STARTED subscriptions can be assigned.`;if(e.product_type!==`DEVICE`)return`Product type is ${e.product_type}; only DEVICE subscriptions can be assigned.`;if(v(e)<1)return`No seats available.`;let t=new Date().toISOString().slice(0,10);return e.start_date&&e.start_date>t?`Starts ${e.start_date}.`:e.end_date&&e.end_date{let n=Object.values(e).filter(e=>e.subscriptionKey===t.key),r=n.length,i=r-v(t),a=[...new Set(n.map(e=>e.tenantName))];return i>0?[{path:`devices`,code:`subscription_capacity_exceeded`,message:`${t.key} has ${v(t)} ${v(t)===1?`seat`:`seats`} available before this draft, but ${r} devices are mapped. Affected ${a.length===1?`tenant`:`tenants`}: ${a.join(`, `)}. Remove ${i} ${i===1?`device`:`devices`} or choose another key.`}]:[]})}function T({demo:a,initialJobId:s=null,onJobDone:c,onJobStarted:l}){let[u,f]=(0,d.useState)(s?`run`:`tenant`),[p,y]=(0,d.useState)([]),[x,T]=(0,d.useState)([]),[E,ce]=(0,d.useState)(``),[D,O]=(0,d.useState)({}),[k,A]=(0,d.useState)({}),[j,le]=(0,d.useState)([]),[M,ue]=(0,d.useState)([]),[N,P]=(0,d.useState)({}),[F,de]=(0,d.useState)(``),[I,fe]=(0,d.useState)(null),[L,R]=(0,d.useState)([]),[z,B]=(0,d.useState)([]),[V,H]=(0,d.useState)(``),[U,W]=(0,d.useState)(!1),[pe,G]=(0,d.useState)(!1),[K,q]=(0,d.useState)(null),[J,Y]=(0,d.useState)(s),X=(0,d.useRef)(null);(0,d.useEffect)(()=>{if(s)return;X.current??=g();let e=!0;return ie().then(t=>{e&&y(t)}).catch(t=>{e&&H(_(t))}),()=>{e=!1}},[s]);let me=(0,d.useRef)(!0);(0,d.useEffect)(()=>{if(me.current){me.current=!1;return}return re()},[u]);let Z=x.flatMap(e=>{let t=p.find(t=>t.workspace_id===e);return t?[t]:[]}),he=Z.map(e=>e.workspace_name),ge=(0,d.useMemo)(()=>{let e=E.trim().toLocaleLowerCase(`en`);return p.filter(t=>!e||t.workspace_name.toLocaleLowerCase(`en`).includes(e)||t.workspace_id.toLocaleLowerCase(`en`).includes(e))},[E,p]),_e=(0,d.useMemo)(()=>{let e=F.trim().toLocaleLowerCase(`en`).replace(/[^a-z0-9]/g,``);return j.filter(t=>!e||[t.serial_number,t.mac_address].some(t=>t.toLocaleLowerCase(`en`).replace(/[^a-z0-9]/g,``).includes(e)))},[F,j]),Q=j.filter(e=>m(e)in N),ve=M.reduce((e,t)=>(e[t.key]=Object.values(N).filter(e=>e.subscriptionKey===t.key).length,e),{}),ye=se(N,M),be=Q.flatMap((e,t)=>{let n=N[m(e)],r=M.find(e=>e.key===n.subscriptionKey);return[...n.tenantName?[]:[{path:`devices[${t}].tenant`,code:`missing_tenant`,message:`Choose a tenant for ${e.serial_number||e.mac_address}.`}],...n.subscriptionKey?[]:[{path:`devices[${t}].subscription_key`,code:`missing_subscription`,message:`Choose a subscription for ${e.serial_number||e.mac_address}.`}],...n.subscriptionKey&&(!r||C(r))?[{path:`devices[${t}].subscription_key`,code:`ineligible_subscription`,message:`${n.subscriptionKey} is not an eligible subscription key for ${e.serial_number||e.mac_address}.`}]:[],...r&&S(r,e.device_type)?[{path:`devices[${t}].subscription_key`,code:`subscription_type_mismatch`,message:`${n.subscriptionKey} is not an eligible subscription key (${S(r,e.device_type)}). Choose an eligible key to continue.`}]:[]]}),xe=Q.filter(e=>{let t=N[m(e)],n=M.find(e=>e.key===t.subscriptionKey);return!!(t.tenantName&&n&&!C(n)&&!S(n,e.device_type))}).length,Se=e=>{let t=x.includes(e),n=p.find(t=>t.workspace_id===e)?.workspace_name;if(T(n=>t?n.filter(t=>t!==e):[...n,e]),t&&n){let t=x.filter(t=>t!==e),r=t.length===1?p.find(e=>e.workspace_id===t[0])?.workspace_name??``:``;P(e=>Object.fromEntries(Object.entries(e).filter(([,e])=>e.tenantName!==n).map(([e,t])=>[e,t.tenantName||!r?t:{...t,tenantName:r}])))}O({}),A({}),B([]),q(null)},Ce=async()=>{if(!(!x.length||U)){W(!0),H(``),B([]);try{let e=Object.keys(D).length?D:await ne(x);O(e);let n={...k};x.forEach(r=>{let i=e[r]??[],a=n[r];a&&i.some(e=>t(e)===t(a))||(i.length===1?n[r]={service_manager_id:i[0].service_manager_id,region:i[0].region}:delete n[r])}),A(n);let r=x.filter(t=>!e[t]?.length);if(r.length){B([{path:`tenants`,code:`missing_provisioned_service`,message:`${r.length} selected ${r.length===1?`tenant has`:`tenants have`} no provisioned application. Deselect ${r.length===1?`it`:`them`} below or provision the application first.`}]);return}let i=x.filter(e=>!n[e]);if(i.length){B([{path:`tenants`,code:`missing_service_choice`,message:`Choose an application for ${i.length===1?`the highlighted tenant`:`each of the ${i.length} highlighted tenants`} below, then continue.`}]);return}let a=X.current??=g(),[o,s]=await Promise.all([a.devices,a.subscriptions]);if(`error`in o)throw o.error;if(`error`in s)throw s.error;le(o.data.filter(e=>e.management.toLocaleLowerCase(`en`)===`msp`)),ue(s.data),f(`mapping`)}catch(e){H(_(e))}finally{W(!1)}}},we=(e,t)=>{let n=m(e);P(e=>t?{...e,[n]:e[n]??{subscriptionKey:``,tenantName:Z.length===1?Z[0].workspace_name:``}}:Object.fromEntries(Object.entries(e).filter(([e])=>e!==n))),R([]),q(null)},Te=e=>{if(fe(e),R([]),!e||e.kind!==`device`||e.issues.length||!e.parsedRows.length)return;let t={},n=[];e.parsedRows.forEach((e,r)=>{let i=j.find(t=>ae(t,e));if(!i){n.push({path:`devices[${r}]`,code:`device_not_in_inventory`,message:`${e.serial_number||e.mac_address||`CSV line ${r+2}`} is not in available MSP-owned inventory.`});return}t[m(i)]={subscriptionKey:e.subscription_key,tenantName:e.tenant}}),P(t),R(n),q(null)},$=()=>Q.map(e=>{let t=N[m(e)];return{tenant:t.tenantName,...e.serial_number?{serial_number:e.serial_number}:{mac_address:e.mac_address},subscription_key:t.subscriptionKey}}),Ee=()=>Z.map(e=>({name:e.workspace_name,workspace_id:e.workspace_id,service:k[e.workspace_id]})),De=async()=>{let e=[...Q.length?[]:[{path:`devices`,code:`missing_devices`,message:`Select at least one device.`}],...be,...L,...I?.issues??[]];if(e.length){B(e);return}W(!0),H(``),B([]);try{let e=await ee({version:2,mode:`existing`,tenants:Ee(),devices:$()});q(e),B([...e.errors,...e.tenant_groups.flatMap(e=>e.errors)]),f(`preflight`)}catch(e){e instanceof te&&e.issues.length?B(e.issues):H(_(e))}finally{W(!1)}},Oe=async()=>{if(K){G(!0),H(``);try{let e=await o(K.job_id);Y(e.job_id),f(`run`),l?.(e.job_id)}catch(e){H(_(e))}finally{G(!1)}}};return u===`run`&&J?(0,h.jsx)(n,{jobId:J,onDone:()=>{Y(null),q(null),f(`tenant`),c?.()}}):u===`preflight`&&K?(0,h.jsxs)(h.Fragment,{children:[z.length?(0,h.jsx)(`ul`,{className:`workflow-issues`,role:`alert`,children:z.map((e,t)=>(0,h.jsx)(`li`,{children:e.message},`${e.path}-${e.code}-${t}`))}):null,V?(0,h.jsx)(`p`,{className:`form-error`,role:`alert`,children:V}):null,(0,h.jsx)(r,{backLabel:`devices`,confirming:pe,demo:a,devices:$(),onBack:()=>f(`mapping`),onConfirm:()=>void Oe(),plan:K,subscriptions:M})]}):u===`tenant`?(0,h.jsxs)(`section`,{"aria-labelledby":`add-devices-title`,className:`add-devices-page`,children:[(0,h.jsx)(`p`,{className:`section-label`,children:`Device journey`}),(0,h.jsx)(`h1`,{id:`add-devices-title`,children:`Assign devices to tenants`}),(0,h.jsx)(`p`,{className:`tenant-page-lede`,children:`Choose the existing tenants that will receive MSP-owned devices. Provisioned applications are verified before mapping.`}),V?(0,h.jsx)(`p`,{className:`form-error`,role:`alert`,children:V}):null,z.length?(0,h.jsx)(`ul`,{className:`workflow-issues`,role:`alert`,children:z.map((e,t)=>(0,h.jsx)(`li`,{children:e.message},`${e.path}-${e.code}-${t}`))}):null,(0,h.jsxs)(`section`,{"aria-labelledby":`tenant-picker-title`,className:`device-tenant-picker`,children:[(0,h.jsx)(`div`,{className:`tenant-editor-heading`,children:(0,h.jsxs)(`div`,{children:[(0,h.jsx)(`h2`,{id:`tenant-picker-title`,children:`Existing tenants`}),(0,h.jsxs)(`p`,{children:[x.length,` selected`]})]})}),(0,h.jsxs)(`label`,{className:`device-filter-field`,children:[`Filter tenants`,(0,h.jsx)(`input`,{"aria-label":`Filter tenants`,onChange:e=>ce(e.target.value),placeholder:`Name or workspace ID`,type:`search`,value:E})]}),(0,h.jsx)(`ul`,{"aria-label":`Tenant results`,className:`existing-tenant-picker-list`,children:ge.map(e=>{let t=x.includes(e.workspace_id);return(0,h.jsx)(`li`,{children:(0,h.jsxs)(`label`,{className:t?`is-selected`:void 0,children:[(0,h.jsx)(`input`,{"aria-label":`Select ${e.workspace_name}`,checked:t,onChange:()=>Se(e.workspace_id),type:`checkbox`}),(0,h.jsxs)(`span`,{children:[(0,h.jsx)(`strong`,{children:e.workspace_name}),(0,h.jsx)(`span`,{children:e.workspace_id})]}),(0,h.jsx)(`span`,{className:`existing-tenant-picker-state`,children:t?`Selected`:`Select`})]})},e.workspace_id)})}),ge.length?null:(0,h.jsx)(`p`,{className:`device-empty-state`,children:`No tenants match this filter.`})]}),x.some(e=>e in D)?(0,h.jsxs)(`section`,{"aria-labelledby":`device-services-title`,className:`device-service-results`,children:[(0,h.jsx)(`h2`,{id:`device-services-title`,children:`Provisioned applications`}),(0,h.jsx)(`ul`,{children:x.map(n=>{let r=p.find(e=>e.workspace_id===n),i=D[n]??[];return(0,h.jsxs)(`li`,{children:[(0,h.jsxs)(`span`,{children:[(0,h.jsx)(`strong`,{children:r?.workspace_name??n}),(0,h.jsx)(`small`,{children:i.length?`${i.length} provisioned ${i.length===1?`instance`:`instances`}`:`No provisioned application`})]}),i.length>1?(0,h.jsxs)(h.Fragment,{children:[(0,h.jsxs)(`label`,{className:`visually-hidden`,htmlFor:`service-${n}`,children:[`Application for `,r?.workspace_name]}),(0,h.jsxs)(`select`,{"aria-invalid":!k[n]||void 0,id:`service-${n}`,onChange:e=>{let r=i.find(n=>t(n)===e.target.value);A(e=>r?{...e,[n]:{service_manager_id:r.service_manager_id,region:r.region}}:Object.fromEntries(Object.entries(e).filter(([e])=>e!==n)))},value:k[n]?t(k[n]):``,children:[(0,h.jsx)(`option`,{value:``,children:`Choose an application`}),i.map(n=>(0,h.jsx)(`option`,{value:t(n),children:e(n.name,n.region,n.region_display_name)},t(n)))]})]}):i.length===1?(0,h.jsx)(`span`,{children:e(i[0].name,i[0].region,i[0].region_display_name)}):(0,h.jsx)(`span`,{className:`form-error`,children:`Deselect this tenant or provision its application first.`})]},n)})})]}):null,(0,h.jsxs)(`div`,{className:`tenant-page-actions`,children:[(0,h.jsx)(`span`,{children:x.length?`${x.length} ${x.length===1?`tenant`:`tenants`} selected`:`Select at least one tenant`}),(0,h.jsx)(`button`,{className:`primary-button`,disabled:!x.length||U,onClick:()=>void Ce(),type:`button`,children:U?`Preparing inventory…`:`Continue to devices`})]})]}):(0,h.jsxs)(`section`,{"aria-labelledby":`device-mapping-title`,className:`add-devices-page`,children:[(0,h.jsx)(`p`,{className:`section-label`,children:`Device journey`}),(0,h.jsx)(`h1`,{id:`device-mapping-title`,children:`Map devices and subscriptions`}),(0,h.jsx)(`p`,{className:`tenant-page-lede`,children:`Select MSP-owned inventory and choose one tenant and subscription key for every device.`}),V?(0,h.jsx)(`p`,{className:`form-error`,role:`alert`,children:V}):null,z.length||L.length?(0,h.jsx)(`ul`,{className:`workflow-issues`,role:`alert`,children:[...z,...L].map((e,t)=>(0,h.jsx)(`li`,{children:e.message},`${e.path}-${e.code}-${t}`))}):null,(0,h.jsx)(i,{disabled:U,kind:`device`,onChange:Te,template:oe,tenantNames:he,value:I}),(0,h.jsxs)(`section`,{"aria-labelledby":`mapping-table-title`,className:`device-mapping-panel`,children:[(0,h.jsxs)(`div`,{className:`tenant-editor-heading`,children:[(0,h.jsxs)(`div`,{children:[(0,h.jsx)(`h2`,{id:`mapping-table-title`,children:`MSP-owned inventory`}),(0,h.jsxs)(`p`,{children:[xe,` of `,Q.length,` selected devices ready`]})]}),Q.length>1?(0,h.jsxs)(`label`,{className:`bulk-subscription`,children:[`Apply key to all selected`,(0,h.jsxs)(`select`,{"aria-label":`Apply subscription key to all selected eligible devices`,onChange:e=>{let t=e.target.value;if(!t)return;let n=M.find(e=>e.key===t);P(e=>Object.fromEntries(Object.entries(e).map(([e,r])=>{let i=j.find(t=>m(t)===e);return[e,n&&i&&!S(n,i.device_type)?{...r,subscriptionKey:t}:r]}))),e.target.value=``},value:``,children:[(0,h.jsx)(`option`,{value:``,children:`Choose a key`}),M.filter(e=>!C(e)).map(e=>(0,h.jsx)(`option`,{value:e.key,children:w(e,0)},e.key))]})]}):null]}),(0,h.jsxs)(`label`,{className:`device-filter-field`,children:[`Filter devices`,(0,h.jsx)(`input`,{"aria-label":`Filter devices`,onChange:e=>de(e.target.value),placeholder:`Serial number or MAC address`,type:`search`,value:F})]}),(0,h.jsx)(`div`,{className:`device-mapping-scroller`,children:(0,h.jsxs)(`table`,{className:`device-mapping-table`,children:[(0,h.jsx)(`caption`,{className:`visually-hidden`,children:`Device to tenant and subscription mapping`}),(0,h.jsx)(`thead`,{children:(0,h.jsxs)(`tr`,{children:[(0,h.jsx)(`th`,{scope:`col`,children:(0,h.jsx)(`span`,{className:`visually-hidden`,children:`Include`})}),(0,h.jsx)(`th`,{scope:`col`,children:`Device`}),(0,h.jsx)(`th`,{scope:`col`,children:`Type`}),Z.length>1?(0,h.jsx)(`th`,{scope:`col`,children:`Tenant`}):null,(0,h.jsx)(`th`,{scope:`col`,children:`Subscription`}),(0,h.jsx)(`th`,{scope:`col`,children:`Status`})]})}),(0,h.jsxs)(`tbody`,{children:[_e.map(e=>{let t=m(e),n=N[t],r=e.serial_number||e.mac_address,i=M.find(e=>e.key===n?.subscriptionKey),a=i?S(i,e.device_type):``,o=i&&!C(i)&&!a,s=i&&ve[i.key]>v(i),c=n?s?`Capacity`:n.tenantName?o?`Ready`:`Needs subscription`:`Needs tenant`:``;return(0,h.jsxs)(`tr`,{className:n?`is-selected`:void 0,children:[(0,h.jsx)(`td`,{children:(0,h.jsx)(`input`,{"aria-label":`${n?`Remove`:`Add`} ${r} ${n?`from`:`to`} run`,checked:!!n,onChange:t=>we(e,t.currentTarget.checked),type:`checkbox`})}),(0,h.jsx)(`th`,{scope:`row`,children:r||`Not reported`}),(0,h.jsx)(`td`,{children:b(e.device_type)}),Z.length>1?(0,h.jsxs)(`td`,{children:[(0,h.jsxs)(`label`,{className:`visually-hidden`,htmlFor:`tenant-${t}`,children:[`Tenant for `,r]}),(0,h.jsxs)(`select`,{disabled:!n,id:`tenant-${t}`,onChange:e=>P(n=>({...n,[t]:{...n[t],tenantName:e.target.value}})),value:n?.tenantName??``,children:[(0,h.jsx)(`option`,{value:``,children:`Choose a tenant`}),Z.map(e=>(0,h.jsx)(`option`,{value:e.workspace_name,children:e.workspace_name},e.workspace_id))]})]}):null,(0,h.jsxs)(`td`,{children:[(0,h.jsxs)(`label`,{className:`visually-hidden`,htmlFor:`subscription-${t}`,children:[`Subscription for `,r]}),(0,h.jsxs)(`select`,{disabled:!n,id:`subscription-${t}`,onChange:e=>P(n=>({...n,[t]:{...n[t],subscriptionKey:e.target.value}})),value:n?.subscriptionKey??``,children:[(0,h.jsx)(`option`,{value:``,children:`Choose a key`}),M.filter(t=>!C(t)&&!S(t,e.device_type)).map(e=>(0,h.jsx)(`option`,{value:e.key,children:w(e,ve[e.key]??0)},e.key)),n?.subscriptionKey&&!M.some(e=>e.key===n.subscriptionKey)?(0,h.jsxs)(`option`,{disabled:!0,value:n.subscriptionKey,children:[n.subscriptionKey,`: Imported key is not eligible.`]}):null]})]}),(0,h.jsx)(`td`,{children:(0,h.jsx)(`span`,{className:c===`Ready`?`device-mapping-status is-ready`:c===`Needs tenant`?`device-mapping-status is-warning`:c?`device-mapping-status is-danger`:void 0,children:c})})]},t)}),_e.length?null:(0,h.jsx)(`tr`,{children:(0,h.jsx)(`td`,{className:`device-empty-state`,colSpan:Z.length>1?6:5,children:`No devices match this filter.`})})]})]})})]}),ye.length?(0,h.jsx)(`ul`,{"aria-label":`Seat capacity exceeded`,className:`workflow-issues workbench-capacity-errors`,role:`alert`,children:ye.map(e=>(0,h.jsx)(`li`,{children:e.message},e.message))}):null,(0,h.jsx)(`p`,{className:`tenant-page-note`,children:`Seat counts are a run-wide estimate. Preflight rechecks live capacity before any write.`}),(0,h.jsxs)(`div`,{className:`tenant-page-actions`,children:[(0,h.jsx)(`button`,{className:`secondary-button`,disabled:U,onClick:()=>f(`tenant`),type:`button`,children:`Back to tenants`}),(0,h.jsxs)(`span`,{children:[xe,` of `,Q.length,` ready`]}),(0,h.jsx)(`button`,{className:`primary-button`,disabled:U||!Q.length||!!(be.length||L.length||I?.issues.length),onClick:()=>void De(),type:`button`,children:U?`Running preflight…`:`Run preflight`})]})]})}export{T as AddDevicesPage}; \ No newline at end of file diff --git a/msp-onboarding/static/assets/CsvImportPanel-B8vKd-O9.js b/msp-onboarding/static/assets/CsvImportPanel-B8vKd-O9.js new file mode 100644 index 0000000..4e30b10 --- /dev/null +++ b/msp-onboarding/static/assets/CsvImportPanel-B8vKd-O9.js @@ -0,0 +1,4 @@ +import{b as e,d as t,f as n,g as r,n as i,r as a,v as o}from"./index-CeYmaMoQ.js";var s=r(`download`,[[`path`,{d:`M12 15V3`,key:`m9g1x1`}],[`path`,{d:`M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4`,key:`ih7n3h`}],[`path`,{d:`m7 10 5 5 5-5`,key:`brsn70`}]]),c=r(`file-spreadsheet`,[[`path`,{d:`M6 22a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h8a2.4 2.4 0 0 1 1.704.706l3.588 3.588A2.4 2.4 0 0 1 20 8v12a2 2 0 0 1-2 2z`,key:`1oefj6`}],[`path`,{d:`M14 2v5a1 1 0 0 0 1 1h5`,key:`wfsgrz`}],[`path`,{d:`M8 13h2`,key:`yr2amv`}],[`path`,{d:`M14 13h2`,key:`un5t4a`}],[`path`,{d:`M8 17h2`,key:`2yhykz`}],[`path`,{d:`M14 17h2`,key:`10kma7`}]]),l=r(`upload`,[[`path`,{d:`M12 3v12`,key:`1x0j5s`}],[`path`,{d:`m17 8-5-5-5 5`,key:`7q97r8`}],[`path`,{d:`M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4`,key:`ih7n3h`}]]),u=r(`x`,[[`path`,{d:`M18 6 6 18`,key:`1bl5f8`}],[`path`,{d:`m6 6 12 12`,key:`d8bk6v`}]]),d=e(o(),1),f=i();function p(e){let t=e.replace(/^\uFEFF/,``);if(!t)return[];let n=[],r=[],i=``,a=!1;for(let e=0;e1&&n.at(-1)?.every(e=>!e);)n.pop();return n}function m(e){return/[",\r\n]/.test(e)?`"${e.replaceAll(`"`,`""`)}"`:e}function h(e){return`${e.map(e=>e.map(m).join(`,`)).join(` +`)}\n`}function g(e){let t=e.match(/^csv\.rows\[(\d+)\]/);if(t)return Number(t[1]);let n=e.match(/^(?:devices|tenants)\[(\d+)\]/);return n?Number(n[1])+2:null}function _(e){return e.reduce((e,t)=>{let n=g(t.path);return n!==null&&(e[n]=[...e[n]??[],t]),e},{})}function v(e){return e.match(/\.([a-z_]+)$/)?.[1]??``}function y(e){return e.replaceAll(`_`,` `).replace(/^./,e=>e.toUpperCase())}function b(e){return e instanceof a&&e.issues.length?e.issues:[{path:`csv`,code:`import_failed`,message:e instanceof Error?e.message:`CSV import could not be completed.`}]}function x({disabled:e=!1,kind:r,onChange:i,template:a,tenantNames:o=[],value:m}){let x=(0,d.useId)(),S=(0,d.useId)(),C=(0,d.useRef)(0),w=(0,d.useRef)(m?.rawGrid??[]),[T,E]=(0,d.useState)(!1),[D,O]=(0,d.useState)(!1),[k,A]=(0,d.useState)(!1);(0,d.useEffect)(()=>{w.current=m?.rawGrid??[]},[m?.rawGrid]);let j=(e,t,n=[])=>{let i={filename:e,rawGrid:t,issues:n,rowIssues:_(n)};return r===`tenant`?{...i,kind:r,parsedRows:[],services:[]}:{...i,kind:r,parsedRows:[]}},M=async(e,a)=>{let s=++C.current,c=h(e);O(!0);try{if(r===`tenant`){let t=await n(c);if(s!==C.current)return;i({filename:a,issues:[],kind:r,parsedRows:t.tenants,rawGrid:e,rowIssues:{},services:t.services??[]})}else{let n=await t(c,o);if(s!==C.current)return;i({filename:a,issues:[],kind:r,parsedRows:n.devices,rawGrid:e,rowIssues:{}})}}catch(t){if(s!==C.current)return;i(j(a,e,b(t)))}finally{s===C.current&&(O(!1),A(!1))}},N=async t=>{if(!t||e)return;let n=p(await t.text());w.current=n,i(j(t.name,n)),await M(n,t.name)},P=(t,n,r)=>{if(!m||e)return;let a=m.rawGrid.map(e=>[...e]);a[t][n]=r,w.current=a,A(!0),i(j(m.filename,a))},F=()=>{let e=URL.createObjectURL(new Blob([a],{type:`text/csv;charset=utf-8`})),t=document.createElement(`a`);t.download=`msp-${r}-import-template.csv`,t.href=e,document.body.append(t),t.click(),t.remove(),URL.revokeObjectURL(e)},I=m?.rawGrid[0]??[],L=m?.rawGrid.slice(1)??[],R=m?.issues.filter(e=>g(e.path)===null)??[],z=r===`tenant`?`tenant`:`device`;return(0,f.jsxs)(`section`,{"aria-busy":D||void 0,"aria-labelledby":x,className:`csv-import-panel${T?` is-dragging`:``}${e?` is-disabled`:``}`,onDragEnter:t=>{t.preventDefault(),e||E(!0)},onDragLeave:e=>{e.relatedTarget instanceof Node&&e.currentTarget.contains(e.relatedTarget)||E(!1)},onDragOver:t=>{t.preventDefault(),e||(t.dataTransfer.dropEffect=`copy`,E(!0))},onDrop:e=>{e.preventDefault(),E(!1),N(e.dataTransfer.files?.[0]??null)},children:[(0,f.jsxs)(`div`,{className:`csv-import-heading`,children:[(0,f.jsxs)(`div`,{className:`csv-import-title`,children:[(0,f.jsx)(c,{"aria-hidden":`true`,size:19,strokeWidth:1.8}),(0,f.jsxs)(`div`,{children:[(0,f.jsxs)(`h2`,{id:x,children:[`Import `,z,` CSV`]}),(0,f.jsx)(`p`,{children:r===`tenant`?`Name and country are required.`:`Map each device to a tenant and subscription key.`})]})]}),(0,f.jsxs)(`button`,{className:`inline-action csv-import-template`,onClick:F,type:`button`,children:[(0,f.jsx)(s,{"aria-hidden":`true`,size:15}),` Download template`]})]}),(0,f.jsxs)(`label`,{"aria-disabled":e||void 0,className:`csv-import-dropzone`,htmlFor:S,children:[(0,f.jsx)(l,{"aria-hidden":`true`,size:18}),(0,f.jsxs)(`span`,{children:[(0,f.jsx)(`strong`,{children:m?`Replace CSV`:`Choose a CSV`}),` or drop it anywhere in this panel`]}),(0,f.jsx)(`input`,{accept:`.csv,text/csv`,"aria-label":`Choose ${z} CSV`,className:`visually-hidden`,disabled:e,id:S,onChange:e=>{N(e.target.files?.[0]??null),e.target.value=``},type:`file`})]}),m?(0,f.jsxs)(`div`,{className:`csv-import-result`,children:[(0,f.jsxs)(`div`,{className:`csv-import-summary`,children:[(0,f.jsxs)(`p`,{"aria-live":`polite`,role:`status`,children:[(0,f.jsx)(`strong`,{children:m.filename}),(0,f.jsx)(`span`,{children:D?`Validating changes…`:k?`${L.length} ${L.length===1?`row`:`rows`}, unvalidated changes`:`${L.length} ${L.length===1?`row`:`rows`}, ${m.issues.length?`${m.issues.length} ${m.issues.length===1?`issue`:`issues`}`:`ready`}`})]}),(0,f.jsxs)(`button`,{className:`ghost-button`,disabled:e||D,onClick:()=>i(null),type:`button`,children:[(0,f.jsx)(u,{"aria-hidden":`true`,size:15}),` Remove`]})]}),R.length?(0,f.jsx)(`ul`,{"aria-label":`CSV import issues`,className:`csv-import-global-errors`,role:`alert`,children:R.map((e,t)=>(0,f.jsxs)(`li`,{children:[e.path===`csv.header`?`Header: `:``,e.message]},`${e.path}-${e.code}-${t}`))}):null,I.length?(0,f.jsx)(`div`,{className:`csv-import-grid-wrap`,children:(0,f.jsxs)(`table`,{className:`csv-import-grid`,children:[(0,f.jsx)(`thead`,{children:(0,f.jsxs)(`tr`,{children:[(0,f.jsx)(`th`,{scope:`col`,children:`Line`}),I.map((e,t)=>(0,f.jsx)(`th`,{scope:`col`,children:y(e||`Column ${t+1}`)},`${e}-${t}`))]})}),(0,f.jsx)(`tbody`,{children:L.map((t,n)=>{let r=n+2,i=m.rowIssues[r]??[];return(0,f.jsxs)(d.Fragment,{children:[(0,f.jsxs)(`tr`,{className:i.length?`has-errors`:void 0,children:[(0,f.jsx)(`th`,{scope:`row`,children:r}),I.map((a,o)=>{let s=i.filter(e=>v(e.path)===a);return(0,f.jsx)(`td`,{children:(0,f.jsx)(`input`,{"aria-describedby":s.length?`csv-line-${r}-issues`:void 0,"aria-invalid":s.length>0||void 0,"aria-label":`${y(a||`Column ${o+1}`)}, CSV line ${r}`,disabled:e||D,onBlur:()=>void M(w.current,m.filename),onChange:e=>P(n+1,o,e.target.value),value:t[o]??``})},`${r}-${a}-${o}`)})]}),i.length?(0,f.jsx)(`tr`,{className:`csv-import-error-row`,children:(0,f.jsx)(`td`,{colSpan:I.length+1,children:(0,f.jsx)(`ul`,{id:`csv-line-${r}-issues`,children:i.map((e,t)=>(0,f.jsxs)(`li`,{children:[(0,f.jsxs)(`strong`,{children:[`Line `,r,v(e.path)?`, ${y(v(e.path))}`:``,`:`]}),` `,e.message]},`${e.path}-${e.code}-${t}`))})})}):null]},r)})})]})}):null]}):null]})}export{u as n,x as t}; \ No newline at end of file diff --git a/msp-onboarding/static/assets/CsvImportPanel.preview-e06k2viG.js b/msp-onboarding/static/assets/CsvImportPanel.preview-e06k2viG.js new file mode 100644 index 0000000..0f34e1f --- /dev/null +++ b/msp-onboarding/static/assets/CsvImportPanel.preview-e06k2viG.js @@ -0,0 +1,5 @@ +import{t as e}from"./CsvImportPanel-B8vKd-O9.js";import{b as t,n,v as r}from"./index-CeYmaMoQ.js";var i=t(r(),1),a=n(),o=`name,country,description,email,phone_number,street_address,street_address_complement,city,state_or_region,postal_code,application,region +Northstar Retail,US,Flagship stores,,,,,Austin,TX,,HPE Aruba Networking Central,us-west +`,s=`serial_number,mac_address,subscription_key,tenant +CNXA001,,SUB-AP-FOUNDATION,Northstar Retail +`;function c({kind:t}){let[n,r]=(0,i.useState)(null);return(0,a.jsx)(e,{kind:t,onChange:r,template:t===`tenant`?o:s,tenantNames:t===`device`?[`Northstar Retail`]:void 0,value:n})}function l(){return(0,a.jsxs)(`main`,{className:`csv-import-preview`,children:[(0,a.jsx)(`h1`,{children:`CSV import panel`}),(0,a.jsx)(`p`,{children:`Development preview for tenant and device journeys.`}),(0,a.jsxs)(`div`,{className:`csv-import-preview-grid`,children:[(0,a.jsx)(c,{kind:`tenant`}),(0,a.jsx)(c,{kind:`device`})]})]})}export{l as CsvImportPanelPreview}; \ No newline at end of file diff --git a/msp-onboarding/static/assets/RunSurface-DZb15gFL.js b/msp-onboarding/static/assets/RunSurface-DZb15gFL.js new file mode 100644 index 0000000..c4e7539 --- /dev/null +++ b/msp-onboarding/static/assets/RunSurface-DZb15gFL.js @@ -0,0 +1 @@ +import{n as e}from"./CsvImportPanel-B8vKd-O9.js";import{a as t,b as n,g as r,h as i,m as a,n as o,r as s,v as c,y as l}from"./index-CeYmaMoQ.js";var u=r(`check`,[[`path`,{d:`M20 6 9 17l-5-5`,key:`1gmf2c`}]]),d=r(`circle`,[[`circle`,{cx:`12`,cy:`12`,r:`10`,key:`1mglay`}]]),f=r(`loader-circle`,[[`path`,{d:`M21 12a9 9 0 1 1-6.219-8.56`,key:`13zald`}]]),p=l((e=>{var t=c();function n(e,t){return e===t&&(e!==0||1/e==1/t)||e!==e&&t!==t}var r=typeof Object.is==`function`?Object.is:n,i=t.useState,a=t.useEffect,o=t.useLayoutEffect,s=t.useDebugValue;function l(e,t){var n=t(),r=i({inst:{value:n,getSnapshot:t}}),c=r[0].inst,l=r[1];return o(function(){c.value=n,c.getSnapshot=t,u(c)&&l({inst:c})},[e,n,t]),a(function(){return u(c)&&l({inst:c}),e(function(){u(c)&&l({inst:c})})},[e]),s(n),n}function u(e){var t=e.getSnapshot;e=e.value;try{var n=t();return!r(e,n)}catch{return!0}}function d(e,t){return t()}var f=typeof window>`u`||window.document===void 0||window.document.createElement===void 0?d:l;e.useSyncExternalStore=t.useSyncExternalStore===void 0?f:t.useSyncExternalStore})),m=l(((e,t)=>{t.exports=p()})),h=n(c(),1),g=m(),_=Object.prototype.hasOwnProperty;function v(e,t){var n,r;if(e===t)return!0;if(e&&t&&(n=e.constructor)===t.constructor){if(n===Date)return e.getTime()===t.getTime();if(n===RegExp)return e.toString()===t.toString();if(n===Array){if((r=e.length)===t.length)for(;r--&&v(e[r],t[r]););return r===-1}if(!n||typeof e==`object`){for(n in r=0,e)if(_.call(e,n)&&++r&&!_.call(t,n)||!(n in t)||!v(e[n],t[n]))return!1;return Object.keys(t).length===r}}return e!==e&&t!==t}var y=new WeakMap,b=()=>{},x=Object,S=e=>e===void 0,C=e=>typeof e==`function`,w=(e,t)=>({...e,...t}),T=e=>C(e.then),E={},D={},O=`undefined`,k=typeof window!=O,A=typeof document!=O,j=k&&`Deno`in window,M=()=>k&&typeof window.requestAnimationFrame!=O,ee=(e,t)=>{let n=y.get(e);return[()=>!S(t)&&e.get(t)||E,r=>{if(!S(t)){let i=e.get(t);t in D||(D[t]=i),n[5](t,w(i,r),i||E)}},n[6],()=>!S(t)&&t in D?D[t]:!S(t)&&e.get(t)||E]},N=!0,P=()=>N,[F,I]=k&&window.addEventListener?[window.addEventListener.bind(window),window.removeEventListener.bind(window)]:[b,b],te=()=>{let e=A&&document.visibilityState;return S(e)||e!==`hidden`},L=e=>(A&&document.addEventListener(`visibilitychange`,e),F(`focus`,e),()=>{A&&document.removeEventListener(`visibilitychange`,e),I(`focus`,e)}),R=e=>{let t=()=>{N=!0,e()},n=()=>{N=!1};return F(`online`,t),F(`offline`,n),()=>{I(`online`,t),I(`offline`,n)}},ne={isOnline:P,isVisible:te},re={initFocus:L,initReconnect:R},ie=!h.useId,z=!k||j,ae=e=>M()?window.requestAnimationFrame(e):setTimeout(e,1),oe=z?h.useEffect:h.useLayoutEffect,B=typeof navigator<`u`&&navigator.connection,V=!z&&B&&([`slow-2g`,`2g`].includes(B.effectiveType)||B.saveData),H=new WeakMap,U=e=>x.prototype.toString.call(e),W=(e,t)=>e===`[object ${t}]`,G=0,K=e=>{let t=typeof e,n=U(e),r=W(n,`Date`),i=W(n,`RegExp`),a=W(n,`Object`),o,s;if(x(e)===e&&!r&&!i){if(o=H.get(e),o)return o;if(o=++G+`~`,H.set(e,o),Array.isArray(e)){for(o=`@`,s=0;s{if(C(e))try{e=e()}catch{e=``}let t=e;return e=typeof e==`string`?e:(Array.isArray(e)?e.length:e)?K(e):``,[e,t]},q=0,ce=()=>++q;async function le(...e){let[t,n,r,i]=e,a=w({populateCache:!0,throwOnError:!0},typeof i==`boolean`?{revalidate:i}:i||{}),o=a.populateCache,s=a.rollbackOnError,c=a.optimisticData,l=e=>typeof s==`function`?s(e):s!==!1,u=a.throwOnError;if(C(n)){let e=n,r=[],i=t.keys();for(let n of i)!/^\$(inf|sub)\$/.test(n)&&e(t.get(n)._k)&&r.push(n);return Promise.all(r.map(d))}return d(n);async function d(n){let[i]=se(n);if(!i)return;let[s,d]=ee(t,i),[f,p,m,h]=y.get(t),g=()=>{let e=f[i];return(C(a.revalidate)?a.revalidate(s().data,n):a.revalidate!==!1)&&(delete m[i],delete h[i],e&&e[0])?e[0](2).then(()=>s().data):s().data};if(e.length<3)return g();let _=r,v,b=!1,x=ce();p[i]=[x,0];let w=!S(c),E=s(),D=E.data,O=E._c,k=S(O)?D:O;if(w&&(c=C(c)?c(k,D):c,d({data:c,_c:k})),C(_))try{_=_(k)}catch(e){v=e,b=!0}if(_&&T(_))if(_=await _.catch(e=>{v=e,b=!0}),x!==p[i][0]){if(b)throw v;return _}else b&&w&&l(v)&&(o=!0,d({data:k,_c:void 0}));if(o&&(b||(C(o)?d({data:o(_,k),error:void 0,_c:void 0}):d({data:_,error:void 0,_c:void 0}))),p[i][1]=ce(),Promise.resolve(g()).then(()=>{d({_c:void 0})}),b){if(u)throw v;return}return _}}var J=(e,t)=>{for(let n in e)e[n][0]&&e[n][0](t)},Y=(e,t)=>{if(!y.has(e)){let n=w(re,t),r=Object.create(null),i=le.bind(void 0,e),a=b,o=Object.create(null),s=(e,t)=>{let n=o[e]||[];return o[e]=n,n.push(t),()=>{let e=n.indexOf(t);e>=0&&(n[e]=n[n.length-1],n.pop())}},c=(t,n,r)=>{e.set(t,n);let i=o[t];if(i)for(let e of i)e(n,r)},l=()=>{if(!y.has(e)&&(y.set(e,[r,Object.create(null),Object.create(null),Object.create(null),i,c,s]),!z)){let t=n.initFocus(setTimeout.bind(void 0,J.bind(void 0,r,0))),i=n.initReconnect(setTimeout.bind(void 0,J.bind(void 0,r,1)));a=()=>{t&&t(),i&&i(),y.delete(e)}}};return l(),[e,i,l,a]}return[e,y.get(e)[4]]},ue=(e,t,n,r,i)=>{let a=n.errorRetryCount,o=i.retryCount,s=~~((Math.random()+.5)*(1<<(o<8?o:8)))*n.errorRetryInterval;!S(a)&&o>a||setTimeout(r,s,i)},de=v,[fe,pe]=Y(new Map),me=w({onLoadingSlow:b,onSuccess:b,onError:b,onErrorRetry:ue,onDiscarded:b,revalidateOnFocus:!0,revalidateOnReconnect:!0,revalidateIfStale:!0,shouldRetryOnError:!0,errorRetryInterval:V?1e4:5e3,focusThrottleInterval:5*1e3,dedupingInterval:2*1e3,loadingTimeout:V?5e3:3e3,compare:de,isPaused:()=>!1,cache:fe,mutate:pe,fallback:{}},ne),X=(e,t)=>{let n=w(e,t);if(t){let{use:r,fallback:i}=e,{use:a,fallback:o}=t;r&&a&&(n.use=r.concat(a)),i&&o&&(n.fallback=w(i,o))}return n},he=(0,h.createContext)({}),ge=e=>{let{value:t}=e,n=(0,h.useContext)(he),r=C(t),i=(0,h.useMemo)(()=>r?t(n):t,[r,n,t]),a=(0,h.useMemo)(()=>r?i:X(n,i),[r,n,i]),o=i&&i.provider,s=(0,h.useRef)(void 0);o&&!s.current&&(s.current=Y(o(a.cache||fe),i));let c=s.current;return c&&(a.cache=c[0],a.mutate=c[1]),oe(()=>{if(c)return c[2]&&c[2](),c[3]},[]),(0,h.createElement)(he.Provider,w(e,{value:a}))},_e=k&&window.__SWR_DEVTOOLS_USE__,ve=_e?window.__SWR_DEVTOOLS_USE__:[],ye=()=>{_e&&(window.__SWR_DEVTOOLS_REACT__=h.default)},be=e=>C(e[1])?[e[0],e[1],e[2]||{}]:[e[0],null,(e[1]===null?e[2]:e[1])||{}],xe=()=>{let e=(0,h.useContext)(he);return(0,h.useMemo)(()=>w(me,e),[e])},Se=ve.concat(e=>(t,n,r)=>e(t,n&&((...e)=>{let[r]=se(t),[,,,i]=y.get(fe);if(r.startsWith(`$inf$`))return n(...e);let a=i[r];return S(a)?n(...e):(delete i[r],a)}),r)),Ce=e=>function(...t){let n=xe(),[r,i,a]=be(t),o=X(n,a),s=e,{use:c}=o,l=(c||[]).concat(Se);for(let e=l.length;e--;)s=l[e](s);return s(r,i||o.fetcher||null,o)},we=(e,t,n)=>{let r=t[e]||(t[e]=[]);return r.push(n),()=>{let e=r.indexOf(n);e>=0&&(r[e]=r[r.length-1],r.pop())}};ye();var Te=h.use||(e=>{switch(e.status){case`pending`:throw e;case`fulfilled`:return e.value;case`rejected`:throw e.reason;default:throw e.status=`pending`,e.then(t=>{e.status=`fulfilled`,e.value=t},t=>{e.status=`rejected`,e.reason=t}),e}}),Ee={dedupe:!0},De=Promise.resolve(void 0);De.status=`fulfilled`,De.value=void 0;var Oe=()=>b,ke=(e,t,n)=>{let{cache:r,compare:i,suspense:a,fallbackData:o,revalidateOnMount:s,revalidateIfStale:c,refreshInterval:l,refreshWhenHidden:u,refreshWhenOffline:d,keepPreviousData:f,strictServerPrefetchWarning:p}=n,[m,_,v,b]=y.get(r),[x,E]=se(e),D=(0,h.useRef)(!1),O=(0,h.useRef)(!1),k=(0,h.useRef)(x),A=(0,h.useRef)(t),j=(0,h.useRef)(n),M=()=>j.current,N=()=>M().isVisible()&&M().isOnline(),[P,F,I,te]=ee(r,x),L=(0,h.useRef)({}).current,R=S(o)?S(n.fallback)?void 0:n.fallback[x]:o,ne=(e,t)=>{for(let n in L){let r=n;if(r===`data`){if(!i(e[r],t[r])&&(!S(e[r])||!i(q,t[r])))return!1}else if(t[r]!==e[r])return!1}return!0},re=!D.current,B=(0,h.useMemo)(()=>{let e=P(),n=te(),r=e=>{let n=w(e);return delete n._k,!(!x||!t||M().isPaused())&&(re&&!S(s)?s:S(S(R)?n.data:R)||c)?{isValidating:!0,isLoading:!0,...n}:n},i=r(e),a=e===n?i:r(n),o=i;return[()=>{let e=r(P());return ne(e,o)?(o.data=e.data,o.isLoading=e.isLoading,o.isValidating=e.isValidating,o.error=e.error,o):(o=e,e)},()=>a]},[r,x]),V=(0,g.useSyncExternalStore)((0,h.useCallback)(e=>I(x,(t,n)=>{ne(n,t)||e()}),[r,x]),B[0],B[1]),H=m[x]&&m[x].length>0,U=V.data,W=S(U)?R&&T(R)?Te(R):R:U,G=V.error,K=(0,h.useRef)(W),q=f?S(U)?S(K.current)?W:K.current:U:W,J=x&&S(W),Y=(0,h.useRef)(null);!z&&(0,g.useSyncExternalStore)(Oe,()=>(Y.current=!1,Y),()=>(Y.current=!0,Y));let ue=Y.current;p&&ue&&!a&&J&&console.warn(`Missing pre-initiated data for serialized key "${x}" during server-side rendering. Data fetching should be initiated on the server and provided to SWR via fallback data. You can set "strictServerPrefetchWarning: false" to disable this warning.`);let de=!x||!t||M().isPaused()||H&&!S(G)?!1:re&&!S(s)?s:a?!S(W)&&c:S(W)||c,fe=re&&de,pe=S(V.isValidating)?fe:V.isValidating,me=S(V.isLoading)?fe:V.isLoading,X=(0,h.useCallback)(async e=>{let t=A.current;if(!x||!t||O.current||M().isPaused())return!1;let r,a,o=!0,s=e||{},c=!v[x]||!s.dedupe,l=()=>ie?!O.current&&x===k.current&&D.current:x===k.current,u={isValidating:!1,isLoading:!1},d=()=>{F(u)},f=()=>{let e=v[x];e&&e[1]===a&&delete v[x]},p={isValidating:!0};S(P().data)&&(p.isLoading=!0);try{if(c&&(F(p),n.loadingTimeout&&S(P().data)&&setTimeout(()=>{o&&l()&&M().onLoadingSlow(x,n)},n.loadingTimeout),v[x]=[t(E),ce()]),[r,a]=v[x],r=await r,c&&setTimeout(f,n.dedupingInterval),!v[x]||v[x][1]!==a)return c&&l()&&M().onDiscarded(x),!1;u.error=void 0;let e=_[x];if(!S(e)&&(a<=e[0]||a<=e[1]||e[1]===0))return d(),c&&l()&&M().onDiscarded(x),!1;let s=P().data;u.data=i(s,r)?s:r,c&&l()&&M().onSuccess(r,x,n)}catch(e){f();let t=M(),{shouldRetryOnError:n}=t;t.isPaused()||(u.error=e,c&&l()&&(t.onError(e,x,t),(n===!0||C(n)&&n(e))&&(!M().revalidateOnFocus||!M().revalidateOnReconnect||N())&&t.onErrorRetry(e,x,t,e=>{let t=m[x];t&&t[0]&&t[0](3,e)},{retryCount:(s.retryCount||0)+1,dedupe:!0})))}return o=!1,d(),!0},[x,r]),he=(0,h.useCallback)((...e)=>le(r,k.current,...e),[]);if(oe(()=>{A.current=t,j.current=n,S(U)||(K.current=U)}),oe(()=>{if(!x)return;let e=X.bind(void 0,Ee),t=0;M().revalidateOnFocus&&(t=Date.now()+M().focusThrottleInterval);let n=we(x,m,(n,r={})=>{if(n==0){let n=Date.now();M().revalidateOnFocus&&n>t&&N()&&(t=n+M().focusThrottleInterval,e())}else if(n==1)M().revalidateOnReconnect&&N()&&e();else if(n==2)return X();else if(n==3)return X(r)});return O.current=!1,k.current=x,D.current=!0,F({_k:E}),de&&(v[x]||(S(W)||z?e():ae(e))),()=>{O.current=!0,n()}},[x]),oe(()=>{let e;function t(){let t=C(l)?l(P().data):l;t&&e!==-1&&(e=setTimeout(n,t))}function n(){!P().error&&(u||M().isVisible())&&(d||M().isOnline())?X(Ee).then(t):t()}return t(),()=>{e&&=(clearTimeout(e),-1)}},[l,u,d,x]),(0,h.useDebugValue)(q),a){if(!ie&&z&&J)throw Error(`Fallback data is required when using Suspense in SSR.`);J&&(A.current=t,j.current=n,O.current=!1);let e=b[x];if(Te(!S(e)&&J?he(e):De),!S(G)&&J)throw G;let r=J?X(Ee):De;!S(q)&&J&&(r.status=`fulfilled`,r.value=!0),Te(r)}return{mutate:he,get data(){return L.data=!0,q},get error(){return L.error=!0,G},get isValidating(){return L.isValidating=!0,pe},get isLoading(){return L.isLoading=!0,me}}};x.defineProperty(ge,`defaultValue`,{value:me});var Ae=Ce(ke);function je(e,t){return t?.trim()||e?.trim()||`Region unavailable`}function Me(e){let t=e?.trim()||``;return t.replace(/^HPE Aruba Networking\s+/i,``)||t||`Service name unavailable`}function Ne(e,t,n){let r=je(t,n),i=t?.trim(),a=i&&r!==i?`${r} (${i})`:r;return`${Me(e)} · ${a}`}function Pe(e){return e.trim().toLocaleLowerCase(`en`).replace(/[\s_-]+/g,`-`).replace(/^-|-$/g,``)}function Fe(e){return JSON.stringify([e.service_manager_id,e.region])}var Z=o(),Q=new Set([`succeeded`,`already_satisfied`]),Ie=[`ready`,`queued`,`running`],Le=e=>[...new Set(e.errors.map(e=>e.message))];function Re(e,t,n=`${t}s`){return e===1?t:n}function ze(e){let t=new Map;return e.forEach(e=>{e.subscription_key&&t.set(e.subscription_key,(t.get(e.subscription_key)??0)+1)}),[...t].sort((e,t)=>t[1]-e[1])[0]?.[0]??``}function Be(e,t,n){let r=new Map;return e.filter(e=>e.tenant===t&&e.subscription_key!==n).forEach(e=>r.set(e.subscription_key,(r.get(e.subscription_key)??0)+1)),[...r]}function Ve({backLabel:e=`Devices`,confirmDisabled:t=!1,confirming:n,demo:r,devices:i,onBack:a,onConfirm:o,plan:s,subscriptions:c}){let l=s.tenant_groups.filter(e=>e.status===`pending`),u=s.tenant_groups.filter(e=>e.status===`blocked`),d=l.reduce((e,t)=>e+t.device_count,0),f=ze(i),p=s.mode===`new`,m=i.reduce((e,t)=>(t.subscription_key&&e.set(t.subscription_key,(e.get(t.subscription_key)??0)+1),e),new Map),h=u.map(e=>e.tenant_name).join(`, `);return(0,Z.jsxs)(`section`,{className:`review-plan`,"aria-labelledby":`review-title`,children:[(0,Z.jsxs)(`header`,{className:`review-heading`,children:[(0,Z.jsx)(`h1`,{id:`review-title`,children:`Review this preflight plan`}),(0,Z.jsxs)(`p`,{children:[l.length?`${l.length} ${Re(l.length,`tenant`)} will ${p?`be created and provisioned with ${[...new Set(l.map(e=>e.service_name))].join(`, `)||`Central`}`:`receive ${d} device ${Re(d,`assignment`)}`}.`:`Nothing will run: every tenant is blocked.`,u.length?` ${u.length} blocked (${h}).`:``,r===void 0?null:(0,Z.jsxs)(`strong`,{className:r?`review-mode-note is-demo`:`review-mode-note is-live`,children:[` `,r?`Demo mode — no live changes.`:`Live run — writes to real tenants.`]})]})]}),(0,Z.jsx)(`div`,{className:`review-plan-grid${p?` is-creation`:``}`,children:p?null:(0,Z.jsxs)(`section`,{className:`review-capacity`,"aria-labelledby":`review-capacity-title`,children:[(0,Z.jsx)(`h2`,{id:`review-capacity-title`,children:`Seat capacity for keys in use`}),(0,Z.jsx)(`p`,{children:`Each subscription key is counted separately.`}),(0,Z.jsx)(`ul`,{children:[...m].map(([e,t])=>{let n=c.find(t=>t.key===e),r=Number(n?.available_quantity);return(0,Z.jsxs)(`li`,{children:[(0,Z.jsx)(`span`,{children:(0,Z.jsxs)(`strong`,{children:[e,` · `,t,` requested`]})}),(0,Z.jsx)(`span`,{children:Number.isFinite(r)?`${r} available`:`Capacity unavailable`})]},e)})})]})}),(0,Z.jsx)(`section`,{className:`review-tenant-list`,"aria-label":`Tenant plan`,children:s.tenant_groups.map(e=>{let t=Be(i,e.tenant_name,f);return(0,Z.jsx)(`div`,{className:`review-tenant is-${e.status}`,children:(0,Z.jsxs)(`div`,{className:`review-tenant-summary`,children:[(0,Z.jsxs)(`span`,{children:[(0,Z.jsx)(`strong`,{children:e.tenant_name}),(0,Z.jsx)(`small`,{children:Ne(e.service_name,e.service_region,e.service_region_display_name)}),e.status===`blocked`?(0,Z.jsx)(`small`,{children:Le(e).join(` `)||`Will not run`}):null,t.map(([e,t])=>(0,Z.jsxs)(`small`,{className:`review-subscription-exception`,children:[e,` on `,t,` `,Re(t,`device`),` (differs from the common key)`]},e))]}),p?null:(0,Z.jsxs)(`span`,{children:[e.device_count,` `,Re(e.device_count,`device`)]}),(0,Z.jsx)(`span`,{className:`review-tenant-state is-${e.status}`,children:e.status===`blocked`?`Will not run`:`Ready`})]})},e.tenant_name)})}),(0,Z.jsxs)(`div`,{className:`review-actions`,children:[(0,Z.jsxs)(`button`,{className:`secondary-button`,onClick:a,type:`button`,children:[`Back to `,e]}),(0,Z.jsx)(`button`,{className:`commit-button`,disabled:l.length===0||t||n,onClick:()=>void o(s),type:`button`,children:n?`Starting run…`:`Confirm and start run`})]})]})}function He(e,t){return e.tenant_name===t.tenant_name||!!(t.tenant_workspace_id&&e.tenant_workspace_id===t.tenant_workspace_id)}var Ue=`Queued tenants wait for one of the 5 provisioning slots to free up.`,We=new Set([`submitting`,`submitted`,`running`]);function $({ariaLabel:t,children:n,description:r,tone:i=`idle`}){let a=i===`success`?u:i===`danger`?e:i===`active`?f:d;return(0,Z.jsxs)(`span`,{"aria-label":t,className:`run-step-chip is-${i}`,children:[(0,Z.jsx)(a,{"aria-hidden":`true`}),n,r?(0,Z.jsxs)(`span`,{className:`visually-hidden`,children:[`. `,r]}):null]})}function Ge({caption:e,children:t,headers:n,strip:r}){return(0,Z.jsxs)(`div`,{className:`run-progress-table-shell`,children:[r?(0,Z.jsx)(`div`,{className:`run-table-caption`,children:r}):null,(0,Z.jsx)(`div`,{className:`review-run-table-wrap`,children:(0,Z.jsxs)(`table`,{className:`review-run-table run-progress-table`,children:[(0,Z.jsx)(`caption`,{className:`visually-hidden`,children:e}),(0,Z.jsx)(`thead`,{children:(0,Z.jsx)(`tr`,{children:n.map(e=>(0,Z.jsx)(`th`,{scope:`col`,children:e},e))})}),t]})})]})}function Ke({complete:e,label:t,total:n}){let r=2*Math.PI*26,i=n?Math.min(1,e/n):0;return(0,Z.jsxs)(`svg`,{"aria-label":`${e} of ${n} ${t}`,className:`run-progress-ring`,role:`img`,viewBox:`0 0 64 64`,children:[(0,Z.jsx)(`circle`,{className:`run-progress-ring-track`,cx:`32`,cy:`32`,r:`26`}),(0,Z.jsx)(`circle`,{className:`run-progress-ring-value`,cx:`32`,cy:`32`,r:`26`,strokeDasharray:r,strokeDashoffset:r*(1-i)}),(0,Z.jsxs)(`text`,{textAnchor:`middle`,x:`32`,y:`37`,children:[e,`/`,n]})]})}var qe={batch_failed:`The write for this device did not finish. Start a new run with these devices to retry; completed writes are re-checked, not repeated.`,partial_write:`Some devices in this tenant were not fully written. A new run with the same devices retries only the missing writes.`,verification_failed:`Written, but verification could not confirm the final state. Run these devices again to check.`};function Je(e){return e?qe[e.code]??`${e.code}: ${e.message}`:`Outcome needs review`}function Ye(e,t){if(!e)return 0;let n=Date.parse(e);return Number.isFinite(n)?Math.max(0,Math.ceil((n-t)/1e3)):0}function Xe(e,t){let n=new Date(e);if(Number.isNaN(n.getTime()))return e;let r=t?new Date(t):null;return r&&!Number.isNaN(r.getTime())&&n.toDateString()!==r.toDateString()?n.toLocaleString([],{day:`numeric`,hour:`2-digit`,minute:`2-digit`,month:`short`}):n.toLocaleTimeString([],{hour:`2-digit`,minute:`2-digit`})}function Ze(e){let[t,n]=(0,h.useState)(()=>Date.now());return(0,h.useEffect)(()=>{if(!e)return;let t=window.setInterval(()=>n(Date.now()),1e3);return()=>window.clearInterval(t)},[e]),t}function Qe({action:e,complete:t,completeLabel:n,detail:r,inFlight:i,job:a,notes:o,pace:s,statSections:c,title:l,total:u}){let d=a.steps?.[0]?.created_at??a.created_at;return(0,Z.jsxs)(`aside`,{"aria-label":`Run summary`,className:`run-summary-rail`,children:[(0,Z.jsxs)(`div`,{className:`run-summary-head`,children:[(0,Z.jsx)(`div`,{className:`run-summary-status-line`,children:(0,Z.jsx)(`h1`,{className:`run-status-badge is-${a.status}`,id:`review-run-title`,children:l})}),(0,Z.jsxs)(`div`,{className:`run-summary-ring-row`,children:[(0,Z.jsx)(Ke,{complete:t,label:n,total:u}),(0,Z.jsxs)(`div`,{children:[(0,Z.jsx)(`strong`,{children:n[0].toUpperCase()+n.slice(1)}),o]})]})]}),c.map(e=>(0,Z.jsxs)(`section`,{"aria-label":e.label,className:`run-summary-stat-section`,children:[(0,Z.jsx)(`h2`,{children:e.label}),(0,Z.jsx)(`dl`,{className:`run-summary-stats`,children:e.stats.map(e=>(0,Z.jsxs)(`div`,{className:e.danger?`is-danger`:void 0,children:[(0,Z.jsx)(`dt`,{children:e.label}),(0,Z.jsx)(`dd`,{children:e.value})]},e.label))})]},e.label)),(0,Z.jsxs)(`details`,{className:`run-summary-details`,children:[(0,Z.jsx)(`summary`,{children:`Run details`}),(0,Z.jsxs)(`div`,{children:[(0,Z.jsxs)(`span`,{children:[`Run `,(0,Z.jsx)(`code`,{children:a.id})]}),(0,Z.jsxs)(`span`,{children:[`Started `,(0,Z.jsx)(`time`,{dateTime:d,children:Xe(d,a.updated_at)})]}),(0,Z.jsxs)(`span`,{children:[`Pace: `,s]}),(0,Z.jsxs)(`span`,{children:[`In flight: `,i]}),r]})]}),(0,Z.jsx)(`div`,{className:`run-summary-action`,children:e})]})}function $e(e){return e===`completed_with_errors`?`Run completed with errors`:e===`succeeded`?`Run complete`:e===`stopped`?`Run stopped`:e===`failed`?`Run failed`:e===`queued`?`Run queued`:`Assignment run in progress`}function et(e){return[`succeeded`,`completed_with_errors`,`failed`,`stopped`].includes(e)}function tt({jobId:e}){return(0,Z.jsx)(`a`,{className:`secondary-button`,download:!0,href:`/api/jobs/${encodeURIComponent(e)}/manifest`,children:`Download manifest`})}function nt({jobId:e}){return(0,Z.jsx)(`a`,{className:`secondary-button`,download:!0,href:a(e),children:`Download CSV report`})}function rt({error:e,subject:t=`devices`}){return e.code===`rate_limited`?(0,Z.jsxs)(`span`,{className:`review-rate-limit-error`,children:[(0,Z.jsx)(`strong`,{children:`Rate limited — the API asked this run to slow down.`}),(0,Z.jsxs)(`small`,{children:[`Rate limiting was still in force after bounded retries. Start a new run with these `,t,` when ready; `,t===`tenants`?`the tenant is kept and only the service is provisioned`:`completed writes will be re-checked`,`.`]})]}):(0,Z.jsx)(`small`,{className:`review-run-error-copy`,children:Je(e)})}function it(e){return e===`completed_with_errors`?`Tenant creation completed with errors`:e===`succeeded`?`Tenants created and provisioned`:e===`stopped`?`Tenant creation stopped`:e===`failed`?`Tenant creation failed`:e===`queued`?`Tenant creation queued`:`Creating tenants`}function at(e,t,n){let r=n===`tenant`?`ensure_tenant`:`ensure_service`;return e.find(e=>e.tenant_name===t&&e.logical_key===n&&e.operation===r)}function ot({waitUntil:e}){return(0,Z.jsxs)(Z.Fragment,{children:[`Retry in `,Ye(e,Ze(!!e)),`s`]})}function st({group:e,kind:t,step:n}){let r=n?.error??e.last_error;return[`blocked`,`skipped`].includes(e.status)?(0,Z.jsx)($,{tone:`idle`,children:`Skipped`}):n?Q.has(n.status)?(0,Z.jsx)($,{tone:`success`,children:t===`tenant`?`Created`:`Provisioned`}):[`failed`,`unknown`].includes(n.status)?(0,Z.jsx)($,{ariaLabel:`Failed: ${r?`${r.code}, ${r.message}`:`outcome needs review`}`,tone:`danger`,children:`Failed`}):n.status===`waiting_rate_limit`?e.status===`failed`?(0,Z.jsx)($,{ariaLabel:`Failed: rate limited after bounded retries`,tone:`danger`,children:`Rate limited`}):(0,Z.jsx)($,{tone:`wait`,children:(0,Z.jsx)(ot,{waitUntil:n.wait_until})}):t===`tenant`&&[`running`,`submitting`,`submitted`].includes(n.status)?(0,Z.jsx)($,{tone:`active`,children:`Creating`}):t===`service`&&We.has(n.status)?(0,Z.jsx)($,{tone:`active`,children:`Provisioning`}):t===`service`&&n.status===`pending`?(0,Z.jsx)($,{ariaLabel:`Queued: ${Ue}`,description:Ue,children:`Queued`}):(0,Z.jsx)($,{children:`Pending`}):e.status===`failed`?(0,Z.jsx)($,{ariaLabel:`Failed: ${Je(r).replace(`:`,`,`)}`,tone:`danger`,children:`Failed`}):(0,Z.jsx)($,{children:`Pending`})}function ct(e,t,n,r,i=!1){return e===`already_satisfied`?(0,Z.jsx)($,{tone:`success`,children:`Already satisfied`}):e===`succeeded`?(0,Z.jsx)($,{tone:`success`,children:`Complete`}):[`failed`,`unknown`].includes(e)?(0,Z.jsx)($,{ariaLabel:`Failed: ${t?`${t.code}, ${t.message}`:`outcome needs review`}`,tone:`danger`,children:`Failed`}):e===`running`||i&&!Q.has(e)?(0,Z.jsx)($,{ariaLabel:`Writing`,tone:`active`,children:`Writing`}):n||r?(0,Z.jsx)($,{tone:`idle`,children:`Skipped`}):(0,Z.jsx)($,{ariaLabel:`Queued: Waiting for the active assignment batch to finish.`,description:`Waiting for the active assignment batch to finish.`,children:`Queued`})}function lt(e,t,n,r){if(!r||e.error||![`failed`,`skipped`].includes(t.status))return null;let i=[e.device_status,e.subscription_status].some(e=>!Q.has(e)&&![`failed`,`unknown`].includes(e)),a=!n||!Q.has(n.status)&&![`failed`,`unknown`,`running`].includes(n.status);return!i&&!a?null:t.status===`skipped`?`Not attempted, this run stopped before this step.`:`Not attempted, this tenant group stopped before this step.`}function ut({jobId:e,onDone:n,onRerun:r}){let[a,o]=(0,h.useState)(!1),[c,l]=(0,h.useState)(!1),[u,d]=(0,h.useState)(``),[f,p]=(0,h.useState)(!1),[m,g]=(0,h.useState)(``),{data:_,error:v,mutate:y}=Ae([`review-job`,e],()=>t(e),{dedupingInterval:0,refreshInterval:e=>!e||Ie.includes(e.status)?1e3:0,refreshWhenHidden:!0}),b=(0,h.useMemo)(()=>{if(!_||_.mode!==`existing`)return[];let e=new Set(_.tenant_groups.filter(e=>e.status!==`blocked`).map(e=>e.tenant_name));return _.devices?.length?_.devices:(_.plan?.devices??[]).filter(t=>e.has(t.tenant_name)).map(e=>({tenant_name:e.tenant_name,tenant_workspace_id:e.tenant_workspace_id,glp_id:e.glp_id,serial_number:e.serial_number,subscription_key:e.subscription_key,device_status:`pending`,subscription_status:`pending`,error:null,updated_at:_.updated_at??_.created_at}))},[_]),x=(0,h.useMemo)(()=>{let e=new Map;return b.forEach(t=>{let n=e.get(t.tenant_name);n?n.push(t):e.set(t.tenant_name,[t])}),e},[b]),S=v instanceof s&&v.status===404;(0,h.useEffect)(()=>{S&&n()},[S,n]);let C=async()=>{if(!a){o(!0),d(``);try{await i(e),l(!0),await y()}catch{d(`Unable to stop this run. Check the connection and try again.`)}finally{o(!1)}}},w=async()=>{if(!(!r||f)){p(!0),g(``);try{await r(e)}catch{g(`Unable to load this run’s manifest. Try again or download it below.`),p(!1)}}};if(S)return(0,Z.jsx)(`p`,{"aria-busy":`true`,className:`review-run-loading`,children:`Run no longer exists — returning to hub…`});if(v)return(0,Z.jsxs)(`section`,{className:`review-run-error`,role:`alert`,children:[(0,Z.jsx)(`h1`,{children:`Unable to load run progress`}),(0,Z.jsx)(`p`,{children:`Check the connection, then retry.`}),(0,Z.jsx)(`button`,{className:`secondary-button`,onClick:()=>void y(),type:`button`,children:`Retry`})]});if(!_)return(0,Z.jsx)(`p`,{"aria-busy":`true`,className:`review-run-loading`,children:`Checking run progress…`});let T=et(_.status),E=_.tenant_groups.filter(e=>e.status===`succeeded`).length,D=_.tenant_groups.filter(e=>e.status===`failed`).length,O=_.tenant_groups.filter(e=>[`blocked`,`skipped`].includes(e.status)).length,k=(_.devices??[]).filter(e=>Q.has(e.device_status)).length,A=(_.devices??[]).filter(e=>Q.has(e.subscription_status)).length,j=_.status===`running`?(0,Z.jsxs)(Z.Fragment,{children:[(0,Z.jsx)(`p`,{className:`stop-run-note`,children:c?`Stop requested. Completed writes stay, remaining work is skipped, and this run cannot be resumed.`:`Completed writes stay. Stopping skips the remaining work, and this run cannot be resumed.`}),(0,Z.jsx)(`button`,{className:`danger-button`,disabled:a||c,onClick:()=>void C(),type:`button`,children:a||c?`Stopping after current step…`:`Stop after current step`})]}):null,M=_.mode===`existing`&&r&&[`completed_with_errors`,`failed`].includes(_.status),ee=T?(0,Z.jsxs)(Z.Fragment,{children:[M?(0,Z.jsxs)(Z.Fragment,{children:[(0,Z.jsx)(`p`,{className:`stop-run-note`,children:`This starts a new run with preflight and confirmation. Completed writes are re-checked, not repeated.`}),m?(0,Z.jsx)(`p`,{className:`form-error`,role:`alert`,children:m}):null,(0,Z.jsx)(`button`,{"aria-busy":f,className:`primary-button`,disabled:f,onClick:()=>void w(),type:`button`,children:f?`Loading manifest…`:`Run these devices again`})]}):null,(0,Z.jsx)(`button`,{className:_.status===`succeeded`?`primary-button`:`secondary-button`,onClick:n,type:`button`,children:_.status===`succeeded`?`Start another onboarding run`:`Back to hub`}),(0,Z.jsx)(tt,{jobId:_.id}),(0,Z.jsx)(nt,{jobId:_.id})]}):null;if(_.mode===`new`){let e=_.steps??[],t=_.tenant_groups.map(t=>{let n=at(e,t.tenant_name,`tenant`),r=at(e,t.tenant_name,`service`);return{group:t,serviceProvisioned:!!(r&&Q.has(r.status)),serviceStep:r,tenantCreated:!!(n&&Q.has(n.status)),tenantStep:n}}),n=t.filter(({tenantCreated:e})=>e).length,r=t.filter(({serviceProvisioned:e})=>e).length,i=_.status===`running`?t.filter(({serviceStep:e})=>e&&We.has(e.status)).length:0,a=t.flatMap(({serviceStep:e,tenantStep:t})=>[t,e]).filter(e=>e?.status===`waiting_rate_limit`),o=a.map(e=>e.wait_until).filter(e=>!!e).sort()[0],s=it(_.status),c=j??ee??(0,Z.jsx)(tt,{jobId:_.id});return(0,Z.jsxs)(`section`,{"aria-labelledby":`review-run-title`,className:`review-run run-surface tenant-creation-run`,children:[(0,Z.jsxs)(`p`,{className:`visually-hidden`,role:`status`,"aria-live":`polite`,children:[s,`. `,r,` of `,_.tenant_groups.length,` provisioned.`]}),(0,Z.jsx)(`div`,{className:`run-surface-main`,children:(0,Z.jsx)(Ge,{caption:`Live tenant creation and Central provisioning progress`,headers:[`Tenant`,`Central instance`,`Create workspace`,`Provision Central`,`Notes`],children:(0,Z.jsx)(`tbody`,{children:t.map(({group:e,serviceStep:t,tenantStep:n})=>{let r=n?.error??t?.error??e.last_error;return(0,Z.jsxs)(`tr`,{className:`run-progress-row is-${e.status}`,children:[(0,Z.jsx)(`th`,{scope:`row`,children:e.tenant_name}),(0,Z.jsx)(`td`,{children:Ne(e.service_name,e.service_region,e.service_region_display_name)}),(0,Z.jsx)(`td`,{children:st({group:e,kind:`tenant`,step:n})}),(0,Z.jsx)(`td`,{children:st({group:e,kind:`service`,step:t})}),(0,Z.jsx)(`td`,{children:r?(0,Z.jsx)(`span`,{className:`run-row-status`,children:(0,Z.jsx)(rt,{error:r,subject:`tenants`})}):(0,Z.jsx)(`span`,{"aria-hidden":`true`,children:`—`})})]},e.tenant_name)})})})}),(0,Z.jsx)(Qe,{action:(0,Z.jsxs)(Z.Fragment,{children:[u?(0,Z.jsx)(`p`,{className:`form-error`,role:`alert`,children:u}):null,c]}),complete:r,completeLabel:`tenants provisioned`,detail:null,inFlight:`${i} of 5 provisioning slots`,job:_,notes:(0,Z.jsxs)(`div`,{className:`run-summary-notes`,children:[a.length?(0,Z.jsxs)(`small`,{children:[a.length,` waiting on rate limit, `,(0,Z.jsx)(`span`,{children:(0,Z.jsx)(ot,{waitUntil:o})})]}):(0,Z.jsx)(`small`,{children:`No groups waiting on rate limit.`}),t.some(({serviceStep:e})=>e?.status===`pending`)?(0,Z.jsx)(`small`,{children:Ue}):null]}),pace:`at least 1 second between calls`,statSections:[{label:`Tenant groups`,stats:[{label:`Tenants created`,value:n},{label:`Central provisioned`,value:r},{danger:D>0,label:`Failed`,value:D},{label:`Skipped`,value:O}]}],title:s,total:_.tenant_groups.length})]})}let N=new Set((_.steps??[]).filter(e=>e.status===`running`).map(e=>`${e.tenant_name}:${e.operation}`)),P=new Map((_.steps??[]).filter(e=>e.logical_key===`verify`&&e.operation===`verify`).map(e=>[e.tenant_name,e])),F=b.filter(e=>{let t=_.tenant_groups.find(t=>He(e,t)),n=P.get(e.tenant_name);return e.error?.code!==`verification_failed`&&!!(n&&Q.has(n.status)||t?.status===`succeeded`&&Q.has(e.device_status)&&Q.has(e.subscription_status))}).length,I=$e(_.status),te=j??ee??(0,Z.jsx)(tt,{jobId:_.id}),L=_.status===`running`?_.tenant_groups.filter(e=>e.status===`running`).length:0;return(0,Z.jsxs)(`section`,{"aria-labelledby":`review-run-title`,className:`review-run run-surface`,children:[(0,Z.jsxs)(`p`,{className:`visually-hidden`,role:`status`,"aria-live":`polite`,children:[I,`. `,F,` of `,b.length,` devices verified. `,D,` tenant groups failed. `,O,` skipped.`]}),(0,Z.jsx)(`div`,{className:`run-surface-main`,children:(0,Z.jsx)(Ge,{caption:`Live device application assignment and subscription assignment progress`,headers:[`Device`,`Subscription key`,`Assign tenant`,`Assign subscription`,`Notes`],children:_.tenant_groups.map(e=>{let t=x.get(e.tenant_name)??[],n=t.filter(e=>Q.has(e.device_status)).length,r=t.filter(e=>Q.has(e.subscription_status)).length,i=t.length-t.filter(e=>Q.has(e.device_status)&&Q.has(e.subscription_status)).length,a=e.last_error??[...t].reverse().find(e=>e.error)?.error??null,o=e.status===`succeeded`?`Complete`:e.status===`failed`?`Failed`:[`blocked`,`skipped`].includes(e.status)?`Skipped`:e.status===`running`?`In progress`:`Queued`,s=P.get(e.tenant_name);return(0,Z.jsxs)(`tbody`,{"aria-label":`${e.tenant_name}, ${o}`,className:`review-run-group is-${e.status}`,children:[(0,Z.jsx)(`tr`,{className:`review-run-group-heading`,children:(0,Z.jsx)(`th`,{colSpan:5,scope:`rowgroup`,children:(0,Z.jsxs)(`div`,{className:`review-run-group-line`,children:[(0,Z.jsx)(`span`,{children:e.tenant_name}),(0,Z.jsx)(`small`,{children:Ne(e.service_name,e.service_region,e.service_region_display_name)}),e.status===`succeeded`?null:(0,Z.jsx)($,{tone:e.status===`failed`?`danger`:e.status===`running`?`active`:`idle`,children:o}),e.status===`failed`?(0,Z.jsxs)(`small`,{className:`review-run-partial`,children:[n,` application written · `,r,` subscription written · `,i,` not fully written`]}):null,a?(0,Z.jsx)(rt,{error:a}):null]})})}),t.map(t=>{let n=lt(t,e,s,T);return(0,Z.jsxs)(`tr`,{children:[(0,Z.jsx)(`th`,{className:`review-run-device`,scope:`row`,children:t.serial_number||`No serial reported`}),(0,Z.jsx)(`td`,{className:`review-run-subscription`,children:t.subscription_key||`—`}),(0,Z.jsx)(`td`,{children:ct(t.device_status,t.error,[`blocked`,`failed`,`skipped`].includes(e.status),T,N.has(`${e.tenant_name}:assign_devices`))}),(0,Z.jsx)(`td`,{children:ct(t.subscription_status,t.error,[`blocked`,`failed`,`skipped`].includes(e.status),T,N.has(`${e.tenant_name}:assign_subscriptions`))}),(0,Z.jsx)(`td`,{className:`run-cell-notes`,children:t.error?(0,Z.jsx)(`small`,{className:`review-run-error-copy`,children:Je(t.error)}):n?(0,Z.jsx)(`small`,{children:n}):null})]},t.glp_id)}),t.length===0?(0,Z.jsx)(`tr`,{children:(0,Z.jsx)(`td`,{colSpan:5,children:e.status===`blocked`?`${Le(e).join(` `)||`Preflight skipped this tenant.`} No writes were attempted.`:e.device_count?`Device writes have not started yet.`:`This tenant has no device writes.`})}):null]},e.tenant_name)})})}),(0,Z.jsx)(Qe,{action:(0,Z.jsxs)(Z.Fragment,{children:[u?(0,Z.jsx)(`p`,{className:`form-error`,role:`alert`,children:u}):null,te]}),complete:F,completeLabel:`devices verified`,detail:null,inFlight:`${L} ${Re(L,`tenant group`)}`,job:_,notes:(0,Z.jsx)(`div`,{className:`run-summary-notes`,children:(0,Z.jsx)(`small`,{children:T?`Tenant-group outcomes are shown below.`:`Application assignment completes before subscription assignment.`})}),pace:`adaptive, slows when the API asks`,statSections:[{label:`Devices`,stats:[{label:`In this run`,value:b.length},{label:`Tenant assigned`,value:k},{label:`Subscription assigned`,value:A},{label:`Verified`,value:F}]},{label:`Tenant groups`,stats:[{label:`Complete`,value:E},{danger:D>0,label:`Failed`,value:D},{label:`Skipped`,value:O}]}],title:I,total:b.length})]})}export{Ne as a,Fe as i,ut as n,Pe as r,Ve as t}; \ No newline at end of file diff --git a/msp-onboarding/static/assets/TenantsPage-C1BNyjuJ.js b/msp-onboarding/static/assets/TenantsPage-C1BNyjuJ.js new file mode 100644 index 0000000..c68fabc --- /dev/null +++ b/msp-onboarding/static/assets/TenantsPage-C1BNyjuJ.js @@ -0,0 +1,3 @@ +import{a as e,n as t,r as n,t as r}from"./RunSurface-DZb15gFL.js";import{t as i}from"./CsvImportPanel-B8vKd-O9.js";import{_ as a,b as o,c as s,i as c,n as l,p as u,r as d,t as f,v as p}from"./index-CeYmaMoQ.js";var m=o(p(),1),h=a(),g=l();function _({"aria-describedby":e,"aria-invalid":t,className:n,id:r,onBlur:i,onChange:a,options:o,required:s,value:c}){let l=(0,m.useRef)(null),[u,d]=(0,m.useState)(null),[f,p]=(0,m.useState)(!1),[_,v]=(0,m.useState)(null),y=`${r}-suggestions`,b=`${r}-match-count`,x=c.trim().toLocaleLowerCase(`en`),S=(0,m.useMemo)(()=>o.filter(({code:e,name:t})=>!x||t.toLocaleLowerCase(`en`).includes(x)||e.toLocaleLowerCase(`en`).includes(x)),[o,x]),C=u===null?null:S[u],w=e=>{a(e.name),d(null),p(!1)},T=(0,m.useCallback)(()=>{let e=l.current;if(!e)return;let t=e.getBoundingClientRect();v({left:t.left,maxHeight:Math.max(80,Math.min(256,window.innerHeight-t.bottom-16)),top:t.bottom+4,width:t.width})},[]);return(0,m.useLayoutEffect)(()=>{if(f)return T(),window.addEventListener(`resize`,T),window.addEventListener(`scroll`,T,!0),()=>{window.removeEventListener(`resize`,T),window.removeEventListener(`scroll`,T,!0)}},[f,T]),(0,m.useLayoutEffect)(()=>{!f||!C||document.getElementById(`${r}-option-${C.code}`)?.scrollIntoView?.({block:`nearest`})},[C,r,f]),(0,g.jsxs)(g.Fragment,{children:[(0,g.jsx)(`input`,{"aria-activedescendant":C?`${r}-option-${C.code}`:void 0,"aria-autocomplete":`list`,"aria-controls":y,"aria-describedby":[e,f?b:``].filter(Boolean).join(` `)||void 0,"aria-expanded":f,"aria-invalid":t,autoComplete:`off`,className:n,id:r,onBlur:()=>{p(!1),i?.()},onChange:e=>{d(null),p(!0),a(e.target.value)},onFocus:()=>p(!0),onKeyDown:e=>{e.key===`ArrowDown`&&S.length?(e.preventDefault(),p(!0),d(e=>e===null?0:(e+1)%S.length)):e.key===`ArrowUp`&&S.length?(e.preventDefault(),p(!0),d(e=>e===null?S.length-1:(e-1+S.length)%S.length)):e.key===`Enter`&&C?(e.preventDefault(),w(C)):e.key===`Escape`&&(d(null),p(!1))},ref:l,required:s,role:`combobox`,type:`text`,value:c}),f&&_?(0,h.createPortal)((0,g.jsxs)(g.Fragment,{children:[(0,g.jsxs)(`span`,{"aria-label":`Country match count`,"aria-live":`polite`,className:`visually-hidden`,id:b,role:`status`,children:[S.length,` `,S.length===1?`country match`:`country matches`]}),(0,g.jsx)(`ul`,{"aria-label":`Country suggestions`,className:`country-combobox-listbox`,id:y,role:`listbox`,style:_,children:S.map(({code:e,name:t},n)=>(0,g.jsxs)(`li`,{"aria-label":`${t} ${e}`,"aria-selected":n===u,className:n===u?`country-combobox-option is-active`:`country-combobox-option`,id:`${r}-option-${e}`,onClick:()=>w({code:e,name:t}),onMouseDown:e=>e.preventDefault(),role:`option`,children:[(0,g.jsx)(`span`,{children:t}),(0,g.jsx)(`span`,{children:e})]},e))})]}),document.body):null]})}var v=`AD.AE.AF.AG.AI.AL.AM.AO.AQ.AR.AS.AT.AU.AW.AX.AZ.BA.BB.BD.BE.BF.BG.BH.BI.BJ.BL.BM.BN.BO.BQ.BR.BS.BT.BV.BW.BY.BZ.CA.CC.CD.CF.CG.CH.CI.CK.CL.CM.CN.CO.CR.CU.CV.CW.CX.CY.CZ.DE.DJ.DK.DM.DO.DZ.EC.EE.EG.EH.ER.ES.ET.FI.FJ.FK.FM.FO.FR.GA.GB.GD.GE.GF.GG.GH.GI.GL.GM.GN.GP.GQ.GR.GS.GT.GU.GW.GY.HK.HM.HN.HR.HT.HU.ID.IE.IL.IM.IN.IO.IQ.IR.IS.IT.JE.JM.JO.JP.KE.KG.KH.KI.KM.KN.KP.KR.KW.KY.KZ.LA.LB.LC.LI.LK.LR.LS.LT.LU.LV.LY.MA.MC.MD.ME.MF.MG.MH.MK.ML.MM.MN.MO.MP.MQ.MR.MS.MT.MU.MV.MW.MX.MY.MZ.NA.NC.NE.NF.NG.NI.NL.NO.NP.NR.NU.NZ.OM.PA.PE.PF.PG.PH.PK.PL.PM.PN.PR.PS.PT.PW.PY.QA.RE.RO.RS.RU.RW.SA.SB.SC.SD.SE.SG.SH.SI.SJ.SK.SL.SM.SN.SO.SR.SS.ST.SV.SX.SY.SZ.TC.TD.TF.TG.TH.TJ.TK.TL.TM.TN.TO.TR.TT.TV.TW.TZ.UA.UG.UM.US.UY.UZ.VA.VC.VE.VG.VI.VN.VU.WF.WS.YE.YT.ZA.ZM.ZW`.split(`.`),y=new Intl.DisplayNames([`en`],{type:`region`}),b=new Set(v);function x(e){let t=e.trim().toUpperCase();return b.has(t)?y.of(t)??t:``}var S=v.map(e=>({code:e,name:x(e)})).sort((e,t)=>e.name.localeCompare(t.name,`en`)),C=new Map(S.map(({code:e,name:t})=>[t.toLocaleLowerCase(`en`),e]));function w(){return S.map(e=>({...e}))}function T(e){let t=e.trim();if(!t)return``;let n=t.toUpperCase();if(b.has(n))return n;let r=t.match(/^.+\(([a-z]{2})\)$/i);if(r){let e=r[1].toUpperCase();if(b.has(e))return e}return C.get(t.toLocaleLowerCase(`en`))??``}var E=w(),D=0,O=(e=null)=>({id:++D,name:``,countryInput:``,country:``,service:e,details:{},detailsOpen:!1}),ee=`name,country,description,email,phone_number,street_address,street_address_complement,city,state_or_region,postal_code,application,region +Example workspace,US,,,,,,,,,HPE Aruba Networking Central,us-west +`,k=[{key:`description`,label:`Description`},{key:`email`,label:`Contact email`,type:`email`,autoComplete:`email`},{key:`phone_number`,label:`Phone number`,type:`tel`,autoComplete:`tel`},{key:`address.street_address`,label:`Street address`,autoComplete:`address-line1`},{key:`address.street_address_complement`,label:`Address line 2`,autoComplete:`address-line2`},{key:`address.city`,label:`City`,autoComplete:`address-level2`},{key:`address.state_or_region`,label:`State or region`,autoComplete:`address-level1`},{key:`address.postal_code`,label:`Postal code`,autoComplete:`postal-code`}],A=(e,t)=>t.startsWith(`address.`)?e.address?.[t.slice(8)]??``:e[t]??``,j=(e,t,n)=>t.startsWith(`address.`)?{...e,address:{...e.address,[t.slice(8)]:n}}:{...e,[t]:n},M=e=>k.filter(t=>A(e,t.key).trim()).length,N=e=>`${e.service_manager_id}::${e.region}`,P=(e,t)=>{if(!e?.application||!e.region)return null;let r=t.filter(t=>t.name.trim().toLocaleLowerCase(`en`)===e.application.trim().toLocaleLowerCase(`en`)&&[t.region,t.region_display_name??``].some(t=>n(t)===n(e.region)));return r.length===1?{service_manager_id:r[0].service_manager_id,region:r[0].region}:null},F=e=>e instanceof d||e instanceof Error&&e.message.trim()&&!(e instanceof TypeError)?e.message:`Unable to reach onboarding. Check the server and try again.`,I=e=>{let t=e.path.match(/^tenants\[(\d+)\]/);return t?`Row ${Number(t[1])+1}: ${e.message}`:e.message};function L({demo:n,initialJobId:a=null,onJobDone:o,onJobStarted:l}){let[p,h]=(0,m.useState)(a?`run`:`edit`),[v,y]=(0,m.useState)(()=>[O()]),[b,S]=(0,m.useState)([]),[C,w]=(0,m.useState)(!a),[L,R]=(0,m.useState)(null),[z,B]=(0,m.useState)(a),[V,H]=(0,m.useState)([]),[U,W]=(0,m.useState)(``),[G,K]=(0,m.useState)(!1),[q,J]=(0,m.useState)(!1),[te,ne]=(0,m.useState)(null),Y=(0,m.useRef)(null),X=(0,m.useRef)(!0);(0,m.useEffect)(()=>{if(X.current){X.current=!1;return}return f()},[p]),(0,m.useEffect)(()=>{if(a)return;let e=!0;return s().then(t=>{if(!e)return;S(t);let n=Y.current;y(e=>e.map((e,r)=>{let i=e.service&&t.some(t=>t.service_manager_id===e.service?.service_manager_id&&t.region===e.service.region),a=n?.kind===`tenant`?P(n.services[r],t):null;return{...e,service:i?e.service:a??(t.length===1?{service_manager_id:t[0].service_manager_id,region:t[0].region}:null)}}))}).catch(t=>{e&&W(F(t))}).finally(()=>{e&&w(!1)}),()=>{e=!1}},[a]);let Z=(e,t)=>{y(n=>n.map(n=>n.id===e?{...n,...t}:n)),H([]),R(null)},Q=e=>{y(t=>t.map(n=>({...n,...e(t[0])}))),H([]),R(null)},$=(0,m.useMemo)(()=>v.flatMap((e,t)=>[...e.name.trim()?[]:[{path:`tenants[${t}].name`,code:`required`,message:`Tenant name is required.`}],...e.country?[]:[{path:`tenants[${t}].country`,code:`required`,message:`Choose a country.`}],...e.service?[]:[{path:`tenants[${t}].service`,code:`required`,message:`Choose an application.`}]]),[v]),re=()=>v.map(e=>({...e.details,name:e.name.trim(),country:e.country,service:e.service})),ie=e=>{Y.current=e,ne(e),!(!e||e.kind!==`tenant`||e.issues.length||!e.parsedRows.length)&&(y(e.parsedRows.map((t,n)=>{let{name:r,country:i,service:a,...o}=t,s=P(e.services[n],b);return{id:++D,name:r,country:i,countryInput:x(i),details:o,detailsOpen:Object.values(o).some(e=>e&&(typeof e!=`object`||Object.values(e).some(Boolean))),service:a??s??(b.length===1?{service_manager_id:b[0].service_manager_id,region:b[0].region}:null)}})),H([]),R(null))},ae=async()=>{if($.length){H($);return}K(!0),W(``),H([]);try{let e=await u({version:2,mode:`new`,tenants:re(),devices:[]});R(e),H([...e.errors,...e.tenant_groups.flatMap(e=>e.errors)]),h(`preflight`)}catch(e){e instanceof d&&e.issues.length?H(e.issues):W(F(e))}finally{K(!1)}},oe=async()=>{if(!L)return;let e=L.job_id;J(!0),W(``),B(e),h(`run`);try{let t=await c(e);l?.(t.job_id)}catch(e){W(F(e)),B(null),h(`preflight`)}finally{J(!1)}};return p===`run`&&z?(0,g.jsx)(t,{jobId:z,onDone:()=>{B(null),R(null),h(`edit`),o?.()}}):p===`preflight`&&L?(0,g.jsxs)(g.Fragment,{children:[V.length?(0,g.jsx)(`ul`,{className:`workflow-issues`,role:`alert`,children:V.map((e,t)=>(0,g.jsx)(`li`,{children:I(e)},`${e.path}-${e.code}-${t}`))}):null,U?(0,g.jsx)(`p`,{className:`form-error`,role:`alert`,children:U}):null,(0,g.jsx)(r,{backLabel:`tenants`,confirming:q,demo:n,devices:[],onBack:()=>h(`edit`),onConfirm:()=>void oe(),plan:L,subscriptions:[]})]}):(0,g.jsxs)(`section`,{"aria-labelledby":`tenants-page-title`,className:`tenants-page`,children:[(0,g.jsx)(`p`,{className:`section-label`,children:`Tenant journey`}),(0,g.jsx)(`h1`,{id:`tenants-page-title`,children:`Onboard tenants`}),(0,g.jsx)(`p`,{className:`tenant-page-lede`,children:`Add tenants as rows. Preflight checks every entry before anything is created.`}),U?(0,g.jsx)(`p`,{className:`form-error`,role:`alert`,children:U}):null,V.length?(0,g.jsx)(`ul`,{className:`workflow-issues`,role:`alert`,children:V.map((e,t)=>(0,g.jsx)(`li`,{children:I(e)},`${e.path}-${e.code}-${t}`))}):null,(0,g.jsx)(i,{disabled:G,kind:`tenant`,onChange:ie,template:ee,value:te}),(0,g.jsxs)(`section`,{"aria-labelledby":`tenant-rows-heading`,className:`tenant-editor-panel`,children:[(0,g.jsxs)(`div`,{className:`tenant-editor-heading`,children:[(0,g.jsxs)(`div`,{children:[(0,g.jsx)(`h2`,{id:`tenant-rows-heading`,children:`Tenants to create`}),(0,g.jsxs)(`p`,{children:[v.length,` `,v.length===1?`tenant`:`tenants`]})]}),(0,g.jsx)(`button`,{className:`secondary-button`,onClick:()=>y(e=>[...e,O(b.length===1?{service_manager_id:b[0].service_manager_id,region:b[0].region}:null)]),type:`button`,children:`Add tenant`})]}),(0,g.jsx)(`div`,{className:`tenant-editor-scroller`,children:(0,g.jsxs)(`table`,{className:`tenant-editor-table`,children:[(0,g.jsx)(`caption`,{className:`visually-hidden`,children:`New tenants`}),(0,g.jsx)(`thead`,{children:(0,g.jsxs)(`tr`,{children:[(0,g.jsx)(`th`,{scope:`col`,children:`Tenant name`}),(0,g.jsxs)(`th`,{scope:`col`,children:[(0,g.jsx)(`span`,{children:`Country`}),v.length>1?(0,g.jsx)(`button`,{className:`inline-action`,disabled:!v[0].country,onClick:()=>Q(e=>({country:e.country,countryInput:e.countryInput})),type:`button`,children:`Apply to all`}):null]}),(0,g.jsxs)(`th`,{scope:`col`,children:[(0,g.jsx)(`span`,{children:`Application`}),v.length>1&&b.length>1?(0,g.jsx)(`button`,{className:`inline-action`,disabled:!v[0].service,onClick:()=>Q(e=>({service:e.service})),type:`button`,children:`Apply to all`}):null]}),(0,g.jsx)(`th`,{scope:`col`,children:(0,g.jsx)(`span`,{className:`visually-hidden`,children:`Details and actions`})})]})}),(0,g.jsx)(`tbody`,{children:v.map((t,n)=>(0,g.jsxs)(m.Fragment,{children:[(0,g.jsxs)(`tr`,{children:[(0,g.jsxs)(`td`,{children:[(0,g.jsxs)(`label`,{className:`visually-hidden`,htmlFor:`tenant-${t.id}-name`,children:[`Tenant name, row `,n+1]}),(0,g.jsx)(`input`,{id:`tenant-${t.id}-name`,onChange:e=>Z(t.id,{name:e.target.value}),required:!0,value:t.name})]}),(0,g.jsxs)(`td`,{children:[(0,g.jsxs)(`label`,{className:`visually-hidden`,htmlFor:`tenant-${t.id}-country`,children:[`Country, row `,n+1]}),(0,g.jsx)(_,{id:`tenant-${t.id}-country`,onChange:e=>Z(t.id,{countryInput:e,country:T(e)}),options:E,required:!0,value:t.countryInput})]}),(0,g.jsxs)(`td`,{children:[(0,g.jsxs)(`label`,{className:`visually-hidden`,htmlFor:`tenant-${t.id}-service`,children:[`Application, row `,n+1]}),(0,g.jsxs)(`select`,{disabled:C||!b.length,id:`tenant-${t.id}-service`,onChange:e=>{let n=b.find(t=>N(t)===e.target.value);Z(t.id,{service:n?{service_manager_id:n.service_manager_id,region:n.region}:null})},value:t.service?N(t.service):``,children:[(0,g.jsx)(`option`,{value:``,children:C?`Loading services…`:b.length?`Choose a service`:`No eligible services`}),b.map(t=>(0,g.jsx)(`option`,{value:N(t),children:e(t.name,t.region,t.region_display_name)},N(t)))]})]}),(0,g.jsxs)(`td`,{className:`tenant-row-actions`,children:[(0,g.jsxs)(`button`,{"aria-controls":`tenant-${t.id}-details`,"aria-expanded":t.detailsOpen,"aria-label":`${t.detailsOpen?`Hide`:`Show`} optional details, row ${n+1}${M(t.details)?` (${M(t.details)} filled)`:``}`,className:`tenant-icon-button`,onClick:()=>Z(t.id,{detailsOpen:!t.detailsOpen}),type:`button`,children:[(0,g.jsx)(`span`,{"aria-hidden":`true`,className:`tenant-disclosure-chevron`,children:`›`}),M(t.details)?(0,g.jsx)(`span`,{"aria-hidden":`true`,className:`tenant-details-count`,children:M(t.details)}):null]}),v.length>1?(0,g.jsx)(`button`,{"aria-label":`Remove tenant row ${n+1}`,className:`tenant-icon-button`,onClick:()=>y(e=>e.filter(e=>e.id!==t.id)),type:`button`,children:`×`}):null]})]}),t.detailsOpen?(0,g.jsx)(`tr`,{className:`tenant-details-row`,id:`tenant-${t.id}-details`,children:(0,g.jsx)(`td`,{colSpan:4,children:(0,g.jsxs)(`fieldset`,{className:`tenant-details-fields`,children:[(0,g.jsxs)(`legend`,{children:[`Optional details, row `,n+1]}),k.map(e=>(0,g.jsxs)(`div`,{className:`tenant-details-field`,children:[(0,g.jsx)(`label`,{htmlFor:`tenant-${t.id}-${e.key}`,children:e.label}),(0,g.jsx)(`input`,{autoComplete:e.autoComplete,id:`tenant-${t.id}-${e.key}`,onChange:n=>Z(t.id,{details:j(t.details,e.key,n.target.value)}),type:e.type??`text`,value:A(t.details,e.key)})]},e.key))]})})}):null]},t.id))})]})})]}),(0,g.jsxs)(`div`,{className:`tenant-page-actions`,children:[(0,g.jsxs)(`span`,{children:[v.filter(e=>e.name&&e.country&&e.service).length,` of `,v.length,` ready`]}),(0,g.jsx)(`button`,{className:`primary-button`,disabled:G||C,onClick:()=>void ae(),type:`button`,children:G?`Running preflight…`:`Run preflight`})]})]})}export{L as TenantsPage}; \ No newline at end of file diff --git a/msp-onboarding/static/assets/geist-cyrillic-ext-wght-normal-DjL33-gN.woff2 b/msp-onboarding/static/assets/geist-cyrillic-ext-wght-normal-DjL33-gN.woff2 new file mode 100644 index 0000000..4c46295 Binary files /dev/null and b/msp-onboarding/static/assets/geist-cyrillic-ext-wght-normal-DjL33-gN.woff2 differ diff --git a/msp-onboarding/static/assets/geist-cyrillic-wght-normal-BEAKL7Jp.woff2 b/msp-onboarding/static/assets/geist-cyrillic-wght-normal-BEAKL7Jp.woff2 new file mode 100644 index 0000000..4b7a2be Binary files /dev/null and b/msp-onboarding/static/assets/geist-cyrillic-wght-normal-BEAKL7Jp.woff2 differ diff --git a/msp-onboarding/static/assets/geist-latin-ext-wght-normal-DC-KSUi6.woff2 b/msp-onboarding/static/assets/geist-latin-ext-wght-normal-DC-KSUi6.woff2 new file mode 100644 index 0000000..ba90e20 Binary files /dev/null and b/msp-onboarding/static/assets/geist-latin-ext-wght-normal-DC-KSUi6.woff2 differ diff --git a/msp-onboarding/static/assets/geist-latin-wght-normal-BgDaEnEv.woff2 b/msp-onboarding/static/assets/geist-latin-wght-normal-BgDaEnEv.woff2 new file mode 100644 index 0000000..991445d Binary files /dev/null and b/msp-onboarding/static/assets/geist-latin-wght-normal-BgDaEnEv.woff2 differ diff --git a/msp-onboarding/static/assets/geist-mono-cyrillic-ext-wght-normal-X_5orZeX.woff2 b/msp-onboarding/static/assets/geist-mono-cyrillic-ext-wght-normal-X_5orZeX.woff2 new file mode 100644 index 0000000..fbc0ebd Binary files /dev/null and b/msp-onboarding/static/assets/geist-mono-cyrillic-ext-wght-normal-X_5orZeX.woff2 differ diff --git a/msp-onboarding/static/assets/geist-mono-cyrillic-wght-normal-DiZS0aHC.woff2 b/msp-onboarding/static/assets/geist-mono-cyrillic-wght-normal-DiZS0aHC.woff2 new file mode 100644 index 0000000..2fb7832 Binary files /dev/null and b/msp-onboarding/static/assets/geist-mono-cyrillic-wght-normal-DiZS0aHC.woff2 differ diff --git a/msp-onboarding/static/assets/geist-mono-latin-ext-wght-normal-Bwz-egvJ.woff2 b/msp-onboarding/static/assets/geist-mono-latin-ext-wght-normal-Bwz-egvJ.woff2 new file mode 100644 index 0000000..ea22fd9 Binary files /dev/null and b/msp-onboarding/static/assets/geist-mono-latin-ext-wght-normal-Bwz-egvJ.woff2 differ diff --git a/msp-onboarding/static/assets/geist-mono-latin-wght-normal-XN7g48iV.woff2 b/msp-onboarding/static/assets/geist-mono-latin-wght-normal-XN7g48iV.woff2 new file mode 100644 index 0000000..0750682 Binary files /dev/null and b/msp-onboarding/static/assets/geist-mono-latin-wght-normal-XN7g48iV.woff2 differ diff --git a/msp-onboarding/static/assets/geist-mono-symbols2-wght-normal-CO5SzqOn.woff2 b/msp-onboarding/static/assets/geist-mono-symbols2-wght-normal-CO5SzqOn.woff2 new file mode 100644 index 0000000..f45df5d Binary files /dev/null and b/msp-onboarding/static/assets/geist-mono-symbols2-wght-normal-CO5SzqOn.woff2 differ diff --git a/msp-onboarding/static/assets/geist-mono-vietnamese-wght-normal-DadHysG0.woff2 b/msp-onboarding/static/assets/geist-mono-vietnamese-wght-normal-DadHysG0.woff2 new file mode 100644 index 0000000..49730a4 Binary files /dev/null and b/msp-onboarding/static/assets/geist-mono-vietnamese-wght-normal-DadHysG0.woff2 differ diff --git a/msp-onboarding/static/assets/geist-vietnamese-wght-normal-6IgcOCM7.woff2 b/msp-onboarding/static/assets/geist-vietnamese-wght-normal-6IgcOCM7.woff2 new file mode 100644 index 0000000..ee96589 Binary files /dev/null and b/msp-onboarding/static/assets/geist-vietnamese-wght-normal-6IgcOCM7.woff2 differ diff --git a/msp-onboarding/static/assets/index-Biuja0_L.css b/msp-onboarding/static/assets/index-Biuja0_L.css new file mode 100644 index 0000000..cb415e0 --- /dev/null +++ b/msp-onboarding/static/assets/index-Biuja0_L.css @@ -0,0 +1 @@ +@font-face{font-family:Geist Variable;font-style:normal;font-display:swap;font-weight:100 900;src:url(/static/assets/geist-cyrillic-ext-wght-normal-DjL33-gN.woff2)format("woff2-variations");unicode-range:U+460-52F,U+1C80-1C8A,U+20B4,U+2DE0-2DFF,U+A640-A69F,U+FE2E-FE2F}@font-face{font-family:Geist Variable;font-style:normal;font-display:swap;font-weight:100 900;src:url(/static/assets/geist-cyrillic-wght-normal-BEAKL7Jp.woff2)format("woff2-variations");unicode-range:U+301,U+400-45F,U+490-491,U+4B0-4B1,U+2116}@font-face{font-family:Geist Variable;font-style:normal;font-display:swap;font-weight:100 900;src:url(/static/assets/geist-vietnamese-wght-normal-6IgcOCM7.woff2)format("woff2-variations");unicode-range:U+102-103,U+110-111,U+128-129,U+168-169,U+1A0-1A1,U+1AF-1B0,U+300-301,U+303-304,U+308-309,U+323,U+329,U+1EA0-1EF9,U+20AB}@font-face{font-family:Geist Variable;font-style:normal;font-display:swap;font-weight:100 900;src:url(/static/assets/geist-latin-ext-wght-normal-DC-KSUi6.woff2)format("woff2-variations");unicode-range:U+100-2BA,U+2BD-2C5,U+2C7-2CC,U+2CE-2D7,U+2DD-2FF,U+304,U+308,U+329,U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF}@font-face{font-family:Geist Variable;font-style:normal;font-display:swap;font-weight:100 900;src:url(/static/assets/geist-latin-wght-normal-BgDaEnEv.woff2)format("woff2-variations");unicode-range:U+??,U+131,U+152-153,U+2BB-2BC,U+2C6,U+2DA,U+2DC,U+304,U+308,U+329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD}@font-face{font-family:Geist Mono Variable;font-style:normal;font-display:swap;font-weight:100 900;src:url(/static/assets/geist-mono-cyrillic-ext-wght-normal-X_5orZeX.woff2)format("woff2-variations");unicode-range:U+460-52F,U+1C80-1C8A,U+20B4,U+2DE0-2DFF,U+A640-A69F,U+FE2E-FE2F}@font-face{font-family:Geist Mono Variable;font-style:normal;font-display:swap;font-weight:100 900;src:url(/static/assets/geist-mono-cyrillic-wght-normal-DiZS0aHC.woff2)format("woff2-variations");unicode-range:U+301,U+400-45F,U+490-491,U+4B0-4B1,U+2116}@font-face{font-family:Geist Mono Variable;font-style:normal;font-display:swap;font-weight:100 900;src:url(/static/assets/geist-mono-symbols2-wght-normal-CO5SzqOn.woff2)format("woff2-variations");unicode-range:U+2000-2001,U+2004-2008,U+200A,U+23B8-23BD,U+2500-259F}@font-face{font-family:Geist Mono Variable;font-style:normal;font-display:swap;font-weight:100 900;src:url(/static/assets/geist-mono-vietnamese-wght-normal-DadHysG0.woff2)format("woff2-variations");unicode-range:U+102-103,U+110-111,U+128-129,U+168-169,U+1A0-1A1,U+1AF-1B0,U+300-301,U+303-304,U+308-309,U+323,U+329,U+1EA0-1EF9,U+20AB}@font-face{font-family:Geist Mono Variable;font-style:normal;font-display:swap;font-weight:100 900;src:url(/static/assets/geist-mono-latin-ext-wght-normal-Bwz-egvJ.woff2)format("woff2-variations");unicode-range:U+100-2BA,U+2BD-2C5,U+2C7-2CC,U+2CE-2D7,U+2DD-2FF,U+304,U+308,U+329,U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF}@font-face{font-family:Geist Mono Variable;font-style:normal;font-display:swap;font-weight:100 900;src:url(/static/assets/geist-mono-latin-wght-normal-XN7g48iV.woff2)format("woff2-variations");unicode-range:U+??,U+131,U+152-153,U+2BB-2BC,U+2C6,U+2DA,U+2DC,U+304,U+308,U+329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD}:root,[data-theme=dark]{--lightningcss-light: ;--lightningcss-dark:initial;color-scheme:dark;--canvas:#010102;--surface-1:#0f1011;--surface-2:#141516;--surface-3:#18191a;--surface-4:#191a1b;--hairline:#23252a;--hairline-strong:#34343a;--hairline-tertiary:#3e3e44;--ink:#f7f8f8;--ink-muted:#d0d6e0;--ink-subtle:#8a8f98;--ink-tertiary:#7c818b;--primary-base:#5e6ad2;--primary-hover:#828fff;--primary-focus:#5e69d1;--primary-on-solid:#fff;--primary-surface:#17182b;--primary-surface-strong:#24274b;--success:#27a644;--success-surface:#102419;--success-on-surface:#65cf7b;--warning-base:#e0a43a;--warning-surface:#281e0d;--warning-on-surface:#f0bd62;--danger-base:#ec6a70;--danger-solid:#a73540;--danger-solid-hover:#bd414b;--danger-surface:#291316;--danger-surface-strong:#3a1b20;--shadow-color:#000000ad;--background:var(--canvas);--surface:var(--surface-1);--foreground:var(--ink);--muted:var(--surface-2);--muted-foreground:var(--ink-subtle);--border:var(--hairline);--primary:var(--primary-base);--primary-foreground:var(--primary-on-solid);--warning:var(--warning-base);--danger:var(--danger-base);--focus:var(--primary-focus);--font-sans:"Geist Variable", "Geist", ui-sans-serif, system-ui, -apple-system, sans-serif;--font-mono:"Geist Mono Variable", "Geist Mono", ui-monospace, "SFMono-Regular", Consolas, monospace;--text-display-xl:5rem;--text-display-lg:3.5rem;--text-display-md:2.5rem;--text-headline:1.75rem;--text-card-title:1.375rem;--text-subhead:1.25rem;--text-body-lg:1.125rem;--text-body:1rem;--text-body-sm:.875rem;--text-caption:.75rem;--text-eyebrow:.8125rem;--leading-display-xl:1.05;--leading-display-lg:1.1;--leading-display-md:1.15;--leading-headline:1.2;--leading-card-title:1.25;--leading-subhead:1.4;--leading-body:1.5;--leading-caption:1.4;--tracking-display-xl:-.0375em;--tracking-display-lg:-.032em;--tracking-display-md:-.025em;--tracking-headline:-.021em;--tracking-card-title:-.018em;--tracking-subhead:-.01em;--tracking-body-lg:-.006em;--tracking-body:-.003em;--tracking-eyebrow:.031em;--weight-display:600;--weight-card-title:500;--weight-body:400;--radius-xs:4px;--radius-sm:6px;--radius-md:8px;--radius-lg:12px;--radius-xl:16px;--radius-xxl:24px;--radius-pill:9999px;--radius:var(--radius-md);--motion-duration-feedback:.16s;--motion-duration-check:.9s;--motion-duration-write:1s;--motion-ease-standard:cubic-bezier(.25, 1, .5, 1);--motion-ease-linear:linear;color:var(--foreground);background:var(--background);font-family:var(--font-sans);font-feature-settings:"tnum", "ss01", "ss02";-webkit-font-smoothing:antialiased;text-rendering:optimizelegibility;accent-color:var(--primary)}[data-theme=light]{--lightningcss-light:initial;--lightningcss-dark: ;color-scheme:light;--canvas:#f8f8fb;--surface-1:#fdfdfe;--surface-2:#f1f1f5;--surface-3:#eaeaef;--surface-4:#e3e4ea;--hairline:#d4d6df;--hairline-strong:#bfc2ce;--hairline-tertiary:#aaafbc;--ink:#1b1c20;--ink-muted:#3f424a;--ink-subtle:#5f636d;--ink-tertiary:#666b76;--primary-base:#5661c5;--primary-hover:#4e59bd;--primary-focus:#5e69d1;--primary-on-solid:#fff;--primary-surface:#eef;--primary-surface-strong:#dfe1fa;--success:#187a34;--success-surface:#e4f4e8;--success-on-surface:#176b30;--warning-base:#895c00;--warning-surface:#fff2cf;--warning-on-surface:#734c00;--danger-base:#b4232e;--danger-solid:#a61f2b;--danger-solid-hover:#891923;--danger-surface:#fcebed;--danger-surface-strong:#f6dadd;--shadow-color:#1c1d2333}*{box-sizing:border-box}body{background:var(--background);min-width:320px;min-height:100vh;font-size:var(--text-body);line-height:var(--leading-body);margin:0}button,input,select{font:inherit}button{cursor:pointer}button:disabled{cursor:not-allowed;opacity:.6}button:focus-visible,input:focus-visible,select:focus-visible,summary:focus-visible{outline:.125rem solid var(--focus);outline-offset:2px}main h1:focus-visible{outline:.125rem solid var(--focus);outline-offset:.25rem}h1,h2,p{margin-top:0}h1{font-size:var(--text-display-md);font-weight:var(--weight-display);letter-spacing:var(--tracking-display-md);line-height:var(--leading-display-md)}h2{font-size:var(--text-card-title);font-weight:var(--weight-card-title);letter-spacing:var(--tracking-card-title);line-height:var(--leading-card-title)}.product-name{color:var(--foreground);margin:0;font-size:.875rem;font-weight:600}.product-name strong{color:var(--primary)}.login-page,.loading-page,.placeholder-page{place-items:center;min-height:100vh;padding:1.5rem;display:grid}.login-panel,.placeholder-page section{background:var(--surface);border:1px solid var(--border);box-shadow:0 14px 30px -24px var(--shadow-color);width:100%;max-width:30rem;padding:clamp(1.5rem,4vw,2.5rem)}.login-panel h1,.placeholder-page h1{margin:1.5rem 0 .5rem;font-size:2rem}.login-copy,.hub-intro,.summary-rail p,.placeholder-page p{color:var(--muted-foreground);line-height:1.55}.demo-callout{background:var(--muted);max-width:64ch;margin:1.25rem 0 0;padding:.75rem;font-size:.875rem;line-height:1.45}.login-help-link{margin:.75rem 0 0;font-size:.875rem}.login-help-link a{color:var(--primary);align-items:center;gap:.3rem;text-decoration:none;display:inline-flex}.login-help-link a:hover,.login-help-link a:focus-visible{text-decoration:underline}.login-form{gap:1rem;margin-top:2rem;display:grid}.login-form label{gap:.4rem;font-size:.875rem;font-weight:600;display:grid}.login-form input{background:var(--surface);border:1px solid var(--border);border-radius:var(--radius);color:var(--foreground);min-height:44px;padding:.625rem .75rem}.primary-button,.secondary-button,.danger-button,.commit-button,.file-button{border-radius:var(--radius);font-size:var(--text-body-sm);justify-content:center;align-items:center;min-height:44px;padding:.5rem .875rem;font-weight:500;line-height:1.2;display:inline-flex}.primary-button,.commit-button{background:var(--primary);border:1px solid var(--primary);color:var(--primary-foreground)}.primary-button:hover:not(:disabled),.commit-button:hover:not(:disabled){background:var(--primary-hover);border-color:var(--primary-hover)}.secondary-button,.file-button{background:var(--surface);border:1px solid var(--border);color:var(--foreground)}.secondary-button:hover:not(:disabled),.file-button:hover{background:var(--surface-2)}.danger-button{background:var(--danger-solid);border:1px solid var(--danger-solid);color:var(--primary-foreground)}.danger-button:hover:not(:disabled){background:var(--danger-solid-hover);border-color:var(--danger-solid-hover)}[aria-invalid=true]{box-shadow:0 0 0 1px var(--danger);border-color:var(--danger)!important}.demo-divider{color:var(--muted-foreground);text-align:center;margin:1.5rem 0 .75rem;font-size:.8125rem}.login-panel>.secondary-button{width:100%}.form-error{color:var(--danger);margin:0;font-size:.875rem;line-height:1.45}.loading-page{align-content:center;justify-items:start;gap:.75rem;max-width:50rem;margin:auto}.skeleton{background:var(--muted);border-radius:var(--radius);animation:1.4s ease-in-out infinite pulse}.skeleton-title{width:min(60%,24rem);height:2.5rem}.skeleton-line{width:min(90%,34rem);height:1rem}.app-shell{min-height:100vh}.app-header{background:var(--surface);border-bottom:1px solid var(--border);align-items:center;gap:.75rem;min-height:56px;padding:.75rem clamp(1rem,4vw,3rem);display:flex}.app-header>.secondary-button{margin-left:auto}.header-actions{align-items:center;gap:.5rem;margin-left:auto;display:flex}.discard-confirmation{flex-wrap:wrap;justify-content:flex-end;align-items:center;gap:.5rem;display:flex}.discard-confirmation p{max-width:34rem;margin:0 .5rem;font-size:.8125rem;line-height:1.4}.guided-shell{grid-template-columns:12.5rem minmax(0,1fr) 15rem;max-width:90rem;min-height:calc(100vh - 56px);margin:0 auto;display:grid}.guided-shell-run{grid-template-columns:12.5rem minmax(0,1fr)}.guided-shell-run .workflow-main{max-width:none}.hub-shell{grid-template-columns:minmax(0,1fr)}.stage-rail,.summary-rail{background:var(--surface);padding:1.5rem 1rem}.stage-rail{border-right:1px solid var(--border)}.stage-scroll{min-width:0}.stage-rail ol{gap:.25rem;margin:0;padding:0;list-style:none;display:grid}.stage-overflow-hint{display:none}.stage-rail li{color:var(--muted-foreground);min-height:44px;font-size:.875rem}.stage-rail li button{color:inherit;text-align:left;background:0 0;border:0;align-items:center;gap:.625rem;width:100%;min-height:44px;padding:.25rem .5rem;display:flex}.stage-rail li button span{background:var(--muted);border-radius:50%;flex:none;justify-content:center;align-items:center;width:1.5rem;height:1.5rem;font-size:.75rem;display:inline-flex}.stage-rail .active-stage button:disabled{opacity:1}.stage-rail li button span.stage-badge{background:var(--danger-solid);border-radius:var(--radius-pill);color:var(--primary-foreground);width:auto;min-width:1.125rem;height:1.125rem;margin-left:auto;padding:0 .3rem;font-weight:650}.section-label{color:var(--primary);font-size:var(--text-eyebrow);letter-spacing:var(--tracking-eyebrow);text-transform:uppercase;margin-bottom:.75rem;font-weight:500;line-height:1.3}.hub-main h1{margin-bottom:.75rem}.hub-intro{max-width:42rem}.active-run-entry{background:var(--primary-surface);border:1px solid var(--primary-surface-strong);border-radius:var(--radius-lg);flex-wrap:wrap;justify-content:space-between;align-items:center;gap:1rem;margin:1.5rem 0;padding:1rem;display:flex}.active-run-entry h2{margin:0 0 .25rem;font-size:1rem}.active-run-entry p,.active-run-entry small{color:var(--ink-muted);margin:0;font-size:.875rem;line-height:1.45}.active-run-entry small{margin-top:.25rem;display:block}.empty-state{color:var(--muted-foreground);margin:2rem 0;font-size:.875rem}.draft-notice{background:var(--primary-surface);border:1px solid var(--primary-surface-strong);flex-wrap:wrap;justify-content:space-between;align-items:center;gap:1rem;margin:1.5rem 0;padding:1rem;display:flex}.draft-notice h2{margin:0 0 .25rem;font-size:1rem}.draft-notice p{color:var(--ink-muted);margin:0;font-size:.875rem}.draft-actions{flex-wrap:wrap;gap:.75rem;display:flex}.planned-notice{flex-wrap:wrap;justify-content:space-between;align-items:center;gap:.75rem;margin:1.5rem 0;display:flex}.planned-notice p{margin:0}.journey-list{border-top:1px solid var(--border);display:grid}.journey-button{border:0;border-bottom:1px solid var(--border);color:var(--foreground);text-align:left;background:0 0;grid-template-columns:auto 1fr auto;align-items:center;gap:1rem;min-height:88px;padding:1rem .25rem;display:grid}.journey-button:hover{background:var(--surface-2)}.journey-button strong,.journey-button small{display:block}.journey-button strong{margin-bottom:.25rem;font-size:1rem}.journey-button small{color:var(--muted-foreground);font-size:.8125rem;line-height:1.4}.journey-button>svg{color:var(--primary)}.journey-mark{background:var(--muted);color:var(--primary);border-radius:50%;justify-content:center;align-items:center;width:2.25rem;height:2.25rem;display:inline-flex}.history-entry{border-bottom:1px solid var(--border);flex-wrap:wrap;justify-content:space-between;align-items:center;gap:1rem;padding:1.5rem 0;display:flex}.history-entry h2{margin:0 0 .25rem;font-size:1rem}.history-entry p{color:var(--muted-foreground);max-width:48ch;margin:0;font-size:.875rem;line-height:1.45}.summary-rail{border-left:1px solid var(--border)}.summary-rail h2{text-transform:uppercase;margin-bottom:1rem;font-size:.875rem}.summary-rail p{font-size:.8125rem}.summary-rail dl{margin:1.5rem 0 0}.summary-rail dl div{border-top:1px solid var(--border);padding:.75rem 0}.summary-rail dt{color:var(--muted-foreground);font-size:.75rem}.summary-rail dd{margin:.2rem 0 0;font-size:.8125rem;line-height:1.4}.mobile-summary{display:none}.active-stage{background:var(--primary-surface);font-weight:650;color:var(--foreground)!important}.active-stage button span{color:var(--primary-foreground);background:var(--primary)!important}.complete-stage{color:var(--success-on-surface)!important}.complete-stage button span{background:var(--success-surface)!important}.workflow-main{width:100%;min-width:0;max-width:64rem;margin:0 auto;padding:clamp(1.5rem,4vw,3.5rem) clamp(1rem,4vw,3rem)}.workflow-main-compact{max-width:48rem;padding-block:clamp(1.5rem,3vw,2.75rem)}.workflow-main h1{margin-bottom:1.25rem}.workflow-field{align-content:start;gap:.4rem;max-width:32rem;font-size:.875rem;font-weight:650;display:grid}.field-hint{color:var(--muted-foreground);font-size:.8125rem;font-weight:400;line-height:1.4}.workflow-field input,.workflow-field select,.workbench-toolbar input,.device-workbench select{background:var(--surface);border:1px solid var(--border);border-radius:var(--radius);color:var(--foreground);width:100%;min-height:44px;padding:.625rem .75rem}.country-combobox-listbox{background:var(--surface);border:1px solid var(--border);border-radius:var(--radius);box-shadow:0 14px 30px -24px var(--shadow-color);z-index:50;margin:0;padding:.25rem;list-style:none;position:fixed;overflow-y:auto}.country-combobox-option{border-radius:calc(var(--radius) - .125rem);cursor:pointer;justify-content:space-between;align-items:center;gap:.75rem;min-height:44px;padding:.45rem .625rem;font-size:.875rem;display:flex}.country-combobox-option>span:last-child{color:var(--muted-foreground);font-family:var(--font-mono);font-size:.75rem}.country-combobox-option:hover,.country-combobox-option.is-active{background:var(--muted);color:var(--foreground)}.device-workbench select:disabled{background:var(--muted);color:var(--muted-foreground);cursor:not-allowed;opacity:1;border-style:dashed}.workflow-actions{justify-content:space-between;gap:.75rem;margin-top:1.5rem;display:flex}.workflow-main-compact .workflow-actions{margin-top:1.25rem}.action-hint,.workbench-help{color:var(--muted-foreground);max-width:64ch;margin:.75rem 0 0;font-size:.8125rem;line-height:1.45}#preflight-next-hint{max-width:64ch}.inline-help{color:var(--muted-foreground);max-width:42rem;margin:0 0 1rem;font-size:.8125rem}.inline-help summary,.ineligible-keys summary{cursor:pointer;width:fit-content;min-height:44px;padding-block:.75rem;font-weight:650;display:list-item}.inline-help summary::marker{color:var(--primary)}.ineligible-keys summary::marker{color:var(--primary)}.mobile-summary>summary::marker{color:var(--primary)}.inline-help p{margin:0 0 .75rem;line-height:1.5}.ownership-notice{background:var(--surface-2);border:1px solid var(--border);margin:0;padding:.75rem 1rem;line-height:1.5}.service-notice{background:var(--primary-surface);border:1px solid var(--primary);padding:.75rem 1rem;line-height:1.5}.auto-service-notice{flex-wrap:wrap;justify-content:space-between;align-items:center;gap:.75rem 1rem;display:flex}.auto-service-notice p{margin:0}.workflow-issues{background:var(--danger-surface);border:1px solid var(--danger);color:var(--foreground);margin:1rem 0;padding:.75rem 1rem;line-height:1.5;list-style:none}.workflow-issues:empty{display:none}.workflow-issues>li+li{border-top:1px solid var(--border);margin-top:.5rem;padding-top:.5rem}.workflow-issues ul{margin-top:.25rem;padding-left:1.25rem}.device-workbench{margin-top:1rem}.workbench-entry-bar{flex-wrap:wrap;justify-content:space-between;align-items:center;gap:.5rem 1.5rem;margin-bottom:.75rem;display:flex}.workbench-entry-hint{color:var(--ink-subtle);flex:24rem;align-items:center;gap:.5rem;min-width:0;margin:0;font-size:.8125rem;line-height:1.45;display:flex}.workbench-entry-hint>svg{color:var(--primary);flex:none}.workbench-importer{border-radius:var(--radius);border:1px dashed #0000;flex-wrap:wrap;align-items:center;gap:.25rem .75rem;padding:.25rem;display:flex}.csv-drop-copy{color:var(--muted-foreground);white-space:nowrap;font-size:.75rem}.workbench-importer.is-dragging,.tenant-import-panel.is-dragging{background:var(--primary-surface);border-color:var(--primary)}.search-label-row{justify-content:space-between;align-items:center;gap:.75rem;display:flex}.shortcut-hint{color:var(--muted-foreground);white-space:nowrap;font-size:.6875rem;font-weight:500}.shortcut-hint kbd{background:var(--muted);border:1px solid var(--border);color:var(--foreground);font-family:var(--font-mono);border-radius:.25rem;padding:.0625rem .3rem;font-size:.6875rem}.workbench-import-feedback{justify-items:start;gap:.5rem;min-width:0;margin-bottom:.75rem;display:grid}.workbench-import-feedback .workbench-status{margin:0}.workbench-import-actions{flex-wrap:wrap;gap:.5rem;display:flex}.workbench-action-space{min-block-size:5rem}.workbench-action-footer{background:var(--surface);border:1px solid var(--border);z-index:10;flex-wrap:wrap;align-items:center;gap:.75rem 1rem;padding:.75rem clamp(1rem,4vw,3rem);display:flex;position:fixed;bottom:0;left:max(12.5rem,50vw - 32.5rem);right:max(15rem,50vw - 30rem)}.workbench-action-content{flex:20rem;min-width:0}.workbench-readiness{flex-wrap:wrap;align-items:baseline;gap:.25rem .75rem;display:flex}.workbench-readiness>span:first-child{color:var(--muted-foreground);text-transform:uppercase;font-size:.75rem}.workbench-readiness>strong{font-size:.875rem}.workbench-readiness>a,.workbench-readiness>span:last-child{color:var(--muted-foreground);font-size:.8125rem}.workbench-readiness>a{color:var(--danger);text-underline-offset:.2rem;font-weight:650}.workbench-action-buttons{gap:.75rem;display:flex}.workbench-toolbar{grid-template-columns:minmax(14rem,1fr) minmax(18rem,1.25fr);align-items:start;gap:.75rem;margin-bottom:1rem;display:grid}.workbench-toolbar>label:first-child{flex:16rem;font-size:.875rem;font-weight:650}.csv-heading{flex-wrap:wrap;align-items:center;gap:0 .75rem;display:flex}.inline-action{color:var(--primary);text-underline-offset:.2rem;background:0 0;border:0;justify-self:start;min-height:44px;padding:.5rem 0;font-size:.8125rem;text-decoration:underline}.csv-recovery-help{color:var(--muted-foreground);max-width:64ch;margin:-.5rem 0 1rem;font-size:.8125rem;line-height:1.45}.device-table-wrap{border:1px solid var(--border);padding:.5rem;position:relative;overflow-x:auto}.device-workbench table{border-collapse:collapse;width:100%;min-width:36rem}.device-workbench th,.device-workbench td{border-bottom:1px solid var(--border);text-align:left;padding:.65rem}.device-workbench th{background:var(--muted);color:var(--muted-foreground);text-transform:uppercase;font-size:.75rem}.device-select{width:5.5rem;padding:0!important}.device-select label{cursor:pointer;justify-content:center;align-items:center;gap:.5rem;min-width:44px;min-height:44px;padding:0 .5rem;display:flex}.device-select label>span[aria-hidden=true]{white-space:nowrap}.device-workbench td input[type=checkbox]{block-size:1.25rem;inline-size:1.25rem}.device-identity span{display:block}.row-action{background:var(--surface);border:1px solid var(--border);border-radius:var(--radius);color:var(--primary);min-height:44px;padding:.5rem .75rem}.row-action:hover{background:var(--muted)}.key-value{font-family:var(--font-mono);font-size:.8125rem}.seat-key-list{grid-template-columns:repeat(auto-fit,minmax(11rem,1fr));gap:.75rem;display:grid}.seat-key{background:var(--muted);gap:.25rem;padding:.75rem;display:grid}.seat-key span{color:var(--muted-foreground);font-size:.8125rem}.seat-key-error{border:1px solid var(--danger)}.seat-key .form-error{color:var(--danger)}.ineligible-keys{border-top:1px solid var(--border);margin-top:.75rem;padding-top:.25rem}.ineligible-keys .seat-key-list{margin-top:.5rem}.device-empty-row td{text-align:center;padding:1rem}.device-empty-row p{color:var(--muted-foreground);margin-bottom:.75rem}.seat-mapping-list span+span:before{color:var(--muted-foreground);content:" · "}.visually-hidden{clip:rect(0 0 0 0);clip-path:inset(50%);white-space:nowrap;block-size:1px;inline-size:1px;position:absolute;overflow:hidden}@keyframes pulse{50%{opacity:.45}}@media (width<=900px){.guided-shell{grid-template-columns:10.5rem minmax(0,1fr)}.hub-shell{grid-template-columns:minmax(0,1fr)}.summary-rail{display:none}.stage-rail{grid-area:1/1/span 2}.mobile-summary{background:var(--surface);border-bottom:1px solid var(--border);grid-area:1/2;align-self:start;padding:.5rem 1rem;display:block}.mobile-summary>summary{cursor:pointer;min-height:44px;padding:.75rem 0;font-size:.875rem;font-weight:650;display:list-item}.mobile-summary dl{margin:1rem 0 0}.mobile-summary dl div{border-top:1px solid var(--border);padding:.75rem 0}.mobile-summary dt{color:var(--muted-foreground);font-size:.75rem}.mobile-summary dd{margin:.2rem 0 0;font-size:.8125rem;line-height:1.4}.workflow-main{grid-area:2/2}.workbench-action-footer{left:10.5rem;right:0}}@media (width<=640px){.app-header{flex-wrap:wrap;padding:.75rem 1rem}.header-actions{flex-wrap:wrap;width:100%}.discard-confirmation{flex-basis:100%;justify-content:stretch;margin-left:0}.discard-confirmation p{flex-basis:100%;max-width:none;margin-right:0}.discard-confirmation button{flex:1}.guided-shell{display:block}.stage-rail{border-bottom:1px solid var(--border);border-right:0;padding:.5rem 0}.stage-scroll{scroll-snap-type:x mandatory;overflow-x:auto}.stage-rail ol{grid-auto-columns:50%;grid-auto-flow:column;gap:0;width:100%;display:grid}.stage-rail li{scroll-snap-align:start;white-space:nowrap}.stage-rail li button{padding:.25rem .75rem}.stage-overflow-hint{color:var(--muted-foreground);text-align:right;margin:.25rem 0 0;padding:0 .75rem;font-size:.75rem;display:block}.history-main{padding:2rem 1rem 3rem}.history-entry{align-items:stretch}.history-entry .secondary-button{width:100%}.active-run-entry{align-items:stretch}.active-run-entry .primary-button{width:100%}.workflow-main{padding:1.25rem 1rem 2rem}.workflow-main h1{margin-bottom:.75rem;font-size:1.75rem}.workflow-actions{flex-direction:column-reverse}.workflow-actions button,.draft-actions{width:100%}.draft-actions button{flex:1}.workbench-toolbar{grid-template-columns:1fr}.workbench-action-footer{align-items:stretch;padding:.75rem 1rem;left:0;right:0}.workbench-action-content{flex-basis:100%}.workbench-action-buttons{grid-template-columns:repeat(2,minmax(0,1fr));display:grid}.workbench-action-buttons button{width:100%}.seat-key-list{grid-template-columns:1fr}}.review-plan,.review-run{min-width:0}.app-shell>.review-run{max-width:76rem;margin:0 auto;padding:clamp(2rem,5vw,4rem) clamp(1rem,4vw,3rem)}.workflow-main .review-run{padding:0}.review-heading,.review-run-heading{background:var(--surface);border:1px solid var(--border);padding:1.25rem}.review-heading h1,.review-run-heading h1{letter-spacing:-.025em;margin:.25rem 0 .5rem;font-size:clamp(1.25rem,2.2vw,1.75rem);line-height:1.25}.review-heading>p:last-child,.review-run-heading>p:last-child{color:var(--muted-foreground);max-width:74ch;margin:0;line-height:1.5}.review-plan-grid{grid-template-columns:minmax(0,1fr) minmax(18rem,.8fr);gap:1rem;margin:1rem 0;display:grid}.review-capacity{border:1px solid var(--border);padding:1rem 1.25rem}.review-capacity h2{margin:0;font-size:1rem}.review-capacity>p{color:var(--muted-foreground);margin:.25rem 0 0;font-size:.8125rem;line-height:1.5}.review-capacity ul{margin:.75rem 0 0;padding:0;list-style:none}.review-capacity li{background:var(--muted);justify-content:space-between;align-items:center;padding:.625rem .75rem;font-size:.8125rem;display:flex}.review-capacity li+li{border-top:1px solid var(--border)}.review-tenant-list{border:1px solid var(--border)}.review-tenant+.review-tenant{border-top:1px solid var(--border)}.review-tenant-summary{grid-template-columns:minmax(12rem,1fr) auto 7rem;align-items:center;gap:1rem;min-height:58px;padding:.625rem 1rem;display:grid}.review-tenant-summary>span:first-child strong,.review-tenant-summary>span:first-child small{display:block}.review-tenant-summary small{color:var(--muted-foreground);margin-top:.125rem;font-size:.75rem}.review-tenant.is-blocked .review-tenant-summary>span:last-child{color:var(--danger);font-weight:650}.review-subscription-exception{font-weight:700;color:var(--warning)!important}.review-actions{justify-content:space-between;gap:.75rem;margin-top:1rem;display:flex}.review-run-totals{border:1px solid var(--border);border-top:0;grid-template-columns:repeat(5,1fr);margin:0 0 1rem;display:grid}.review-run-totals>div{min-width:0;padding:.75rem 1rem}.review-run-totals>div+div{border-left:1px solid var(--border)}.review-run-totals dt{color:var(--muted-foreground);font-size:.6875rem;line-height:1.35}.review-run-totals dd{color:var(--primary);font-variant-numeric:tabular-nums;margin:.125rem 0 0;font-size:1.5rem;font-weight:700}.review-run-table-wrap{border:1px solid var(--border);max-height:36rem;overflow:auto}.review-run-table{border-collapse:separate;border-spacing:0;width:100%;min-width:44rem;font-size:.8125rem}.review-run-table thead th{background:var(--muted);color:var(--muted-foreground);text-align:left;z-index:2;font-size:.75rem;position:sticky;top:0}.review-run-table th,.review-run-table td{border-bottom:1px solid var(--border);text-align:left;padding:.6875rem .75rem}.review-run-group-heading th{background:var(--surface-2);padding-block:.5rem}.review-run-group-line{flex-wrap:wrap;align-items:center;column-gap:.625rem;display:flex}.review-run-group-line>span:first-child{font-size:.9375rem}.review-run-group-heading small{color:var(--muted-foreground);font-weight:500}.review-run-group-heading .review-run-partial,.review-run-group-heading .review-rate-limit-error,.review-run-group-heading .review-run-error-copy{flex-basis:100%}.review-run-error-copy{color:var(--danger)!important}.review-run-device{font-family:var(--font-mono);font-size:.75rem}.review-partial-summary,.review-run-error{background:var(--danger-surface);border:1px solid var(--danger-surface-strong);margin-top:1rem;padding:1rem}.review-partial-summary h2{margin:0;font-size:1rem}.review-partial-summary p{margin:.375rem 0 .75rem;font-size:.8125rem;line-height:1.5}.review-run-loading{color:var(--muted-foreground);padding:2rem}@media (width<=760px){.review-plan-grid{grid-template-columns:1fr}.review-tenant-summary{grid-template-columns:minmax(0,1fr) auto;gap:.25rem .75rem}.review-tenant-summary>span:last-child{grid-column:1/-1}.review-run-totals{grid-template-columns:repeat(2,1fr)}.review-run-totals>div+div{border-left:0}.review-run-totals>div{border-bottom:1px solid var(--border)}}.workbench-status:empty{display:none}.workbench-status{color:var(--primary);max-width:72ch;margin-bottom:1rem;font-size:.875rem;line-height:1.45}@keyframes spin{to{transform:rotate(360deg)}}@keyframes write-result-arrival{0%{opacity:.55;transform:translate(-.25rem)}}.tenant-entry-groups{margin-top:1rem;display:grid}.tenant-import-panel,.tenant-table-panel{background:var(--surface);border:1px solid var(--border)}.tenant-import-panel{padding:1rem}.tenant-entry-heading,.tenant-table-toolbar,.tenant-row-controls,.tenant-import-actions{flex-wrap:wrap;align-items:center;gap:.75rem;display:flex}.tenant-entry-heading,.tenant-table-toolbar{justify-content:space-between}.tenant-entry-heading>div:first-child,.tenant-table-toolbar>div:first-child{min-width:min(100%,18rem)}.tenant-entry-heading h2,.tenant-table-toolbar h2{margin:0;font-size:1rem;font-weight:650}.tenant-entry-heading p,.tenant-table-toolbar p{color:var(--muted-foreground);margin:.25rem 0 0;font-size:.8125rem;line-height:1.5}.tenant-import-actions{justify-content:flex-end}.tenant-import-guidance{color:var(--muted-foreground);max-width:68ch;margin-top:.5rem;font-size:.8125rem}.tenant-import-guidance summary{color:var(--foreground);cursor:pointer;width:fit-content;min-height:44px;padding-block:.6875rem;font-weight:650}.tenant-import-guidance p{margin:0 0 .5rem 1.25rem;line-height:1.5}.tenant-entry-feedback{gap:.5rem;min-height:4rem;display:grid}.tenant-entry-feedback>.tenant-import-summary,.tenant-entry-feedback>.tenant-error-summary{padding-block:.5rem}.tenant-row-controls{flex:none;gap:.5rem}.tenant-row-count{background:var(--muted);border-radius:var(--radius-pill);color:var(--muted-foreground);font-family:var(--font-mono);white-space:nowrap;padding:.3rem .625rem;font-size:.75rem;font-weight:650}.existing-tenant-picker{background:var(--surface);border:1px solid var(--border);gap:.75rem;margin-top:1rem;padding:.875rem;display:grid}.existing-tenant-picker-list{border:1px solid var(--border);max-height:20rem;margin:0;padding:0;list-style:none;overflow-y:auto}.existing-tenant-picker-list li+li{border-top:1px solid var(--border)}.existing-tenant-picker-list label{cursor:pointer;grid-template-columns:auto minmax(0,1fr) auto;align-items:center;gap:.75rem;min-height:52px;padding:.5rem .75rem;display:grid}.existing-tenant-picker-list label:hover{background:var(--muted)}.existing-tenant-picker-list label.is-added{color:var(--muted-foreground);cursor:default}.existing-tenant-picker-list label>span:not(.existing-tenant-picker-state),.existing-tenant-identity{gap:.15rem;min-width:0;display:grid}.existing-tenant-identity:focus{outline:.125rem solid var(--focus);outline-offset:2px}.existing-tenant-picker-list strong,.existing-tenant-identity strong{overflow-wrap:anywhere}.existing-tenant-picker-list label>span>span,.existing-tenant-identity span{color:var(--muted-foreground);overflow-wrap:anywhere;font-size:.75rem}.existing-tenant-picker-state{color:var(--muted-foreground);font-size:.75rem;font-weight:650}.setup-tenant-picker{background:var(--surface);border:1px solid var(--border);gap:0;max-width:64rem;margin-top:1rem;display:grid}.setup-section-heading{justify-content:space-between;align-items:start;gap:1rem;padding:1rem 1.25rem .875rem;display:flex}.setup-section-heading h2{margin:0}.setup-section-heading p,.service-check-heading p{color:var(--muted-foreground);max-width:65ch;margin:.3rem 0 0;font-size:.8125rem;line-height:1.5}.setup-section-heading>strong{background:var(--muted);border-radius:var(--radius-pill);white-space:nowrap;padding:.35rem .65rem;font-size:.75rem}.setup-tenant-picker .tenant-focus-search{margin:0 1.25rem .75rem;display:block;position:relative}.setup-tenant-picker .tenant-search-label{margin-bottom:.35rem;display:block}.setup-tenant-picker .tenant-focus-search svg{color:var(--muted-foreground);pointer-events:none;position:absolute;bottom:.82rem;left:.75rem}.setup-tenant-picker .tenant-focus-search input{padding-left:2.25rem}.selected-tenant-chips{flex-wrap:wrap;align-items:center;gap:.35rem;min-height:2.5rem;padding:0 1.25rem .75rem;display:flex}.selected-tenant-chips>span{background:var(--primary-surface);border:1px solid var(--primary-surface-strong);border-radius:var(--radius-pill);color:var(--foreground);padding:.25rem .55rem;font-size:.75rem;font-weight:600}.selected-tenant-chips>.selected-tenant-empty{color:var(--muted-foreground);background:0 0;border:0;padding-inline:0;font-weight:400}.tenant-bulk-actions{background:var(--muted);border-block:1px solid var(--border);flex-wrap:wrap;align-items:center;gap:.4rem;padding:.45rem .75rem;display:flex}.tenant-bulk-actions>span{color:var(--muted-foreground);margin-right:auto;font-size:.75rem}.tenant-bulk-actions .ghost-button{min-height:44px}.setup-tenant-picker .existing-tenant-picker-list{border:0;max-height:20rem}.existing-tenant-picker-list label.is-selected{background:var(--primary-surface);box-shadow:inset 0 0 0 1px var(--primary)}.tenant-render-note,.tenant-empty-state{color:var(--muted-foreground);margin:0;padding:.65rem 1.25rem;font-size:.75rem;line-height:1.5}.tenant-pagination{border-top:1px solid var(--border);color:var(--muted-foreground);flex-wrap:wrap;align-items:center;gap:.25rem;padding:.4rem .75rem;font-size:.75rem;display:flex}.tenant-pagination>span:first-child{margin-right:auto}.tenant-pagination .ghost-button{min-height:44px}.tenant-empty-state{padding-block:1rem;font-size:.8125rem}.tenant-empty-state strong{color:var(--foreground);display:block}.provisioned-service-check{border-top:1px solid var(--border);padding:1.25rem}.service-check-heading{justify-content:space-between;align-items:start;gap:1rem;display:flex}.service-check-heading h2{margin:0}.service-check-status{background:var(--muted);border-radius:var(--radius-pill);color:var(--muted-foreground);font-family:var(--font-mono);white-space:nowrap;align-items:center;gap:.35rem;min-height:28px;padding:.25rem .6rem;font-size:.75rem;font-weight:650;display:inline-flex}.service-check-status.is-checking{color:var(--warning)}.service-check-status.is-ready{color:var(--success-on-surface)}.service-results-space{min-height:68px}.service-results{border:1px solid var(--border);margin:1rem 0 0;padding:0;list-style:none}.service-result{grid-template-columns:minmax(12rem,.85fr) minmax(14rem,1.15fr);align-items:center;gap:1rem;min-height:68px;padding:.75rem;display:grid}.service-result+.service-result{border-top:1px solid var(--border)}.service-result.is-blocked{background:var(--danger-surface)}.service-result>span{gap:.2rem;min-width:0;display:grid}.service-result strong{overflow-wrap:anywhere;align-items:center;gap:.35rem;font-size:.8125rem;display:flex}.service-result small{color:var(--muted-foreground);font-size:.75rem;line-height:1.4}.service-result.is-blocked>span:last-child strong{color:var(--danger)}.service-result .workflow-field{margin:0}.service-result select{background:var(--surface);border:1px solid var(--border);border-radius:var(--radius);color:var(--foreground);width:100%;min-height:44px;padding:.5rem .65rem}.tenant-continue-button{justify-content:center;align-items:center;gap:.5rem;min-inline-size:14.5rem;display:inline-flex}.spin{animation:spin var(--motion-duration-check) var(--motion-ease-linear) infinite}.tenant-empty-row{color:var(--muted-foreground);text-align:center;padding:1rem .75rem;font-size:.8125rem}.tenant-table-toolbar,.tenant-table-footer,.tenant-import-summary,.tenant-error-summary{flex-wrap:wrap;align-items:center;gap:.625rem;padding:.75rem 1rem;display:flex}.tenant-table-toolbar{border-bottom:1px solid var(--border);padding-block:1rem}.tenant-table-footer{border-top:1px solid var(--border);color:var(--muted-foreground);font-size:.8125rem}.tenant-toolbar-spacer{flex:1}.tenant-csv-button,.file-button{cursor:pointer;min-height:44px;padding-block:.375rem}.tenant-csv-button:focus-within,.file-button:focus-within{outline:.125rem solid var(--focus);outline-offset:.125rem}.tenant-import-summary{background:var(--primary-surface);border:1px solid var(--primary-surface-strong);font-size:.8125rem}.tenant-import-summary p{flex:1;min-width:16rem;margin:0}.tenant-error-summary{background:var(--danger-surface);border:1px solid var(--danger-surface-strong);color:var(--danger);align-items:center;font-size:.8125rem}.tenant-error-summary p{margin:0}.tenant-error-jump,.ghost-button{border-radius:var(--radius);color:var(--primary);background:0 0;border:1px solid #0000;min-height:36px;padding:.375rem .625rem;font-size:.8125rem;font-weight:650}.tenant-error-jump{color:var(--danger);text-underline-offset:.15rem;min-height:44px;padding-block:0;text-decoration:underline}.ghost-button:hover,.tenant-error-jump:hover{background:var(--muted)}.tenant-table-scroller{max-height:29rem;overflow:auto}.tenant-table{border-collapse:separate;border-spacing:0;width:100%;min-width:42rem;font-size:.8125rem}.tenant-table th{background:var(--muted);border-bottom:1px solid var(--border);color:var(--muted-foreground);text-align:left;z-index:2;padding:.5rem .625rem;font-size:.75rem;font-weight:650;position:sticky;top:0}.tenant-table td{border-bottom:1px solid var(--border);vertical-align:top;padding:.375rem .625rem}.tenant-table tbody:last-child tr:last-child td{border-bottom:0}.tenant-row-has-error>td{background:var(--danger-surface)}.tenant-status-column{text-align:center;width:2.25rem}.tenant-row-status{color:var(--muted-foreground);justify-content:center;width:1.25rem;padding-top:.75rem;font-size:1rem;line-height:1;display:inline-flex}.tenant-row-status-error{color:var(--danger);font-weight:800}.tenant-cell-input{background:var(--surface);border:1px solid var(--border);border-radius:var(--radius);color:var(--foreground);width:100%;min-height:44px;padding:.5rem}.tenant-table-field{max-width:none;font-weight:400;display:table-cell}.tenant-table .form-error{margin-top:.25rem;font-size:.75rem;display:block}.tenant-origin{background:var(--muted);border-radius:var(--radius-pill);color:var(--muted-foreground);font-family:var(--font-mono);margin-top:.25rem;padding:.0625rem .375rem;font-size:.6875rem;display:inline-block}.tenant-instance-heading{justify-content:space-between;align-items:center;gap:.5rem;display:flex}.tenant-apply-all{min-height:44px;padding:.125rem .375rem;font-size:.6875rem}.tenant-service-loading{align-items:center;min-height:44px;display:flex}.tenant-service-skeleton{width:min(100%,12rem);height:.875rem;display:block}.tenant-service-resolved{align-content:center;min-height:44px;line-height:1.35;display:grid}.tenant-service-resolved span,.tenant-service-unavailable{color:var(--muted-foreground);font-size:.75rem}.tenant-service-unavailable{align-items:center;min-height:44px;display:flex}.tenant-row-actions{text-align:right;white-space:nowrap;width:6rem}.tenant-icon-button{border-radius:var(--radius);color:var(--muted-foreground);background:0 0;border:1px solid #0000;width:44px;height:44px;padding:.375rem;font-size:1.25rem;line-height:1}.tenant-icon-button:hover{background:var(--muted);color:var(--foreground)}.tenant-icon-button[aria-expanded=true]{color:var(--primary)}.tenant-icon-button[aria-expanded=true] .tenant-disclosure-chevron{display:inline-block;transform:rotate(90deg)}.tenant-remove-button:hover{color:var(--danger)}.tenant-detail-row>td{background:var(--muted);padding:.875rem 1rem 1rem 3rem}.tenant-detail-note{color:var(--muted-foreground);margin:0 0 .75rem;font-size:.75rem}.tenant-detail-grid{grid-template-columns:repeat(3,minmax(0,1fr));gap:.75rem 1rem;max-width:58rem;display:grid}.tenant-detail-grid .workflow-field{margin:0}.tenant-detail-grid label{color:var(--muted-foreground);font-size:.75rem}.tenant-add-button{min-height:44px;font-size:.875rem}@media (prefers-reduced-motion:no-preference){.tenant-disclosure-chevron{transition:transform var(--motion-duration-feedback) var(--motion-ease-standard)}}@media (width<=760px){.tenant-entry-heading,.tenant-table-toolbar{flex-direction:column;align-items:stretch}.tenant-import-actions,.tenant-row-controls{justify-content:space-between}.tenant-detail-grid{grid-template-columns:1fr}.tenant-detail-row>td{padding-left:1rem}}.tenant-focus-search{gap:.35rem;font-size:.8125rem;font-weight:650;display:grid}.tenant-focus-search input{background:var(--surface);border:1px solid var(--border);border-radius:var(--radius);color:var(--foreground);width:100%;min-width:0;min-height:44px;padding:.625rem .75rem}.workbench-capacity-errors{margin-bottom:0}.login-field{gap:.4rem;display:grid}.app-header.app-top-nav{background:var(--canvas);border-color:var(--hairline);flex-wrap:nowrap;height:56px;min-height:56px;padding-block:0}.app-top-nav .header-actions{flex-wrap:nowrap;width:auto;margin-left:auto}.header-product{align-items:center;gap:.625rem;min-width:0;display:flex}.session-badge{border:1px solid var(--hairline);border-radius:var(--radius-pill);color:var(--ink-subtle);font-size:var(--text-caption);text-overflow:ellipsis;white-space:nowrap;max-width:min(34vw,28rem);padding:.3125rem .5rem;line-height:1.2;overflow:hidden}.session-badge.is-live{background:var(--surface-2)}.session-badge.is-demo{background:var(--primary-surface);border-color:var(--primary-surface-strong);color:var(--primary-hover)}.toolbar-home,.toolbar-sign-out{gap:.375rem}.theme-toggle{border:1px solid var(--hairline);border-radius:var(--radius-md);color:var(--ink-subtle);min-width:44px;height:44px;transition:background-color var(--motion-duration-feedback) var(--motion-ease-standard), border-color var(--motion-duration-feedback) var(--motion-ease-standard), color var(--motion-duration-feedback) var(--motion-ease-standard);background:0 0;justify-content:center;align-items:center;padding:0;display:inline-flex}.theme-toggle:hover{background:var(--surface-2);border-color:var(--hairline-strong);color:var(--ink)}@media (prefers-reduced-motion:reduce){.skeleton,.spin{animation:none}.theme-toggle,.simple-journey-button{transition:none}}.login-panel,.hub-main,.loading-panel,.placeholder-page section{background:var(--surface-1);border:1px solid var(--hairline);border-radius:var(--radius-lg);box-shadow:none}.login-panel{max-width:32rem}.login-eyebrow,.login-panel .demo-divider{color:var(--ink-subtle);font-size:var(--text-eyebrow);letter-spacing:var(--tracking-eyebrow);text-transform:uppercase;font-weight:500;line-height:1.3}.login-eyebrow strong{color:var(--primary-base)}.login-form input{background:var(--surface-1);border-color:var(--hairline)}.login-form input:hover{border-color:var(--hairline-strong)}.login-form input:focus-visible{border-color:var(--primary-focus);outline-color:var(--primary-focus)}.login-panel .primary-button{background:var(--primary-base);border-color:var(--primary-base);color:var(--primary-on-solid)}.login-panel .primary-button:hover:not(:disabled){background:var(--primary-hover);border-color:var(--primary-hover)}.hub-main{width:calc(100% - 2rem);max-width:52rem;margin:clamp(1.5rem,4vw,3rem) auto;padding:clamp(1.5rem,4vw,3rem)}.loading-page{justify-items:center;width:100%;max-width:none}.loading-panel{gap:.75rem;width:100%;max-width:50rem;padding:clamp(1.5rem,4vw,2.5rem);display:grid}.loading-panel .skeleton,.placeholder-page .skeleton{background:var(--muted)}@media (width<=640px){.app-header.app-top-nav{flex-wrap:wrap;align-items:flex-start;height:auto;min-height:56px;padding-block:.5rem;padding-inline:1rem}.header-product{min-height:44px}.session-badge{max-width:10rem}.app-top-nav .header-actions{flex-wrap:wrap;justify-content:flex-end;width:auto;margin-left:0}.hub-main{margin-block:1rem}}.csv-import-panel{background:var(--surface-1);border:1px solid var(--hairline);border-radius:var(--radius-lg);min-width:0;transition:background-color var(--motion-duration-feedback) var(--motion-ease-standard), border-color var(--motion-duration-feedback) var(--motion-ease-standard);overflow:hidden}.csv-import-panel.is-dragging{background:var(--primary-surface);border-color:var(--primary-base)}.csv-import-panel.is-disabled{opacity:.68}.csv-import-heading,.csv-import-summary{justify-content:space-between;align-items:center;gap:1rem;padding:.875rem 1rem;display:flex}.csv-import-title{align-items:flex-start;gap:.625rem;min-width:0;display:flex}.csv-import-title>svg{color:var(--primary-base);flex:none;margin-top:.125rem}.csv-import-title h2{font-size:var(--text-body);letter-spacing:var(--tracking-body);margin:0 0 .125rem;font-weight:600}.csv-import-title p{color:var(--ink-subtle);font-size:var(--text-caption);line-height:var(--leading-caption);margin:0}.csv-import-template{white-space:nowrap;flex:none;align-items:center;gap:.375rem;display:inline-flex}.csv-import-dropzone{background:var(--surface-2);border:1px dashed var(--hairline-strong);border-radius:var(--radius-md);color:var(--ink-subtle);cursor:pointer;font-size:var(--text-body-sm);align-items:center;gap:.625rem;min-height:48px;margin:0 1rem 1rem;padding:.625rem .75rem;display:flex}.csv-import-dropzone:hover{background:var(--surface-3);border-color:var(--primary-base)}.csv-import-dropzone:focus-within{outline:.125rem solid var(--primary-focus);outline-offset:.125rem}.csv-import-dropzone[aria-disabled=true]{cursor:not-allowed}.csv-import-dropzone>svg{color:var(--primary-base);flex:none}.csv-import-dropzone strong{color:var(--ink);font-weight:600}.csv-import-result{border-top:1px solid var(--hairline)}.csv-import-summary{background:var(--surface-2);border-bottom:1px solid var(--hairline);padding-block:.625rem}.csv-import-summary p{font-size:var(--text-body-sm);flex-wrap:wrap;align-items:baseline;gap:.25rem .625rem;min-width:0;margin:0;display:flex}.csv-import-summary p strong{text-overflow:ellipsis;white-space:nowrap;max-width:28rem;overflow:hidden}.csv-import-summary p span{color:var(--ink-subtle);font-size:var(--text-caption)}.csv-import-summary .ghost-button{flex:none;align-items:center;gap:.3125rem;display:inline-flex}.csv-import-global-errors{background:var(--danger-surface);border:1px solid var(--danger-surface-strong);color:var(--danger-base);font-size:var(--text-body-sm);margin:.75rem 1rem;padding:.625rem .75rem;list-style:none}.csv-import-global-errors li+li{margin-top:.375rem}.csv-import-grid-wrap{max-height:28rem;overflow:auto}.csv-import-grid{border-collapse:separate;border-spacing:0;font-size:var(--text-body-sm);width:100%;min-width:max-content}.csv-import-grid th,.csv-import-grid td{border-bottom:1px solid var(--hairline);text-align:left;padding:.375rem .5rem}.csv-import-grid thead th{background:var(--surface-2);color:var(--ink-subtle);font-size:var(--text-caption);letter-spacing:var(--tracking-eyebrow);text-transform:uppercase;z-index:2;font-weight:600;position:sticky;top:0}.csv-import-grid thead th:first-child,.csv-import-grid tbody>tr:not(.csv-import-error-row)>th{color:var(--ink-tertiary);font-family:var(--font-mono);font-size:var(--text-caption);text-align:right;width:3.25rem}.csv-import-grid tbody>tr:not(.csv-import-error-row):hover>*{background:var(--surface-2)}.csv-import-grid tbody>tr.has-errors>*{background:var(--danger-surface)}.csv-import-grid input{border-radius:var(--radius-sm);color:var(--ink);background:0 0;border:1px solid #0000;width:100%;min-width:9rem;min-height:36px;padding:.375rem .5rem}.csv-import-grid input:hover:not(:disabled){border-color:var(--hairline-strong)}.csv-import-grid input:focus-visible{background:var(--surface-1);border-color:var(--primary-focus)}.csv-import-error-row td{background:var(--danger-surface);color:var(--danger-base);padding:0 .75rem .625rem 3.75rem}.csv-import-error-row ul{font-size:var(--text-caption);line-height:var(--leading-caption);margin:0;padding:0;list-style:none}.csv-import-error-row li+li{margin-top:.25rem}.csv-import-preview{max-width:90rem;margin:0 auto;padding:2rem}.csv-import-preview>h1{font-size:var(--text-headline);margin-bottom:.5rem}.csv-import-preview>p{color:var(--ink-subtle);margin-bottom:1.5rem}.csv-import-preview-grid{grid-template-columns:repeat(2,minmax(0,1fr));gap:1rem;display:grid}@media (width<=820px){.csv-import-preview-grid{grid-template-columns:1fr}}@media (width<=560px){.csv-import-heading{align-items:flex-start}.csv-import-template{flex:0 auto}.csv-import-dropzone{align-items:flex-start}}@media (prefers-reduced-motion:reduce){.csv-import-panel{transition:none}}.review-heading,.review-run-heading{background:var(--surface-1);border-color:var(--hairline);border-radius:var(--radius-xl) var(--radius-xl) 0 0;box-shadow:inset 0 1px 0 var(--hairline-strong);padding:1.5rem}.review-heading h1,.review-run-heading h1{color:var(--ink);font-size:var(--text-headline);font-weight:var(--weight-display);letter-spacing:var(--tracking-headline);line-height:var(--leading-headline)}.review-heading>p:last-child,.review-run-heading>p:last-child{color:var(--ink-subtle)}.review-run-totals{background:var(--surface-1);border-color:var(--hairline)}.review-run-totals>div{padding:1rem 1.25rem}.review-run-totals>div+div{border-color:var(--hairline)}.review-run-totals dt{color:var(--ink-subtle);font-size:var(--text-caption);letter-spacing:var(--tracking-eyebrow);text-transform:uppercase;font-weight:600}.review-run-totals dd{color:var(--ink);font-variant-numeric:tabular-nums}.review-run-totals .is-failed dd{color:var(--danger-base)}.review-plan-grid{gap:.75rem}.review-capacity{background:var(--surface-1);border-color:var(--hairline);border-radius:var(--radius-lg);box-shadow:inset 0 1px 0 var(--hairline-strong);padding:1.25rem}.review-capacity h2,.review-partial-summary h2{color:var(--ink);font-size:var(--text-body);letter-spacing:var(--tracking-body);font-weight:600}.review-capacity>p,.review-partial-summary p{color:var(--ink-subtle);font-size:var(--text-body-sm)}.review-capacity li{background:var(--surface-2);color:var(--ink);padding:.6875rem .75rem}.review-capacity li+li{border-color:var(--hairline)}.review-capacity li>span:last-child{color:var(--ink-subtle)}.review-mode-note{font-weight:650}.review-mode-note.is-demo{color:var(--primary-hover)}.review-mode-note.is-live{color:var(--danger-base)}.review-tenant-list{background:var(--surface-1);border-color:var(--hairline);border-radius:var(--radius-xl);box-shadow:inset 0 1px 0 var(--hairline-strong);overflow:hidden}.new-tenant-confirm-list{margin:1rem 0;padding:1.25rem}.new-tenant-confirm-list h2{font-size:var(--text-body);margin:0 0 .75rem}.new-tenant-confirm-list ul{margin:0;padding:0;list-style:none}.new-tenant-confirm-list li{border-top:1px solid var(--hairline);justify-content:space-between;align-items:baseline;gap:1rem;padding:.75rem 0;display:flex}.new-tenant-confirm-list li span{color:var(--ink-subtle);font-size:var(--text-body-sm)}.review-tenant+.review-tenant{border-color:var(--hairline)}.review-tenant-summary{color:var(--ink);padding:.75rem 1rem}.review-tenant-summary small{color:var(--ink-subtle)}.review-tenant.is-blocked .review-tenant-summary{background:var(--danger-surface)}.review-tenant.is-blocked .review-tenant-summary>span:last-child{color:var(--danger-base)}.review-tenant-state,.review-run-group-status{background:var(--surface-2);border:1px solid var(--hairline-strong);border-radius:var(--radius-pill);color:var(--ink-subtle);align-items:center;min-height:1.375rem;padding:.125rem .5rem;font-size:.6875rem;font-weight:650;line-height:1.35;display:inline-flex}.review-tenant-state.is-pending,.review-run-group-status.is-pending{background:var(--surface-2);border-color:var(--hairline-strong);color:var(--ink-subtle)}.review-tenant-state.is-blocked,.review-run-group-status.is-failed,.review-run-group-status.is-blocked{background:var(--danger-surface);border-color:var(--danger-surface-strong);color:var(--danger-base)}.review-run-group-status.is-succeeded{background:var(--success-surface);border-color:var(--success);color:var(--success-on-surface)}.review-run-group-status.is-running{background:var(--primary-surface);border-color:var(--primary-surface-strong);color:var(--primary-hover)}.review-subscription-exception{color:var(--warning-on-surface)!important}.review-run-totals{border-top:0;margin-bottom:.75rem}.review-run-totals dd{font-size:var(--text-headline);font-weight:var(--weight-display)}.review-run-table-wrap{background:var(--surface-1);border-color:var(--hairline);border-radius:var(--radius-xl);box-shadow:inset 0 1px 0 var(--hairline-strong)}.review-run-table{color:var(--ink);font-size:var(--text-body-sm)}.review-run-table thead th{background:var(--surface-2);box-shadow:0 1px 0 var(--hairline-strong), 0 .75rem 1.25rem -1.25rem var(--shadow-color);color:var(--ink-subtle);font-size:var(--text-caption);letter-spacing:var(--tracking-eyebrow);font-weight:600}.review-run-table th,.review-run-table td{border-color:var(--hairline)}.review-run-table tbody tr:hover>th,.review-run-table tbody tr:hover>td{background:var(--surface-2)}.review-run-group-heading th{background:var(--surface-2);color:var(--ink)}.review-run-group.is-failed .review-run-group-heading th{background:var(--danger-surface)}.review-run-group.is-blocked{color:var(--ink-subtle)}.review-run-group.is-blocked .review-run-group-heading th{background:var(--surface-2)}.review-run-group-heading small{color:var(--ink-subtle)}.review-run-group-heading .review-run-group-status{margin-right:.75rem}.review-run-partial,.review-run-error-copy{color:var(--danger-base)!important}.review-run-partial{background:var(--danger-surface);border:1px solid var(--danger-surface-strong);border-radius:var(--radius-pill);margin-top:.375rem;padding:.125rem .5rem;display:inline-flex}.review-run-device{color:var(--ink-muted);font-family:var(--font-mono);font-variant-ligatures:none}.tenant-creation-totals{grid-template-columns:repeat(4,1fr)}.tenant-creation-progress{background:var(--surface-1);border:1px solid var(--hairline);border-radius:var(--radius-xl);box-shadow:inset 0 1px 0 var(--hairline-strong);margin:0;padding:0;list-style:none;overflow:hidden}.tenant-creation-group+.tenant-creation-group{border-top:1px solid var(--hairline)}.tenant-creation-group-heading{flex-wrap:wrap;justify-content:space-between;align-items:center;gap:1rem;padding:1rem 1.25rem;display:flex}.tenant-creation-group-heading strong{color:var(--ink);font-size:var(--text-body);font-weight:650}.tenant-creation-group-heading .review-run-error-copy{flex-basis:100%;margin:0}.tenant-creation-group.is-failed{background:var(--danger-surface);box-shadow:inset 0 0 0 1px var(--danger-surface-strong)}.tenant-creation-steps{border-top:1px solid var(--hairline);grid-template-columns:repeat(2,minmax(0,1fr));margin:0;padding:0;list-style:none;display:grid}.tenant-creation-steps>li{justify-content:space-between;align-items:center;gap:1rem;min-width:0;padding:.75rem 1.25rem;display:flex}.tenant-creation-steps>li+li{border-left:1px solid var(--hairline)}.tenant-creation-steps>li>span:first-child{color:var(--ink-muted);font-size:var(--text-body-sm);font-weight:600}.tenant-creation-step-failure,.tenant-creation-step-queued{flex-direction:column;align-items:flex-end;gap:.5rem;min-width:0;display:flex}.tenant-creation-step-failure small,.tenant-creation-step-queued small{color:var(--ink-subtle);line-height:var(--leading-body);text-align:right;max-width:34rem}.review-partial-actions{flex-wrap:wrap;gap:.5rem;margin-top:.75rem;display:flex}.review-partial-summary,.review-run-error{background:var(--danger-surface);border-color:var(--danger-surface-strong);border-radius:var(--radius-lg);padding:1.25rem}.review-partial-summary .secondary-button{background:var(--surface-1);border-color:var(--danger-surface-strong);color:var(--ink)}.review-partial-summary .secondary-button:hover:not(:disabled){background:var(--surface-2)}.review-run-loading{color:var(--ink-subtle)}.review-run-title-line{flex-wrap:wrap;align-items:center;gap:.625rem;display:flex}.review-run-title-line h1{margin:0}.run-lineage code{color:var(--ink);font-family:var(--font-mono)}@media (width<=760px){.review-run-totals>div+div{border-color:var(--hairline)}.tenant-creation-totals{grid-template-columns:repeat(2,1fr)}.tenant-creation-group-heading,.tenant-creation-steps>li{flex-direction:column;align-items:flex-start}.tenant-creation-steps{grid-template-columns:1fr}.tenant-creation-steps>li+li{border-left:0;border-top:1px solid var(--hairline)}.tenant-creation-step-failure,.tenant-creation-step-queued{align-items:flex-start}.tenant-creation-step-failure small,.tenant-creation-step-queued small{text-align:left}}.review-rate-limit-error{color:var(--danger);flex-basis:100%;max-width:46rem;margin-top:.5rem;display:block}.review-rate-limit-error strong,.review-rate-limit-error small{line-height:var(--leading-body);display:block}.review-rate-limit-error strong{font-size:var(--text-body-sm)}.review-rate-limit-error small{color:var(--ink-muted);margin-top:.25rem}.app-shell>.review-run.run-surface{max-width:90rem}.simple-journey-main:has(.run-surface){max-width:90rem}.run-surface{grid-template-columns:minmax(0,1fr) 17rem;grid-template-areas:"table rail";align-items:start;gap:1rem;display:grid}.run-surface-main{grid-area:table;min-width:0}.run-summary-rail{background:var(--surface-1);border:1px solid var(--hairline);border-radius:var(--radius-xl);box-shadow:inset 0 1px 0 var(--hairline-strong);grid-area:rail;position:sticky;top:1rem;overflow:hidden}.run-summary-head{border-bottom:1px solid var(--hairline);padding:1rem}.run-summary-status-line{flex-wrap:wrap;align-items:center;gap:.5rem;margin-bottom:.875rem;display:flex}.run-status-badge{background:var(--primary-surface);border:1px solid var(--primary-surface-strong);border-radius:var(--radius-pill);color:var(--primary-hover);font-size:var(--text-caption);font-weight:650;line-height:var(--leading-caption);align-items:center;min-height:1.625rem;margin:0;padding:.1875rem .625rem;display:inline-flex}.run-status-badge.is-succeeded{background:var(--success-surface);border-color:var(--success);color:var(--success-on-surface)}.run-status-badge.is-failed,.run-status-badge.is-completed_with_errors{background:var(--danger-surface);border-color:var(--danger-surface-strong);color:var(--danger-base)}.run-status-badge.is-stopped{background:var(--warning-surface);border-color:var(--warning-base);color:var(--warning-on-surface)}.run-summary-ring-row{align-items:center;gap:.875rem;display:flex}.run-summary-ring-row>div{min-width:0}.run-summary-ring-row strong{color:var(--ink);font-size:var(--text-body-sm)}.run-progress-ring{flex:0 0 4rem;width:4rem;height:4rem;overflow:visible}.run-progress-ring circle{fill:none;stroke-width:6px}.run-progress-ring-track{stroke:var(--hairline-strong)}.run-progress-ring-value{stroke:var(--primary-base);stroke-linecap:round;transform-origin:50%;transition:stroke-dashoffset var(--motion-duration-feedback) var(--motion-ease-standard);transform:rotate(-90deg)}.run-progress-ring text{fill:var(--ink);font-family:var(--font-mono);font-size:.75rem;font-weight:650}.run-summary-notes{flex-direction:column;gap:.25rem;margin-top:.1875rem;display:flex}.run-summary-notes small{color:var(--ink-subtle);font-size:.6875rem;line-height:1.4}.run-summary-notes span{color:var(--warning-on-surface);font-variant-numeric:tabular-nums;font-weight:650}.run-summary-stat-section+.run-summary-stat-section{border-top:1px solid var(--hairline)}.run-summary-stat-section h2{color:var(--ink-subtle);font-size:var(--text-caption);margin:0;padding:.75rem 1rem .125rem;font-weight:650}.run-summary-stats{margin:0}.run-summary-stats>div{border-bottom:1px solid var(--hairline);justify-content:space-between;align-items:baseline;gap:1rem;padding:.625rem 1rem;display:flex}.run-summary-stats dt{color:var(--ink-subtle);font-size:var(--text-caption)}.run-summary-stats dd{color:var(--ink);font-family:var(--font-mono);font-size:var(--text-body-sm);margin:0;font-weight:650}.run-summary-stats .is-danger dd{color:var(--danger-base)}.run-summary-details{border-bottom:1px solid var(--hairline)}.run-summary-details summary{color:var(--ink-subtle);cursor:pointer;font-size:var(--text-caption);min-height:2.75rem;padding:.8125rem 1rem;font-weight:600;list-style-position:inside}.run-summary-details summary:hover,.run-summary-details summary:focus-visible{background:var(--surface-2);color:var(--ink)}.run-summary-details summary:focus-visible{outline:2px solid var(--primary-focus);outline-offset:-2px}.run-summary-details>div{color:var(--ink-subtle);flex-direction:column;gap:.375rem;padding:0 1rem .875rem;font-size:.6875rem;display:flex}.run-summary-details code{color:var(--ink-muted);font-family:var(--font-mono);overflow-wrap:anywhere}.run-summary-details .run-lineage{font-size:inherit;margin:.25rem 0 0}.run-summary-action{flex-direction:column;gap:.5rem;padding:.875rem 1rem;display:flex}.run-summary-action>.primary-button,.run-summary-action>.secondary-button,.run-summary-action>.danger-button{justify-content:center;width:100%}.run-summary-action .form-error{font-size:var(--text-caption);margin:0 0 .625rem}.stop-run-note{color:var(--ink-subtle);font-size:var(--text-caption);margin:0;line-height:1.45}.run-progress-table-shell{background:var(--surface-1);border:1px solid var(--hairline);border-radius:var(--radius-xl);box-shadow:inset 0 1px 0 var(--hairline-strong);overflow:hidden}.run-table-caption{border-bottom:1px solid var(--hairline);color:var(--ink-subtle);font-size:var(--text-caption);flex-wrap:wrap;align-items:center;gap:.75rem;min-height:3rem;padding:.625rem .875rem;display:flex}.run-table-caption strong{color:var(--ink);font-weight:650}.run-slot-meter{gap:.1875rem;display:inline-flex}.run-slot-meter i{background:var(--hairline-strong);border-radius:2px;width:.875rem;height:.5rem}.run-slot-meter i.is-filled{background:var(--primary-base)}.run-progress-table-shell .review-run-table-wrap{box-shadow:none;border:0;border-radius:0;max-height:42rem;margin:0}.run-progress-table thead th{top:0}.tenant-creation-run .run-progress-table tr>:nth-child(n+3):nth-child(-n+5),.review-run:not(.tenant-creation-run) .run-progress-table tr>:nth-child(n+2):nth-child(-n+4){text-align:center}.run-cell-notes .review-run-error-copy{overflow-wrap:anywhere;max-width:22rem;display:block}.run-progress-table th,.run-progress-table td{vertical-align:middle}.run-progress-table tbody>tr:last-child>*{border-bottom:0}.run-progress-row.is-failed>*{background:var(--danger-surface)}.run-progress-row.is-blocked{color:var(--ink-subtle)}.run-progress-row>th,.review-run-device{color:var(--ink);font-family:var(--font-mono);font-size:var(--text-caption);font-weight:650}.run-step-chip{background:var(--surface-2);border:1px solid var(--hairline-strong);border-radius:var(--radius-pill);color:var(--ink-subtle);font-size:var(--text-caption);white-space:nowrap;align-items:center;gap:.3125rem;min-height:1.5rem;padding:.125rem .5rem;font-weight:650;line-height:1.35;display:inline-flex}.run-step-chip svg{width:.75rem;height:.75rem}.run-step-chip.is-success{background:var(--success-surface);border-color:var(--success);color:var(--success-on-surface)}.run-step-chip.is-danger{background:var(--danger-surface);border-color:var(--danger-surface-strong);color:var(--danger-base)}.run-step-chip.is-active{background:var(--primary-surface);border-color:var(--primary-surface-strong);color:var(--primary-hover)}.run-step-chip.is-active svg{animation:spin var(--motion-duration-write) var(--motion-ease-linear) infinite}.run-step-chip.is-wait{background:var(--warning-surface);border-color:var(--warning-base);color:var(--warning-on-surface)}.run-row-status{flex-direction:column;align-items:center;gap:.375rem;display:flex}.run-row-status .review-run-error-copy{max-width:28rem}.run-row-status .review-run-error-copy,.run-cell-notes .review-run-error-copy{font-size:.6875rem;line-height:1.4}.run-row-status>small,.run-row-status>.review-rate-limit-error{text-align:left;align-self:flex-start}.review-run-group-heading th{padding-block:.625rem}.review-run-group-heading th>.run-step-chip{margin-left:.75rem}@media (width<=960px){.run-surface{grid-template-columns:minmax(0,1fr);grid-template-areas:"rail""table"}.run-summary-rail{position:static}.run-summary-stats{grid-template-columns:repeat(4,minmax(0,1fr));display:grid}.run-summary-stats>div{border-right:1px solid var(--hairline);flex-direction:column;align-items:flex-start;gap:.25rem}.run-summary-stats>div:last-child{border-right:0}}@media (width<=560px){.run-summary-stats{grid-template-columns:repeat(2,minmax(0,1fr))}.run-summary-stats>div:nth-child(2){border-right:0}.run-summary-action>.primary-button,.run-summary-action>.secondary-button{min-height:2.75rem}}@media (prefers-reduced-motion:no-preference){.run-step-chip.is-success,.run-step-chip.is-danger{animation:write-result-arrival var(--motion-duration-feedback) var(--motion-ease-standard)}}@media (prefers-reduced-motion:reduce){.run-progress-ring-value{transition:none}.run-step-chip.is-active svg{animation:none}}.simple-app-header .product-name{margin-right:.25rem}.simple-app-header .header-actions{margin-left:auto}.simple-hub-main,.simple-journey-main{width:100%;max-width:64rem;margin:0 auto;padding:clamp(2rem,6vw,5rem) clamp(1rem,4vw,3rem)}.simple-hub-main h1,.tenants-page h1,.tenant-preflight h1,.tenant-run-status h1{margin-bottom:.625rem}.simple-journey-list{border-top:1px solid var(--border);margin-top:2rem;display:grid}.simple-journey-button{border:0;border-bottom:1px solid var(--border);color:var(--foreground);text-align:left;min-height:96px;transition:background var(--motion-duration-feedback) var(--motion-ease-standard), color var(--motion-duration-feedback) var(--motion-ease-standard);background:0 0;grid-template-columns:auto minmax(0,1fr) auto;align-items:center;gap:1rem;width:100%;padding:1rem .5rem;display:grid}.simple-journey-button:hover:not(:disabled){background:var(--surface-2)}.simple-journey-button:disabled{opacity:1}.simple-journey-button:disabled .journey-mark,.simple-journey-button:disabled strong,.simple-journey-button:disabled small{color:var(--ink-tertiary)}.simple-journey-button strong,.simple-journey-button small{display:block}.simple-journey-button strong{margin-bottom:.25rem;font-size:1rem}.simple-journey-button small{color:var(--muted-foreground);font-size:.8125rem;line-height:1.45}.simple-journey-button>svg{color:var(--primary)}.journey-coming{color:var(--ink-tertiary);white-space:nowrap;font-size:.75rem;font-weight:650}.journey-home-link{color:var(--primary-hover);background:0 0;border:0;align-items:center;gap:.4rem;min-height:44px;margin:-.75rem 0 1.5rem -.5rem;padding:.5rem;font-size:.875rem;font-weight:650;display:inline-flex}.journey-home-link:hover{background:var(--surface-2)}.journey-loading{gap:.75rem;padding-block:2rem;display:grid}.tenants-page,.tenant-preflight,.tenant-run-status{min-width:0}.tenant-page-lede{color:var(--muted-foreground);max-width:68ch;line-height:1.55}.tenant-page-note{color:var(--muted-foreground);font-size:.8125rem;line-height:1.5}.tenants-page>.csv-import-panel{margin:1.75rem 0 1rem}.tenant-editor-panel{background:var(--surface);border:1px solid var(--border);border-radius:var(--radius);margin-top:1rem;overflow:hidden}.tenant-editor-heading{border-bottom:1px solid var(--border);justify-content:space-between;align-items:center;gap:1rem;padding:.875rem 1rem;display:flex}.tenant-editor-heading h2{margin:0 0 .125rem;font-size:1rem}.bulk-subscription{flex-direction:column;gap:.25rem;min-width:18rem;font-size:.75rem;font-weight:600;display:flex}.tenant-editor-heading p{color:var(--muted-foreground);margin:0;font-size:.75rem}.tenant-editor-scroller{overflow-x:auto}.tenant-editor-table{border-collapse:collapse;width:100%;min-width:46rem}.tenant-editor-table th,.tenant-editor-table td{border-bottom:1px solid var(--border);text-align:left;vertical-align:top;padding:.625rem .75rem}.tenant-editor-table tbody tr:last-child td{border-bottom:0}.tenant-editor-table thead th{background:var(--surface-2);color:var(--muted-foreground);font-size:.75rem;font-weight:650}.tenant-editor-table thead th>span:first-child{display:block}.tenant-editor-table .inline-action{min-height:28px;margin-top:.2rem;padding:0;font-size:.6875rem}.tenant-editor-table th:first-child{width:33%}.tenant-editor-table th:nth-child(2){width:25%}.tenant-editor-table th:nth-child(3){width:35%}.tenant-editor-table th:last-child{width:92px}.tenant-row-actions{white-space:nowrap}.tenant-disclosure-chevron{transition:transform .15s ease-out;display:inline-block}@media (prefers-reduced-motion:reduce){.tenant-disclosure-chevron{transition:none}}.tenant-details-count{background:var(--primary-surface-strong);border-radius:var(--radius-pill);color:var(--primary-hover);vertical-align:middle;margin-left:.25rem;padding:0 .375rem;font-size:.6875rem;font-weight:650}.tenant-details-row td{background:var(--surface-2);box-shadow:inset 3px 0 0 var(--primary);padding:.75rem 1rem 1rem 2.25rem}.tenant-details-fields{border:0;grid-template-columns:repeat(auto-fill,minmax(14rem,1fr));gap:.75rem;margin:0;padding:0;display:grid}.tenant-details-fields legend{color:var(--muted-foreground);margin-bottom:.5rem;font-size:.75rem;font-weight:650}.tenant-details-field{gap:.25rem;display:grid}.tenant-details-field label{color:var(--muted-foreground);font-size:.75rem}.tenant-editor-table input,.tenant-editor-table select{background:var(--surface);border:1px solid var(--border);border-radius:var(--radius);color:var(--foreground);width:100%;min-height:44px;padding:.625rem .75rem}.tenant-editor-table select:disabled{background:var(--surface-2);color:var(--muted-foreground);opacity:1}.tenant-page-actions{flex-wrap:wrap;justify-content:flex-end;align-items:center;gap:.75rem;margin-top:1rem;display:flex}.tenant-page-actions>span{color:var(--muted-foreground);margin-right:auto;font-size:.8125rem}.tenant-preflight-list,.tenant-status-list{border-top:1px solid var(--border);margin:1.5rem 0 0;padding:0;list-style:none}.tenant-preflight-list li,.tenant-status-list li{border-bottom:1px solid var(--border);grid-template-columns:minmax(0,1fr) auto;align-items:center;gap:.25rem 1rem;min-height:72px;padding:.75rem .5rem;display:grid}.tenant-preflight-list li>small,.tenant-status-list li>small{color:var(--muted-foreground);grid-column:1/-1;font-size:.75rem}.tenant-preflight-list li>span,.tenant-status-list li>span{color:var(--primary-hover);text-transform:capitalize;font-size:.75rem;font-weight:650}@media (width<=640px){.simple-app-header{flex-wrap:wrap;align-items:flex-start}.simple-app-header .header-actions{width:100%}.simple-app-header .toolbar-sign-out{margin-left:auto}.simple-hub-main,.simple-journey-main{padding-block:2rem}.simple-hub-main h1,.tenants-page h1,.tenant-preflight h1,.tenant-run-status h1{font-size:2rem}.simple-journey-button{grid-template-columns:auto minmax(0,1fr)}.simple-journey-button>svg,.journey-coming{grid-column:2;justify-self:start}.tenant-editor-heading{align-items:flex-start}.tenant-page-actions>.primary-button,.tenant-page-actions>.commit-button{flex:auto}}.add-devices-page{min-width:0}.add-devices-page h1{margin-bottom:.625rem}.add-devices-page>.csv-import-panel{margin:1.75rem 0 1rem}.device-tenant-picker,.device-mapping-panel,.device-service-results{background:var(--surface);border:1px solid var(--border);border-radius:var(--radius);margin-top:1.5rem;overflow:hidden}.device-filter-field{color:var(--muted-foreground);gap:.375rem;padding:.75rem 1rem;font-size:.75rem;font-weight:650;display:grid}.device-filter-field input{background:var(--surface);border:1px solid var(--border);border-radius:var(--radius);color:var(--foreground);width:100%;min-height:44px;padding:.625rem .75rem}.device-tenant-picker .existing-tenant-picker-list{border:0;max-height:24rem}.device-empty-state{color:var(--muted-foreground);text-align:center;margin:0;padding:1rem}.device-service-results{padding:1rem}.device-service-results h2{margin:0 0 .75rem;font-size:1rem}.device-service-results ul{border-top:1px solid var(--border);margin:0;padding:0;list-style:none}.device-service-results li{border-bottom:1px solid var(--border);grid-template-columns:minmax(0,1fr) minmax(12rem,.8fr);align-items:center;gap:.75rem;min-height:64px;padding:.625rem 0;display:grid}.device-service-results li>span{gap:.125rem;display:grid}.device-service-results small{color:var(--muted-foreground);font-size:.75rem}.device-service-results select{background:var(--surface);border:1px solid var(--border);border-radius:var(--radius);color:var(--foreground);width:100%;min-height:44px;padding:.625rem .75rem}.device-mapping-scroller{max-height:34rem;overflow:auto}.device-mapping-table{border-collapse:collapse;width:100%;min-width:48rem}.device-mapping-table th,.device-mapping-table td{border-bottom:1px solid var(--border);text-align:left;vertical-align:middle;padding:.625rem .75rem}.device-mapping-table thead th{background:var(--surface-2);color:var(--muted-foreground);z-index:1;font-size:.75rem;position:sticky;top:0}.device-mapping-table tbody tr.is-selected>*{background:var(--primary-surface)}.device-mapping-table input[type=checkbox]{block-size:1.25rem;inline-size:1.25rem}.device-mapping-table select{background:var(--surface);border:1px solid var(--border);border-radius:var(--radius);color:var(--foreground);width:100%;min-width:12rem;min-height:40px;padding:.5rem .625rem}.device-mapping-table select:disabled{background:var(--surface-2);color:var(--muted-foreground);opacity:1}.device-mapping-status{white-space:nowrap;font-size:.75rem;font-weight:650}.device-mapping-status.is-ready{color:var(--success)}.device-mapping-status.is-danger{color:var(--danger)}.device-mapping-status.is-warning{color:var(--warning-base)}.add-devices-page>.workbench-capacity-errors{margin-top:1rem}@media (width<=640px){.add-devices-page h1{font-size:2rem}.device-service-results li{grid-template-columns:1fr;align-items:stretch}}.review-run-subscription{color:var(--ink-muted,var(--ink));font-family:var(--font-mono);font-size:var(--text-caption)} diff --git a/msp-onboarding/static/assets/index-CeYmaMoQ.js b/msp-onboarding/static/assets/index-CeYmaMoQ.js new file mode 100644 index 0000000..9b7b8c9 --- /dev/null +++ b/msp-onboarding/static/assets/index-CeYmaMoQ.js @@ -0,0 +1,10 @@ +const __vite__mapDeps=(i,m=__vite__mapDeps,d=(m.f||(m.f=["assets/TenantsPage-C1BNyjuJ.js","assets/RunSurface-DZb15gFL.js","assets/CsvImportPanel-B8vKd-O9.js","assets/AddDevicesPage-CA5mdvdK.js","assets/CsvImportPanel.preview-e06k2viG.js"])))=>i.map(i=>d[i]); +var e=Object.create,t=Object.defineProperty,n=Object.getOwnPropertyDescriptor,r=Object.getOwnPropertyNames,i=Object.getPrototypeOf,a=Object.prototype.hasOwnProperty,o=(e,t)=>()=>(t||(e((t={exports:{}}).exports,t),e=null),t.exports),s=(e,i,o,s)=>{if(i&&typeof i==`object`||typeof i==`function`)for(var c=r(i),l=0,u=c.length,d;li[e]).bind(null,d),enumerable:!(s=n(i,d))||s.enumerable});return e},c=(n,r,a)=>(a=n==null?{}:e(i(n)),s(r||!n||!n.__esModule?t(a,`default`,{value:n,enumerable:!0}):a,n));(function(){let e=document.createElement(`link`).relList;if(e&&e.supports&&e.supports(`modulepreload`))return;for(let e of document.querySelectorAll(`link[rel="modulepreload"]`))n(e);new MutationObserver(e=>{for(let t of e)if(t.type===`childList`)for(let e of t.addedNodes)e.tagName===`LINK`&&e.rel===`modulepreload`&&n(e)}).observe(document,{childList:!0,subtree:!0});function t(e){let t={};return e.integrity&&(t.integrity=e.integrity),e.referrerPolicy&&(t.referrerPolicy=e.referrerPolicy),e.crossOrigin===`use-credentials`?t.credentials=`include`:e.crossOrigin===`anonymous`?t.credentials=`omit`:t.credentials=`same-origin`,t}function n(e){if(e.ep)return;e.ep=!0;let n=t(e);fetch(e.href,n)}})();var l=o((e=>{var t=Symbol.for(`react.transitional.element`),n=Symbol.for(`react.portal`),r=Symbol.for(`react.fragment`),i=Symbol.for(`react.strict_mode`),a=Symbol.for(`react.profiler`),o=Symbol.for(`react.consumer`),s=Symbol.for(`react.context`),c=Symbol.for(`react.forward_ref`),l=Symbol.for(`react.suspense`),u=Symbol.for(`react.memo`),d=Symbol.for(`react.lazy`),f=Symbol.for(`react.activity`),p=Symbol.iterator;function m(e){return typeof e!=`object`||!e?null:(e=p&&e[p]||e[`@@iterator`],typeof e==`function`?e:null)}var h={isMounted:function(){return!1},enqueueForceUpdate:function(){},enqueueReplaceState:function(){},enqueueSetState:function(){}},g=Object.assign,_={};function v(e,t,n){this.props=e,this.context=t,this.refs=_,this.updater=n||h}v.prototype.isReactComponent={},v.prototype.setState=function(e,t){if(typeof e!=`object`&&typeof e!=`function`&&e!=null)throw Error(`takes an object of state variables to update or a function which returns an object of state variables.`);this.updater.enqueueSetState(this,e,t,`setState`)},v.prototype.forceUpdate=function(e){this.updater.enqueueForceUpdate(this,e,`forceUpdate`)};function y(){}y.prototype=v.prototype;function b(e,t,n){this.props=e,this.context=t,this.refs=_,this.updater=n||h}var x=b.prototype=new y;x.constructor=b,g(x,v.prototype),x.isPureReactComponent=!0;var ee=Array.isArray;function S(){}var C={H:null,A:null,T:null,S:null},te=Object.prototype.hasOwnProperty;function ne(e,n,r){var i=r.ref;return{$$typeof:t,type:e,key:n,ref:i===void 0?null:i,props:r}}function re(e,t){return ne(e.type,t,e.props)}function w(e){return typeof e==`object`&&!!e&&e.$$typeof===t}function ie(e){var t={"=":`=0`,":":`=2`};return`$`+e.replace(/[=:]/g,function(e){return t[e]})}var ae=/\/+/g;function oe(e,t){return typeof e==`object`&&e&&e.key!=null?ie(``+e.key):t.toString(36)}function se(e){switch(e.status){case`fulfilled`:return e.value;case`rejected`:throw e.reason;default:switch(typeof e.status==`string`?e.then(S,S):(e.status=`pending`,e.then(function(t){e.status===`pending`&&(e.status=`fulfilled`,e.value=t)},function(t){e.status===`pending`&&(e.status=`rejected`,e.reason=t)})),e.status){case`fulfilled`:return e.value;case`rejected`:throw e.reason}}throw e}function ce(e,r,i,a,o){var s=typeof e;(s===`undefined`||s===`boolean`)&&(e=null);var c=!1;if(e===null)c=!0;else switch(s){case`bigint`:case`string`:case`number`:c=!0;break;case`object`:switch(e.$$typeof){case t:case n:c=!0;break;case d:return c=e._init,ce(c(e._payload),r,i,a,o)}}if(c)return o=o(e),c=a===``?`.`+oe(e,0):a,ee(o)?(i=``,c!=null&&(i=c.replace(ae,`$&/`)+`/`),ce(o,r,i,``,function(e){return e})):o!=null&&(w(o)&&(o=re(o,i+(o.key==null||e&&e.key===o.key?``:(``+o.key).replace(ae,`$&/`)+`/`)+c)),r.push(o)),1;c=0;var l=a===``?`.`:a+`:`;if(ee(e))for(var u=0;u{t.exports=l()})),d=o((e=>{function t(e,t){var n=e.length;e.push(t);a:for(;0>>1,a=e[r];if(0>>1;ri(c,n))li(u,c)?(e[r]=u,e[l]=n,r=l):(e[r]=c,e[s]=n,r=s);else if(li(u,n))e[r]=u,e[l]=n,r=l;else break a}}return t}function i(e,t){var n=e.sortIndex-t.sortIndex;return n===0?e.id-t.id:n}if(e.unstable_now=void 0,typeof performance==`object`&&typeof performance.now==`function`){var a=performance;e.unstable_now=function(){return a.now()}}else{var o=Date,s=o.now();e.unstable_now=function(){return o.now()-s}}var c=[],l=[],u=1,d=null,f=3,p=!1,m=!1,h=!1,g=!1,_=typeof setTimeout==`function`?setTimeout:null,v=typeof clearTimeout==`function`?clearTimeout:null,y=typeof setImmediate<`u`?setImmediate:null;function b(e){for(var i=n(l);i!==null;){if(i.callback===null)r(l);else if(i.startTime<=e)r(l),i.sortIndex=i.expirationTime,t(c,i);else break;i=n(l)}}function x(e){if(h=!1,b(e),!m)if(n(c)!==null)m=!0,ee||(ee=!0,w());else{var t=n(l);t!==null&&oe(x,t.startTime-e)}}var ee=!1,S=-1,C=5,te=-1;function ne(){return g?!0:!(e.unstable_now()-tet&&ne());){var o=d.callback;if(typeof o==`function`){d.callback=null,f=d.priorityLevel;var s=o(d.expirationTime<=t);if(t=e.unstable_now(),typeof s==`function`){d.callback=s,b(t),i=!0;break b}d===n(c)&&r(c),b(t)}else r(c);d=n(c)}if(d!==null)i=!0;else{var u=n(l);u!==null&&oe(x,u.startTime-t),i=!1}}break a}finally{d=null,f=a,p=!1}i=void 0}}finally{i?w():ee=!1}}}var w;if(typeof y==`function`)w=function(){y(re)};else if(typeof MessageChannel<`u`){var ie=new MessageChannel,ae=ie.port2;ie.port1.onmessage=re,w=function(){ae.postMessage(null)}}else w=function(){_(re,0)};function oe(t,n){S=_(function(){t(e.unstable_now())},n)}e.unstable_IdlePriority=5,e.unstable_ImmediatePriority=1,e.unstable_LowPriority=4,e.unstable_NormalPriority=3,e.unstable_Profiling=null,e.unstable_UserBlockingPriority=2,e.unstable_cancelCallback=function(e){e.callback=null},e.unstable_forceFrameRate=function(e){0>e||125o?(r.sortIndex=a,t(l,r),n(c)===null&&r===n(l)&&(h?(v(S),S=-1):h=!0,oe(x,a-o))):(r.sortIndex=s,t(c,r),m||p||(m=!0,ee||(ee=!0,w()))),r},e.unstable_shouldYield=ne,e.unstable_wrapCallback=function(e){var t=f;return function(){var n=f;f=t;try{return e.apply(this,arguments)}finally{f=n}}}})),f=o(((e,t)=>{t.exports=d()})),p=o((e=>{var t=u();function n(e){var t=`https://react.dev/errors/`+e;if(1{function n(){if(!(typeof __REACT_DEVTOOLS_GLOBAL_HOOK__>`u`||typeof __REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE!=`function`))try{__REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE(n)}catch(e){console.error(e)}}n(),t.exports=p()})),h=o((e=>{var t=f(),n=u(),r=m();function i(e){var t=`https://react.dev/errors/`+e;if(1pe||(e.current=fe[pe],fe[pe]=null,pe--)}function k(e,t){pe++,fe[pe]=e.current,e.current=t}var me=D(null),he=D(null),ge=D(null),_e=D(null);function ve(e,t){switch(k(ge,t),k(he,e),k(me,null),t.nodeType){case 9:case 11:e=(e=t.documentElement)&&(e=e.namespaceURI)?Vd(e):0;break;default:if(e=t.tagName,t=t.namespaceURI)t=Vd(t),e=Hd(t,e);else switch(e){case`svg`:e=1;break;case`math`:e=2;break;default:e=0}}O(me),k(me,e)}function ye(){O(me),O(he),O(ge)}function be(e){e.memoizedState!==null&&k(_e,e);var t=me.current,n=Hd(t,e.type);t!==n&&(k(he,e),k(me,n))}function xe(e){he.current===e&&(O(me),O(he)),_e.current===e&&(O(_e),Qf._currentValue=de)}var Se,Ce;function we(e){if(Se===void 0)try{throw Error()}catch(e){var t=e.stack.trim().match(/\n( *(at )?)/);Se=t&&t[1]||``,Ce=-1)`:-1i||c[r]!==l[i]){var u=` +`+c[r].replace(` at new `,` at `);return e.displayName&&u.includes(``)&&(u=u.replace(``,e.displayName)),u}while(1<=r&&0<=i);break}}}finally{Te=!1,Error.prepareStackTrace=n}return(n=e?e.displayName||e.name:``)?we(n):``}function De(e,t){switch(e.tag){case 26:case 27:case 5:return we(e.type);case 16:return we(`Lazy`);case 13:return e.child!==t&&t!==null?we(`Suspense Fallback`):we(`Suspense`);case 19:return we(`SuspenseList`);case 0:case 15:return Ee(e.type,!1);case 11:return Ee(e.type.render,!1);case 1:return Ee(e.type,!0);case 31:return we(`Activity`);default:return``}}function Oe(e){try{var t=``,n=null;do t+=De(e,n),n=e,e=e.return;while(e);return t}catch(e){return` +Error generating stack: `+e.message+` +`+e.stack}}var ke=Object.prototype.hasOwnProperty,Ae=t.unstable_scheduleCallback,je=t.unstable_cancelCallback,A=t.unstable_shouldYield,Me=t.unstable_requestPaint,Ne=t.unstable_now,Pe=t.unstable_getCurrentPriorityLevel,Fe=t.unstable_ImmediatePriority,Ie=t.unstable_UserBlockingPriority,Le=t.unstable_NormalPriority,Re=t.unstable_LowPriority,ze=t.unstable_IdlePriority,Be=t.log,Ve=t.unstable_setDisableYieldValue,He=null,Ue=null;function We(e){if(typeof Be==`function`&&Ve(e),Ue&&typeof Ue.setStrictMode==`function`)try{Ue.setStrictMode(He,e)}catch{}}var Ge=Math.clz32?Math.clz32:Je,Ke=Math.log,qe=Math.LN2;function Je(e){return e>>>=0,e===0?32:31-(Ke(e)/qe|0)|0}var Ye=256,Xe=262144,Ze=4194304;function Qe(e){var t=e&42;if(t!==0)return t;switch(e&-e){case 1:return 1;case 2:return 2;case 4:return 4;case 8:return 8;case 16:return 16;case 32:return 32;case 64:return 64;case 128:return 128;case 256:case 512:case 1024:case 2048:case 4096:case 8192:case 16384:case 32768:case 65536:case 131072:return e&261888;case 262144:case 524288:case 1048576:case 2097152:return e&3932160;case 4194304:case 8388608:case 16777216:case 33554432:return e&62914560;case 67108864:return 67108864;case 134217728:return 134217728;case 268435456:return 268435456;case 536870912:return 536870912;case 1073741824:return 0;default:return e}}function $e(e,t,n){var r=e.pendingLanes;if(r===0)return 0;var i=0,a=e.suspendedLanes,o=e.pingedLanes;e=e.warmLanes;var s=r&134217727;return s===0?(s=r&~a,s===0?o===0?n||(n=r&~e,n!==0&&(i=Qe(n))):i=Qe(o):i=Qe(s)):(r=s&~a,r===0?(o&=s,o===0?n||(n=s&~e,n!==0&&(i=Qe(n))):i=Qe(o)):i=Qe(r)),i===0?0:t!==0&&t!==i&&(t&a)===0&&(a=i&-i,n=t&-t,a>=n||a===32&&n&4194048)?t:i}function et(e,t){return(e.pendingLanes&~(e.suspendedLanes&~e.pingedLanes)&t)===0}function tt(e,t){switch(e){case 1:case 2:case 4:case 8:case 64:return t+250;case 16:case 32:case 128:case 256:case 512:case 1024:case 2048:case 4096:case 8192:case 16384:case 32768:case 65536:case 131072:case 262144:case 524288:case 1048576:case 2097152:return t+5e3;case 4194304:case 8388608:case 16777216:case 33554432:return-1;case 67108864:case 134217728:case 268435456:case 536870912:case 1073741824:return-1;default:return-1}}function nt(){var e=Ze;return Ze<<=1,!(Ze&62914560)&&(Ze=4194304),e}function rt(e){for(var t=[],n=0;31>n;n++)t.push(e);return t}function it(e,t){e.pendingLanes|=t,t!==268435456&&(e.suspendedLanes=0,e.pingedLanes=0,e.warmLanes=0)}function at(e,t,n,r,i,a){var o=e.pendingLanes;e.pendingLanes=n,e.suspendedLanes=0,e.pingedLanes=0,e.warmLanes=0,e.expiredLanes&=n,e.entangledLanes&=n,e.errorRecoveryDisabledLanes&=n,e.shellSuspendCounter=0;var s=e.entanglements,c=e.expirationTimes,l=e.hiddenUpdates;for(n=o&~n;0`u`||window.document===void 0||window.document.createElement===void 0),vn=!1;if(_n)try{var yn={};Object.defineProperty(yn,"passive",{get:function(){vn=!0}}),window.addEventListener(`test`,yn,yn),window.removeEventListener(`test`,yn,yn)}catch{vn=!1}var bn=null,xn=null,Sn=null;function Cn(){if(Sn)return Sn;var e,t=xn,n=t.length,r,i=`value`in bn?bn.value:bn.textContent,a=i.length;for(e=0;e=tr),ir=` `,ar=!1;function or(e,t){switch(e){case`keyup`:return $n.indexOf(t.keyCode)!==-1;case`keydown`:return t.keyCode!==229;case`keypress`:case`mousedown`:case`focusout`:return!0;default:return!1}}function sr(e){return e=e.detail,typeof e==`object`&&`data`in e?e.data:null}var cr=!1;function lr(e,t){switch(e){case`compositionend`:return sr(t);case`keypress`:return t.which===32?(ar=!0,ir):null;case`textInput`:return e=t.data,e===ir&&ar?null:e;default:return null}}function ur(e,t){if(cr)return e===`compositionend`||!er&&or(e,t)?(e=Cn(),Sn=xn=bn=null,cr=!1,e):null;switch(e){case`paste`:return null;case`keypress`:if(!(t.ctrlKey||t.altKey||t.metaKey)||t.ctrlKey&&t.altKey){if(t.char&&1=t)return{node:n,offset:t-e};e=r}a:{for(;n;){if(n.nextSibling){n=n.nextSibling;break a}n=n.parentNode}n=void 0}n=Mr(n)}}function Pr(e,t){return e&&t?e===t?!0:e&&e.nodeType===3?!1:t&&t.nodeType===3?Pr(e,t.parentNode):`contains`in e?e.contains(t):e.compareDocumentPosition?!!(e.compareDocumentPosition(t)&16):!1:!1}function Fr(e){e=e!=null&&e.ownerDocument!=null&&e.ownerDocument.defaultView!=null?e.ownerDocument.defaultView:window;for(var t=Wt(e.document);t instanceof e.HTMLIFrameElement;){try{var n=typeof t.contentWindow.location.href==`string`}catch{n=!1}if(n)e=t.contentWindow;else break;t=Wt(e.document)}return t}function Ir(e){var t=e&&e.nodeName&&e.nodeName.toLowerCase();return t&&(t===`input`&&(e.type===`text`||e.type===`search`||e.type===`tel`||e.type===`url`||e.type===`password`)||t===`textarea`||e.contentEditable===`true`)}var Lr=_n&&`documentMode`in document&&11>=document.documentMode,Rr=null,zr=null,Br=null,Vr=!1;function Hr(e,t,n){var r=n.window===n?n.document:n.nodeType===9?n:n.ownerDocument;Vr||Rr==null||Rr!==Wt(r)||(r=Rr,`selectionStart`in r&&Ir(r)?r={start:r.selectionStart,end:r.selectionEnd}:(r=(r.ownerDocument&&r.ownerDocument.defaultView||window).getSelection(),r={anchorNode:r.anchorNode,anchorOffset:r.anchorOffset,focusNode:r.focusNode,focusOffset:r.focusOffset}),Br&&jr(Br,r)||(Br=r,r=Ed(zr,`onSelect`),0>=o,i-=o,Pi=1<<32-Ge(t)+i|n<h?(g=d,d=null):g=d.sibling;var _=p(i,d,s[h],c);if(_===null){d===null&&(d=g);break}e&&d&&_.alternate===null&&t(i,d),a=o(_,a,h),u===null?l=_:u.sibling=_,u=_,d=g}if(h===s.length)return n(i,d),M&&Ii(i,h),l;if(d===null){for(;hg?(_=h,h=null):_=h.sibling;var y=p(a,h,v.value,l);if(y===null){h===null&&(h=_);break}e&&h&&y.alternate===null&&t(a,h),s=o(y,s,g),d===null?u=y:d.sibling=y,d=y,h=_}if(v.done)return n(a,h),M&&Ii(a,g),u;if(h===null){for(;!v.done;g++,v=c.next())v=f(a,v.value,l),v!==null&&(s=o(v,s,g),d===null?u=v:d.sibling=v,d=v);return M&&Ii(a,g),u}for(h=r(h);!v.done;g++,v=c.next())v=m(h,a,g,v.value,l),v!==null&&(e&&v.alternate!==null&&h.delete(v.key===null?g:v.key),s=o(v,s,g),d===null?u=v:d.sibling=v,d=v);return e&&h.forEach(function(e){return t(a,e)}),M&&Ii(a,g),u}function b(e,r,o,c){if(typeof o==`object`&&o&&o.type===y&&o.key===null&&(o=o.props.children),typeof o==`object`&&o){switch(o.$$typeof){case _:a:{for(var l=o.key;r!==null;){if(r.key===l){if(l=o.type,l===y){if(r.tag===7){n(e,r.sibling),c=a(r,o.props.children),c.return=e,e=c;break a}}else if(r.elementType===l||typeof l==`object`&&l&&l.$$typeof===w&&Pa(l)===r.type){n(e,r.sibling),c=a(r,o.props),Va(c,o),c.return=e,e=c;break a}n(e,r);break}else t(e,r);r=r.sibling}o.type===y?(c=xi(o.props.children,e.mode,c,o.key),c.return=e,e=c):(c=bi(o.type,o.key,o.props,null,e.mode,c),Va(c,o),c.return=e,e=c)}return s(e);case v:a:{for(l=o.key;r!==null;){if(r.key===l)if(r.tag===4&&r.stateNode.containerInfo===o.containerInfo&&r.stateNode.implementation===o.implementation){n(e,r.sibling),c=a(r,o.children||[]),c.return=e,e=c;break a}else{n(e,r);break}else t(e,r);r=r.sibling}c=wi(o,e.mode,c),c.return=e,e=c}return s(e);case w:return o=Pa(o),b(e,r,o,c)}if(ue(o))return h(e,r,o,c);if(se(o)){if(l=se(o),typeof l!=`function`)throw Error(i(150));return o=l.call(o),g(e,r,o,c)}if(typeof o.then==`function`)return b(e,r,Ba(o),c);if(o.$$typeof===S)return b(e,r,la(e,o),c);Ha(e,o)}return typeof o==`string`&&o!==``||typeof o==`number`||typeof o==`bigint`?(o=``+o,r!==null&&r.tag===6?(n(e,r.sibling),c=a(r,o),c.return=e,e=c):(n(e,r),c=Si(o,e.mode,c),c.return=e,e=c),s(e)):n(e,r)}return function(e,t,n,r){try{za=0;var i=b(e,t,n,r);return Ra=null,i}catch(t){if(t===Oa||t===Aa)throw t;var a=gi(29,t,null,e.mode);return a.lanes=r,a.return=e,a}}}var Wa=Ua(!0),Ga=Ua(!1),Ka=!1;function qa(e){e.updateQueue={baseState:e.memoizedState,firstBaseUpdate:null,lastBaseUpdate:null,shared:{pending:null,lanes:0,hiddenCallbacks:null},callbacks:null}}function Ja(e,t){e=e.updateQueue,t.updateQueue===e&&(t.updateQueue={baseState:e.baseState,firstBaseUpdate:e.firstBaseUpdate,lastBaseUpdate:e.lastBaseUpdate,shared:e.shared,callbacks:null})}function Ya(e){return{lane:e,tag:0,payload:null,callback:null,next:null}}function Xa(e,t,n){var r=e.updateQueue;if(r===null)return null;if(r=r.shared,W&2){var i=r.pending;return i===null?t.next=t:(t.next=i.next,i.next=t),r.pending=t,t=pi(e),fi(e,null,n),t}return li(e,r,t,n),pi(e)}function Za(e,t,n){if(t=t.updateQueue,t!==null&&(t=t.shared,n&4194048)){var r=t.lanes;r&=e.pendingLanes,n|=r,t.lanes=n,st(e,n)}}function Qa(e,t){var n=e.updateQueue,r=e.alternate;if(r!==null&&(r=r.updateQueue,n===r)){var i=null,a=null;if(n=n.firstBaseUpdate,n!==null){do{var o={lane:n.lane,tag:n.tag,payload:n.payload,callback:null,next:null};a===null?i=a=o:a=a.next=o,n=n.next}while(n!==null);a===null?i=a=t:a=a.next=t}else i=a=t;n={baseState:r.baseState,firstBaseUpdate:i,lastBaseUpdate:a,shared:r.shared,callbacks:r.callbacks},e.updateQueue=n;return}e=n.lastBaseUpdate,e===null?n.firstBaseUpdate=t:e.next=t,n.lastBaseUpdate=t}var $a=!1;function eo(){if($a){var e=ya;if(e!==null)throw e}}function to(e,t,n,r){$a=!1;var i=e.updateQueue;Ka=!1;var a=i.firstBaseUpdate,o=i.lastBaseUpdate,s=i.shared.pending;if(s!==null){i.shared.pending=null;var c=s,l=c.next;c.next=null,o===null?a=l:o.next=l,o=c;var u=e.alternate;u!==null&&(u=u.updateQueue,s=u.lastBaseUpdate,s!==o&&(s===null?u.firstBaseUpdate=l:s.next=l,u.lastBaseUpdate=c))}if(a!==null){var d=i.baseState;o=0,u=l=c=null,s=a;do{var f=s.lane&-536870913,p=f!==s.lane;if(p?(q&f)===f:(r&f)===f){f!==0&&f===va&&($a=!0),u!==null&&(u=u.next={lane:0,tag:s.tag,payload:s.payload,callback:null,next:null});a:{var m=e,g=s;f=t;var _=n;switch(g.tag){case 1:if(m=g.payload,typeof m==`function`){d=m.call(_,d,f);break a}d=m;break a;case 3:m.flags=m.flags&-65537|128;case 0:if(m=g.payload,f=typeof m==`function`?m.call(_,d,f):m,f==null)break a;d=h({},d,f);break a;case 2:Ka=!0}}f=s.callback,f!==null&&(e.flags|=64,p&&(e.flags|=8192),p=i.callbacks,p===null?i.callbacks=[f]:p.push(f))}else p={lane:f,tag:s.tag,payload:s.payload,callback:s.callback,next:null},u===null?(l=u=p,c=d):u=u.next=p,o|=f;if(s=s.next,s===null){if(s=i.shared.pending,s===null)break;p=s,s=p.next,p.next=null,i.lastBaseUpdate=p,i.shared.pending=null}}while(1);u===null&&(c=d),i.baseState=c,i.firstBaseUpdate=l,i.lastBaseUpdate=u,a===null&&(i.shared.lanes=0),Kl|=o,e.lanes=o,e.memoizedState=d}}function no(e,t){if(typeof e!=`function`)throw Error(i(191,e));e.call(t)}function ro(e,t){var n=e.callbacks;if(n!==null)for(e.callbacks=null,e=0;ea?a:8;var o=T.T,s={};T.T=s,Rs(e,!1,t,n);try{var c=i(),l=T.S;l!==null&&l(s,c),typeof c==`object`&&c&&typeof c.then==`function`?Ls(e,t,Sa(c,r),pu(e)):Ls(e,t,r,pu(e))}catch(n){Ls(e,t,{then:function(){},status:`rejected`,reason:n},pu())}finally{E.p=a,o!==null&&s.types!==null&&(o.types=s.types),T.T=o}}function Ds(){}function Os(e,t,n,r){if(e.tag!==5)throw Error(i(476));var a=ks(e).queue;Es(e,a,t,de,n===null?Ds:function(){return As(e),n(r)})}function ks(e){var t=e.memoizedState;if(t!==null)return t;t={memoizedState:de,baseState:de,baseQueue:null,queue:{pending:null,lanes:0,dispatch:null,lastRenderedReducer:zo,lastRenderedState:de},next:null};var n={};return t.next={memoizedState:n,baseState:n,baseQueue:null,queue:{pending:null,lanes:0,dispatch:null,lastRenderedReducer:zo,lastRenderedState:n},next:null},e.memoizedState=t,e=e.alternate,e!==null&&(e.memoizedState=t),t}function As(e){var t=ks(e);t.next===null&&(t=e.alternate.memoizedState),Ls(e,t.next.queue,{},pu())}function js(){return ca(Qf)}function Ms(){return z().memoizedState}function Ns(){return z().memoizedState}function Ps(e){for(var t=e.return;t!==null;){switch(t.tag){case 24:case 3:var n=pu();e=Ya(n);var r=Xa(t,e,n);r!==null&&(hu(r,t,n),Za(r,t,n)),t={cache:ma()},e.payload=t;return}t=t.return}}function Fs(e,t,n){var r=pu();n={lane:r,revertLane:0,gesture:null,action:n,hasEagerState:!1,eagerState:null,next:null},zs(e)?Bs(t,n):(n=ui(e,t,n,r),n!==null&&(hu(n,e,r),Vs(n,t,r)))}function Is(e,t,n){Ls(e,t,n,pu())}function Ls(e,t,n,r){var i={lane:r,revertLane:0,gesture:null,action:n,hasEagerState:!1,eagerState:null,next:null};if(zs(e))Bs(t,i);else{var a=e.alternate;if(e.lanes===0&&(a===null||a.lanes===0)&&(a=t.lastRenderedReducer,a!==null))try{var o=t.lastRenderedState,s=a(o,n);if(i.hasEagerState=!0,i.eagerState=s,Ar(s,o))return li(e,t,i,0),G===null&&ci(),!1}catch{}if(n=ui(e,t,i,r),n!==null)return hu(n,e,r),Vs(n,t,r),!0}return!1}function Rs(e,t,n,r){if(r={lane:2,revertLane:dd(),gesture:null,action:r,hasEagerState:!1,eagerState:null,next:null},zs(e)){if(t)throw Error(i(479))}else t=ui(e,n,r,2),t!==null&&hu(t,e,2)}function zs(e){var t=e.alternate;return e===F||t!==null&&t===F}function Bs(e,t){bo=yo=!0;var n=e.pending;n===null?t.next=t:(t.next=n.next,n.next=t),e.pending=t}function Vs(e,t,n){if(n&4194048){var r=t.lanes;r&=e.pendingLanes,n|=r,t.lanes=n,st(e,n)}}var Hs={readContext:ca,use:Lo,useCallback:R,useContext:R,useEffect:R,useImperativeHandle:R,useLayoutEffect:R,useInsertionEffect:R,useMemo:R,useReducer:R,useRef:R,useState:R,useDebugValue:R,useDeferredValue:R,useTransition:R,useSyncExternalStore:R,useId:R,useHostTransitionStatus:R,useFormState:R,useActionState:R,useOptimistic:R,useMemoCache:R,useCacheRefresh:R};Hs.useEffectEvent=R;var Us={readContext:ca,use:Lo,useCallback:function(e,t){return Po().memoizedState=[e,t===void 0?null:t],e},useContext:ca,useEffect:ps,useImperativeHandle:function(e,t,n){n=n==null?null:n.concat([e]),ds(4194308,4,ys.bind(null,t,e),n)},useLayoutEffect:function(e,t){return ds(4194308,4,e,t)},useInsertionEffect:function(e,t){ds(4,2,e,t)},useMemo:function(e,t){var n=Po();t=t===void 0?null:t;var r=e();if(xo){We(!0);try{e()}finally{We(!1)}}return n.memoizedState=[r,t],r},useReducer:function(e,t,n){var r=Po();if(n!==void 0){var i=n(t);if(xo){We(!0);try{n(t)}finally{We(!1)}}}else i=t;return r.memoizedState=r.baseState=i,e={pending:null,lanes:0,dispatch:null,lastRenderedReducer:e,lastRenderedState:i},r.queue=e,e=e.dispatch=Fs.bind(null,F,e),[r.memoizedState,e]},useRef:function(e){var t=Po();return e={current:e},t.memoizedState=e},useState:function(e){e=Yo(e);var t=e.queue,n=Is.bind(null,F,t);return t.dispatch=n,[e.memoizedState,n]},useDebugValue:xs,useDeferredValue:function(e,t){return ws(Po(),e,t)},useTransition:function(){var e=Yo(!1);return e=Es.bind(null,F,e.queue,!0,!1),Po().memoizedState=e,[!1,e]},useSyncExternalStore:function(e,t,n){var r=F,a=Po();if(M){if(n===void 0)throw Error(i(407));n=n()}else{if(n=t(),G===null)throw Error(i(349));q&127||Wo(r,t,n)}a.memoizedState=n;var o={value:n,getSnapshot:t};return a.queue=o,ps(Ko.bind(null,r,o,e),[e]),r.flags|=2048,ls(9,{destroy:void 0},Go.bind(null,r,o,n,t),null),n},useId:function(){var e=Po(),t=G.identifierPrefix;if(M){var n=Fi,r=Pi;n=(r&~(1<<32-Ge(r)-1)).toString(32)+n,t=`_`+t+`R_`+n,n=So++,0<\/script>`,o=o.removeChild(o.firstChild);break;case`select`:o=typeof r.is==`string`?s.createElement(`select`,{is:r.is}):s.createElement(`select`),r.multiple?o.multiple=!0:r.size&&(o.size=r.size);break;default:o=typeof r.is==`string`?s.createElement(a,{is:r.is}):s.createElement(a)}}o[mt]=t,o[ht]=r;a:for(s=t.child;s!==null;){if(s.tag===5||s.tag===6)o.appendChild(s.stateNode);else if(s.tag!==4&&s.tag!==27&&s.child!==null){s.child.return=s,s=s.child;continue}if(s===t)break a;for(;s.sibling===null;){if(s.return===null||s.return===t)break a;s=s.return}s.sibling.return=s.return,s=s.sibling}t.stateNode=o;a:switch(Pd(o,a,r),a){case`button`:case`input`:case`select`:case`textarea`:r=!!r.autoFocus;break a;case`img`:r=!0;break a;default:r=!1}r&&Ic(t)}}return V(t),Lc(t,t.type,e===null?null:e.memoizedProps,t.pendingProps,n),null;case 6:if(e&&t.stateNode!=null)e.memoizedProps!==r&&Ic(t);else{if(typeof r!=`string`&&t.stateNode===null)throw Error(i(166));if(e=ge.current,Ji(t)){if(e=t.stateNode,n=t.memoizedProps,r=null,a=Vi,a!==null)switch(a.tag){case 27:case 5:r=a.memoizedProps}e[mt]=t,e=!!(e.nodeValue===n||r!==null&&!0===r.suppressHydrationWarning||Md(e.nodeValue,n)),e||Gi(t,!0)}else e=Bd(e).createTextNode(r),e[mt]=t,t.stateNode=e}return V(t),null;case 31:if(n=t.memoizedState,e===null||e.memoizedState!==null){if(r=Ji(t),n!==null){if(e===null){if(!r)throw Error(i(318));if(e=t.memoizedState,e=e===null?null:e.dehydrated,!e)throw Error(i(557));e[mt]=t}else Yi(),!(t.flags&128)&&(t.memoizedState=null),t.flags|=4;V(t),e=!1}else n=Xi(),e!==null&&e.memoizedState!==null&&(e.memoizedState.hydrationErrors=n),e=!0;if(!e)return t.flags&256?(go(t),t):(go(t),null);if(t.flags&128)throw Error(i(558))}return V(t),null;case 13:if(r=t.memoizedState,e===null||e.memoizedState!==null&&e.memoizedState.dehydrated!==null){if(a=Ji(t),r!==null&&r.dehydrated!==null){if(e===null){if(!a)throw Error(i(318));if(a=t.memoizedState,a=a===null?null:a.dehydrated,!a)throw Error(i(317));a[mt]=t}else Yi(),!(t.flags&128)&&(t.memoizedState=null),t.flags|=4;V(t),a=!1}else a=Xi(),e!==null&&e.memoizedState!==null&&(e.memoizedState.hydrationErrors=a),a=!0;if(!a)return t.flags&256?(go(t),t):(go(t),null)}return go(t),t.flags&128?(t.lanes=n,t):(n=r!==null,e=e!==null&&e.memoizedState!==null,n&&(r=t.child,a=null,r.alternate!==null&&r.alternate.memoizedState!==null&&r.alternate.memoizedState.cachePool!==null&&(a=r.alternate.memoizedState.cachePool.pool),o=null,r.memoizedState!==null&&r.memoizedState.cachePool!==null&&(o=r.memoizedState.cachePool.pool),o!==a&&(r.flags|=2048)),n!==e&&n&&(t.child.flags|=8192),zc(t,t.updateQueue),V(t),null);case 4:return ye(),e===null&&Sd(t.stateNode.containerInfo),V(t),null;case 10:return na(t.type),V(t),null;case 19:if(O(P),r=t.memoizedState,r===null)return V(t),null;if(a=(t.flags&128)!=0,o=r.rendering,o===null)if(a)Bc(r,!1);else{if(Y!==0||e!==null&&e.flags&128)for(e=t.child;e!==null;){if(o=_o(e),o!==null){for(t.flags|=128,Bc(r,!1),e=o.updateQueue,t.updateQueue=e,zc(t,e),t.subtreeFlags=0,e=n,n=t.child;n!==null;)yi(n,e),n=n.sibling;return k(P,P.current&1|2),M&&Ii(t,r.treeForkCount),t.child}e=e.sibling}r.tail!==null&&Ne()>nu&&(t.flags|=128,a=!0,Bc(r,!1),t.lanes=4194304)}else{if(!a)if(e=_o(o),e!==null){if(t.flags|=128,a=!0,e=e.updateQueue,t.updateQueue=e,zc(t,e),Bc(r,!0),r.tail===null&&r.tailMode===`hidden`&&!o.alternate&&!M)return V(t),null}else 2*Ne()-r.renderingStartTime>nu&&n!==536870912&&(t.flags|=128,a=!0,Bc(r,!1),t.lanes=4194304);r.isBackwards?(o.sibling=t.child,t.child=o):(e=r.last,e===null?t.child=o:e.sibling=o,r.last=o)}return r.tail===null?(V(t),null):(e=r.tail,r.rendering=e,r.tail=e.sibling,r.renderingStartTime=Ne(),e.sibling=null,n=P.current,k(P,a?n&1|2:n&1),M&&Ii(t,r.treeForkCount),e);case 22:case 23:return go(t),co(),r=t.memoizedState!==null,e===null?r&&(t.flags|=8192):e.memoizedState!==null!==r&&(t.flags|=8192),r?n&536870912&&!(t.flags&128)&&(V(t),t.subtreeFlags&6&&(t.flags|=8192)):V(t),n=t.updateQueue,n!==null&&zc(t,n.retryQueue),n=null,e!==null&&e.memoizedState!==null&&e.memoizedState.cachePool!==null&&(n=e.memoizedState.cachePool.pool),r=null,t.memoizedState!==null&&t.memoizedState.cachePool!==null&&(r=t.memoizedState.cachePool.pool),r!==n&&(t.flags|=2048),e!==null&&O(wa),null;case 24:return n=null,e!==null&&(n=e.memoizedState.cache),t.memoizedState.cache!==n&&(t.flags|=2048),na(N),V(t),null;case 25:return null;case 30:return null}throw Error(i(156,t.tag))}function Hc(e,t){switch(zi(t),t.tag){case 1:return e=t.flags,e&65536?(t.flags=e&-65537|128,t):null;case 3:return na(N),ye(),e=t.flags,e&65536&&!(e&128)?(t.flags=e&-65537|128,t):null;case 26:case 27:case 5:return xe(t),null;case 31:if(t.memoizedState!==null){if(go(t),t.alternate===null)throw Error(i(340));Yi()}return e=t.flags,e&65536?(t.flags=e&-65537|128,t):null;case 13:if(go(t),e=t.memoizedState,e!==null&&e.dehydrated!==null){if(t.alternate===null)throw Error(i(340));Yi()}return e=t.flags,e&65536?(t.flags=e&-65537|128,t):null;case 19:return O(P),null;case 4:return ye(),null;case 10:return na(t.type),null;case 22:case 23:return go(t),co(),e!==null&&O(wa),e=t.flags,e&65536?(t.flags=e&-65537|128,t):null;case 24:return na(N),null;case 25:return null;default:return null}}function Uc(e,t){switch(zi(t),t.tag){case 3:na(N),ye();break;case 26:case 27:case 5:xe(t);break;case 4:ye();break;case 31:t.memoizedState!==null&&go(t);break;case 13:go(t);break;case 19:O(P);break;case 10:na(t.type);break;case 22:case 23:go(t),co(),e!==null&&O(wa);break;case 24:na(N)}}function Wc(e,t){try{var n=t.updateQueue,r=n===null?null:n.lastEffect;if(r!==null){var i=r.next;n=i;do{if((n.tag&e)===e){r=void 0;var a=n.create,o=n.inst;r=a(),o.destroy=r}n=n.next}while(n!==i)}}catch(e){Z(t,t.return,e)}}function Gc(e,t,n){try{var r=t.updateQueue,i=r===null?null:r.lastEffect;if(i!==null){var a=i.next;r=a;do{if((r.tag&e)===e){var o=r.inst,s=o.destroy;if(s!==void 0){o.destroy=void 0,i=t;var c=n,l=s;try{l()}catch(e){Z(i,c,e)}}}r=r.next}while(r!==a)}}catch(e){Z(t,t.return,e)}}function Kc(e){var t=e.updateQueue;if(t!==null){var n=e.stateNode;try{ro(t,n)}catch(t){Z(e,e.return,t)}}}function qc(e,t,n){n.props=Xs(e.type,e.memoizedProps),n.state=e.memoizedState;try{n.componentWillUnmount()}catch(n){Z(e,t,n)}}function Jc(e,t){try{var n=e.ref;if(n!==null){switch(e.tag){case 26:case 27:case 5:var r=e.stateNode;break;case 30:r=e.stateNode;break;default:r=e.stateNode}typeof n==`function`?e.refCleanup=n(r):n.current=r}}catch(n){Z(e,t,n)}}function Yc(e,t){var n=e.ref,r=e.refCleanup;if(n!==null)if(typeof r==`function`)try{r()}catch(n){Z(e,t,n)}finally{e.refCleanup=null,e=e.alternate,e!=null&&(e.refCleanup=null)}else if(typeof n==`function`)try{n(null)}catch(n){Z(e,t,n)}else n.current=null}function Xc(e){var t=e.type,n=e.memoizedProps,r=e.stateNode;try{a:switch(t){case`button`:case`input`:case`select`:case`textarea`:n.autoFocus&&r.focus();break a;case`img`:n.src?r.src=n.src:n.srcSet&&(r.srcset=n.srcSet)}}catch(t){Z(e,e.return,t)}}function Zc(e,t,n){try{var r=e.stateNode;Fd(r,e.type,n,t),r[ht]=t}catch(t){Z(e,e.return,t)}}function Qc(e){return e.tag===5||e.tag===3||e.tag===26||e.tag===27&&Zd(e.type)||e.tag===4}function $c(e){a:for(;;){for(;e.sibling===null;){if(e.return===null||Qc(e.return))return null;e=e.return}for(e.sibling.return=e.return,e=e.sibling;e.tag!==5&&e.tag!==6&&e.tag!==18;){if(e.tag===27&&Zd(e.type)||e.flags&2||e.child===null||e.tag===4)continue a;e.child.return=e,e=e.child}if(!(e.flags&2))return e.stateNode}}function el(e,t,n){var r=e.tag;if(r===5||r===6)e=e.stateNode,t?(n.nodeType===9?n.body:n.nodeName===`HTML`?n.ownerDocument.body:n).insertBefore(e,t):(t=n.nodeType===9?n.body:n.nodeName===`HTML`?n.ownerDocument.body:n,t.appendChild(e),n=n._reactRootContainer,n!=null||t.onclick!==null||(t.onclick=cn));else if(r!==4&&(r===27&&Zd(e.type)&&(n=e.stateNode,t=null),e=e.child,e!==null))for(el(e,t,n),e=e.sibling;e!==null;)el(e,t,n),e=e.sibling}function tl(e,t,n){var r=e.tag;if(r===5||r===6)e=e.stateNode,t?n.insertBefore(e,t):n.appendChild(e);else if(r!==4&&(r===27&&Zd(e.type)&&(n=e.stateNode),e=e.child,e!==null))for(tl(e,t,n),e=e.sibling;e!==null;)tl(e,t,n),e=e.sibling}function nl(e){var t=e.stateNode,n=e.memoizedProps;try{for(var r=e.type,i=t.attributes;i.length;)t.removeAttributeNode(i[0]);Pd(t,r,n),t[mt]=e,t[ht]=n}catch(t){Z(e,e.return,t)}}var rl=!1,H=!1,il=!1,al=typeof WeakSet==`function`?WeakSet:Set,ol=null;function sl(e,t){if(e=e.containerInfo,Rd=sp,e=Fr(e),Ir(e)){if(`selectionStart`in e)var n={start:e.selectionStart,end:e.selectionEnd};else a:{n=(n=e.ownerDocument)&&n.defaultView||window;var r=n.getSelection&&n.getSelection();if(r&&r.rangeCount!==0){n=r.anchorNode;var a=r.anchorOffset,o=r.focusNode;r=r.focusOffset;try{n.nodeType,o.nodeType}catch{n=null;break a}var s=0,c=-1,l=-1,u=0,d=0,f=e,p=null;b:for(;;){for(var m;f!==n||a!==0&&f.nodeType!==3||(c=s+a),f!==o||r!==0&&f.nodeType!==3||(l=s+r),f.nodeType===3&&(s+=f.nodeValue.length),(m=f.firstChild)!==null;)p=f,f=m;for(;;){if(f===e)break b;if(p===n&&++u===a&&(c=s),p===o&&++d===r&&(l=s),(m=f.nextSibling)!==null)break;f=p,p=f.parentNode}f=m}n=c===-1||l===-1?null:{start:c,end:l}}else n=null}n||={start:0,end:0}}else n=null;for(zd={focusedElem:e,selectionRange:n},sp=!1,ol=t;ol!==null;)if(t=ol,e=t.child,t.subtreeFlags&1028&&e!==null)e.return=t,ol=e;else for(;ol!==null;){switch(t=ol,o=t.alternate,e=t.flags,t.tag){case 0:if(e&4&&(e=t.updateQueue,e=e===null?null:e.events,e!==null))for(n=0;n title`))),Pd(o,r,n),o[mt]=e,Dt(o),r=o;break a;case`link`:var s=Vf(`link`,`href`,a).get(r+(n.href||``));if(s){for(var c=0;cg&&(o=g,g=h,h=o);var _=Nr(s,h),v=Nr(s,g);if(_&&v&&(p.rangeCount!==1||p.anchorNode!==_.node||p.anchorOffset!==_.offset||p.focusNode!==v.node||p.focusOffset!==v.offset)){var y=d.createRange();y.setStart(_.node,_.offset),p.removeAllRanges(),h>g?(p.addRange(y),p.extend(v.node,v.offset)):(y.setEnd(v.node,v.offset),p.addRange(y))}}}}for(d=[],p=s;p=p.parentNode;)p.nodeType===1&&d.push({element:p,left:p.scrollLeft,top:p.scrollTop});for(typeof s.focus==`function`&&s.focus(),s=0;sn?32:n,T.T=null,n=lu,lu=null;var o=au,s=su;if(X=0,ou=au=null,su=0,W&6)throw Error(i(331));var c=W;if(W|=4,Il(o.current),Ol(o,o.current,s,n),W=c,id(0,!1),Ue&&typeof Ue.onPostCommitFiberRoot==`function`)try{Ue.onPostCommitFiberRoot(He,o)}catch{}return!0}finally{E.p=a,T.T=r,Vu(e,t)}}function Wu(e,t,n){t=Ei(n,t),t=nc(e.stateNode,t,2),e=Xa(e,t,2),e!==null&&(it(e,2),rd(e))}function Z(e,t,n){if(e.tag===3)Wu(e,e,n);else for(;t!==null;){if(t.tag===3){Wu(t,e,n);break}else if(t.tag===1){var r=t.stateNode;if(typeof t.type.getDerivedStateFromError==`function`||typeof r.componentDidCatch==`function`&&(iu===null||!iu.has(r))){e=Ei(n,e),n=rc(2),r=Xa(t,n,2),r!==null&&(ic(n,r,t,e),it(r,2),rd(r));break}}t=t.return}}function Gu(e,t,n){var r=e.pingCache;if(r===null){r=e.pingCache=new Bl;var i=new Set;r.set(t,i)}else i=r.get(t),i===void 0&&(i=new Set,r.set(t,i));i.has(n)||(Wl=!0,i.add(n),e=Ku.bind(null,e,t,n),t.then(e,e))}function Ku(e,t,n){var r=e.pingCache;r!==null&&r.delete(t),e.pingedLanes|=e.suspendedLanes&n,e.warmLanes&=~n,G===e&&(q&n)===n&&(Y===4||Y===3&&(q&62914560)===q&&300>Ne()-eu?!(W&2)&&Su(e,0):Jl|=n,Xl===q&&(Xl=0)),rd(e)}function qu(e,t){t===0&&(t=nt()),e=di(e,t),e!==null&&(it(e,t),rd(e))}function Ju(e){var t=e.memoizedState,n=0;t!==null&&(n=t.retryLane),qu(e,n)}function Yu(e,t){var n=0;switch(e.tag){case 31:case 13:var r=e.stateNode,a=e.memoizedState;a!==null&&(n=a.retryLane);break;case 19:r=e.stateNode;break;case 22:r=e.stateNode._retryCache;break;default:throw Error(i(314))}r!==null&&r.delete(t),qu(e,n)}function Xu(e,t){return Ae(e,t)}var Zu=null,Qu=null,$u=!1,ed=!1,td=!1,nd=0;function rd(e){e!==Qu&&e.next===null&&(Qu===null?Zu=Qu=e:Qu=Qu.next=e),ed=!0,$u||($u=!0,ud())}function id(e,t){if(!td&&ed){td=!0;do for(var n=!1,r=Zu;r!==null;){if(!t)if(e!==0){var i=r.pendingLanes;if(i===0)var a=0;else{var o=r.suspendedLanes,s=r.pingedLanes;a=(1<<31-Ge(42|e)+1)-1,a&=i&~(o&~s),a=a&201326741?a&201326741|1:a?a|2:0}a!==0&&(n=!0,ld(r,a))}else a=q,a=$e(r,r===G?a:0,r.cancelPendingCommit!==null||r.timeoutHandle!==-1),!(a&3)||et(r,a)||(n=!0,ld(r,a));r=r.next}while(n);td=!1}}function ad(){od()}function od(){ed=$u=!1;var e=0;nd!==0&&Gd()&&(e=nd);for(var t=Ne(),n=null,r=Zu;r!==null;){var i=r.next,a=sd(r,t);a===0?(r.next=null,n===null?Zu=i:n.next=i,i===null&&(Qu=n)):(n=r,(e!==0||a&3)&&(ed=!0)),r=i}X!==0&&X!==5||id(e,!1),nd!==0&&(nd=0)}function sd(e,t){for(var n=e.suspendedLanes,r=e.pingedLanes,i=e.expirationTimes,a=e.pendingLanes&-62914561;0s)break;var u=c.transferSize,d=c.initiatorType;u&&Id(d)&&(c=c.responseEnd,o+=u*(c`u`?null:document;function xf(e,t,n){var r=bf;if(r&&typeof t==`string`&&t){var i=Kt(t);i=`link[rel="`+e+`"][href="`+i+`"]`,typeof n==`string`&&(i+=`[crossorigin="`+n+`"]`),hf.has(i)||(hf.add(i),e={rel:e,crossOrigin:n,href:t},r.querySelector(i)===null&&(t=r.createElement(`link`),Pd(t,`link`,e),Dt(t),r.head.appendChild(t)))}}function Sf(e){_f.D(e),xf(`dns-prefetch`,e,null)}function Cf(e,t){_f.C(e,t),xf(`preconnect`,e,t)}function wf(e,t,n){_f.L(e,t,n);var r=bf;if(r&&e&&t){var i=`link[rel="preload"][as="`+Kt(t)+`"]`;t===`image`&&n&&n.imageSrcSet?(i+=`[imagesrcset="`+Kt(n.imageSrcSet)+`"]`,typeof n.imageSizes==`string`&&(i+=`[imagesizes="`+Kt(n.imageSizes)+`"]`)):i+=`[href="`+Kt(e)+`"]`;var a=i;switch(t){case`style`:a=Af(e);break;case`script`:a=Pf(e)}mf.has(a)||(e=h({rel:`preload`,href:t===`image`&&n&&n.imageSrcSet?void 0:e,as:t},n),mf.set(a,e),r.querySelector(i)!==null||t===`style`&&r.querySelector(jf(a))||t===`script`&&r.querySelector(Ff(a))||(t=r.createElement(`link`),Pd(t,`link`,e),Dt(t),r.head.appendChild(t)))}}function Tf(e,t){_f.m(e,t);var n=bf;if(n&&e){var r=t&&typeof t.as==`string`?t.as:`script`,i=`link[rel="modulepreload"][as="`+Kt(r)+`"][href="`+Kt(e)+`"]`,a=i;switch(r){case`audioworklet`:case`paintworklet`:case`serviceworker`:case`sharedworker`:case`worker`:case`script`:a=Pf(e)}if(!mf.has(a)&&(e=h({rel:`modulepreload`,href:e},t),mf.set(a,e),n.querySelector(i)===null)){switch(r){case`audioworklet`:case`paintworklet`:case`serviceworker`:case`sharedworker`:case`worker`:case`script`:if(n.querySelector(Ff(a)))return}r=n.createElement(`link`),Pd(r,`link`,e),Dt(r),n.head.appendChild(r)}}}function Ef(e,t,n){_f.S(e,t,n);var r=bf;if(r&&e){var i=Et(r).hoistableStyles,a=Af(e);t||=`default`;var o=i.get(a);if(!o){var s={loading:0,preload:null};if(o=r.querySelector(jf(a)))s.loading=5;else{e=h({rel:`stylesheet`,href:e,"data-precedence":t},n),(n=mf.get(a))&&Rf(e,n);var c=o=r.createElement(`link`);Dt(c),Pd(c,`link`,e),c._p=new Promise(function(e,t){c.onload=e,c.onerror=t}),c.addEventListener(`load`,function(){s.loading|=1}),c.addEventListener(`error`,function(){s.loading|=2}),s.loading|=4,Lf(o,t,r)}o={type:`stylesheet`,instance:o,count:1,state:s},i.set(a,o)}}}function Df(e,t){_f.X(e,t);var n=bf;if(n&&e){var r=Et(n).hoistableScripts,i=Pf(e),a=r.get(i);a||(a=n.querySelector(Ff(i)),a||(e=h({src:e,async:!0},t),(t=mf.get(i))&&zf(e,t),a=n.createElement(`script`),Dt(a),Pd(a,`link`,e),n.head.appendChild(a)),a={type:`script`,instance:a,count:1,state:null},r.set(i,a))}}function Of(e,t){_f.M(e,t);var n=bf;if(n&&e){var r=Et(n).hoistableScripts,i=Pf(e),a=r.get(i);a||(a=n.querySelector(Ff(i)),a||(e=h({src:e,async:!0,type:`module`},t),(t=mf.get(i))&&zf(e,t),a=n.createElement(`script`),Dt(a),Pd(a,`link`,e),n.head.appendChild(a)),a={type:`script`,instance:a,count:1,state:null},r.set(i,a))}}function kf(e,t,n,r){var a=(a=ge.current)?gf(a):null;if(!a)throw Error(i(446));switch(e){case`meta`:case`title`:return null;case`style`:return typeof n.precedence==`string`&&typeof n.href==`string`?(t=Af(n.href),n=Et(a).hoistableStyles,r=n.get(t),r||(r={type:`style`,instance:null,count:0,state:null},n.set(t,r)),r):{type:`void`,instance:null,count:0,state:null};case`link`:if(n.rel===`stylesheet`&&typeof n.href==`string`&&typeof n.precedence==`string`){e=Af(n.href);var o=Et(a).hoistableStyles,s=o.get(e);if(s||(a=a.ownerDocument||a,s={type:`stylesheet`,instance:null,count:0,state:{loading:0,preload:null}},o.set(e,s),(o=a.querySelector(jf(e)))&&!o._p&&(s.instance=o,s.state.loading=5),mf.has(e)||(n={rel:`preload`,as:`style`,href:n.href,crossOrigin:n.crossOrigin,integrity:n.integrity,media:n.media,hrefLang:n.hrefLang,referrerPolicy:n.referrerPolicy},mf.set(e,n),o||Nf(a,e,n,s.state))),t&&r===null)throw Error(i(528,``));return s}if(t&&r!==null)throw Error(i(529,``));return null;case`script`:return t=n.async,n=n.src,typeof n==`string`&&t&&typeof t!=`function`&&typeof t!=`symbol`?(t=Pf(n),n=Et(a).hoistableScripts,r=n.get(t),r||(r={type:`script`,instance:null,count:0,state:null},n.set(t,r)),r):{type:`void`,instance:null,count:0,state:null};default:throw Error(i(444,e))}}function Af(e){return`href="`+Kt(e)+`"`}function jf(e){return`link[rel="stylesheet"][`+e+`]`}function Mf(e){return h({},e,{"data-precedence":e.precedence,precedence:null})}function Nf(e,t,n,r){e.querySelector(`link[rel="preload"][as="style"][`+t+`]`)?r.loading=1:(t=e.createElement(`link`),r.preload=t,t.addEventListener(`load`,function(){return r.loading|=1}),t.addEventListener(`error`,function(){return r.loading|=2}),Pd(t,`link`,n),Dt(t),e.head.appendChild(t))}function Pf(e){return`[src="`+Kt(e)+`"]`}function Ff(e){return`script[async]`+e}function If(e,t,n){if(t.count++,t.instance===null)switch(t.type){case`style`:var r=e.querySelector(`style[data-href~="`+Kt(n.href)+`"]`);if(r)return t.instance=r,Dt(r),r;var a=h({},n,{"data-href":n.href,"data-precedence":n.precedence,href:null,precedence:null});return r=(e.ownerDocument||e).createElement(`style`),Dt(r),Pd(r,`style`,a),Lf(r,n.precedence,e),t.instance=r;case`stylesheet`:a=Af(n.href);var o=e.querySelector(jf(a));if(o)return t.state.loading|=4,t.instance=o,Dt(o),o;r=Mf(n),(a=mf.get(a))&&Rf(r,a),o=(e.ownerDocument||e).createElement(`link`),Dt(o);var s=o;return s._p=new Promise(function(e,t){s.onload=e,s.onerror=t}),Pd(o,`link`,r),t.state.loading|=4,Lf(o,n.precedence,e),t.instance=o;case`script`:return o=Pf(n.src),(a=e.querySelector(Ff(o)))?(t.instance=a,Dt(a),a):(r=n,(a=mf.get(o))&&(r=h({},n),zf(r,a)),e=e.ownerDocument||e,a=e.createElement(`script`),Dt(a),Pd(a,`link`,r),e.head.appendChild(a),t.instance=a);case`void`:return null;default:throw Error(i(443,t.type))}else t.type===`stylesheet`&&!(t.state.loading&4)&&(r=t.instance,t.state.loading|=4,Lf(r,n.precedence,e));return t.instance}function Lf(e,t,n){for(var r=n.querySelectorAll(`link[rel="stylesheet"][data-precedence],style[data-precedence]`),i=r.length?r[r.length-1]:null,a=i,o=0;o title`):null)}function Uf(e,t,n){if(n===1||t.itemProp!=null)return!1;switch(e){case`meta`:case`title`:return!0;case`style`:if(typeof t.precedence!=`string`||typeof t.href!=`string`||t.href===``)break;return!0;case`link`:if(typeof t.rel!=`string`||typeof t.href!=`string`||t.href===``||t.onLoad||t.onError)break;switch(t.rel){case`stylesheet`:return e=t.disabled,typeof t.precedence==`string`&&e==null;default:return!0}case`script`:if(t.async&&typeof t.async!=`function`&&typeof t.async!=`symbol`&&!t.onLoad&&!t.onError&&t.src&&typeof t.src==`string`)return!0}return!1}function Wf(e){return!(e.type===`stylesheet`&&!(e.state.loading&3))}function Gf(e,t,n,r){if(n.type===`stylesheet`&&(typeof r.media!=`string`||!1!==matchMedia(r.media).matches)&&!(n.state.loading&4)){if(n.instance===null){var i=Af(r.href),a=t.querySelector(jf(i));if(a){t=a._p,typeof t==`object`&&t&&typeof t.then==`function`&&(e.count++,e=Jf.bind(e),t.then(e,e)),n.state.loading|=4,n.instance=a,Dt(a);return}a=t.ownerDocument||t,r=Mf(r),(i=mf.get(i))&&Rf(r,i),a=a.createElement(`link`),Dt(a);var o=a;o._p=new Promise(function(e,t){o.onload=e,o.onerror=t}),Pd(a,`link`,r),n.instance=a}e.stylesheets===null&&(e.stylesheets=new Map),e.stylesheets.set(n,t),(t=n.state.preload)&&!(n.state.loading&3)&&(e.count++,n=Jf.bind(e),t.addEventListener(`load`,n),t.addEventListener(`error`,n))}}var Kf=0;function qf(e,t){return e.stylesheets&&e.count===0&&Xf(e,e.stylesheets),0Kf?50:800)+t);return e.unsuspend=n,function(){e.unsuspend=null,clearTimeout(r),clearTimeout(i)}}:null}function Jf(){if(this.count--,this.count===0&&(this.imgCount===0||!this.waitingForImages)){if(this.stylesheets)Xf(this,this.stylesheets);else if(this.unsuspend){var e=this.unsuspend;this.unsuspend=null,e()}}}var Yf=null;function Xf(e,t){e.stylesheets=null,e.unsuspend!==null&&(e.count++,Yf=new Map,t.forEach(Zf,e),Yf=null,Jf.call(e))}function Zf(e,t){if(!(t.state.loading&4)){var n=Yf.get(e);if(n)var r=n.get(null);else{n=new Map,Yf.set(e,n);for(var i=e.querySelectorAll(`link[data-precedence],style[data-precedence]`),a=0;a{function n(){if(!(typeof __REACT_DEVTOOLS_GLOBAL_HOOK__>`u`||typeof __REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE!=`function`))try{__REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE(n)}catch(e){console.error(e)}}n(),t.exports=h()})),_=c(u(),1),v=g(),y=(...e)=>e.filter((e,t,n)=>!!e&&e.trim()!==``&&n.indexOf(e)===t).join(` `).trim(),b=e=>e.replace(/([a-z0-9])([A-Z])/g,`$1-$2`).toLowerCase(),x=e=>e.replace(/^([A-Z])|[\s-_]+(\w)/g,(e,t,n)=>n?n.toUpperCase():t.toLowerCase()),ee=e=>{let t=x(e);return t.charAt(0).toUpperCase()+t.slice(1)},S={xmlns:`http://www.w3.org/2000/svg`,width:24,height:24,viewBox:`0 0 24 24`,fill:`none`,stroke:`currentColor`,strokeWidth:2,strokeLinecap:`round`,strokeLinejoin:`round`},C=e=>{for(let t in e)if(t.startsWith(`aria-`)||t===`role`||t===`title`)return!0;return!1},te=(0,_.createContext)({}),ne=()=>(0,_.useContext)(te),re=(0,_.forwardRef)(({color:e,size:t,strokeWidth:n,absoluteStrokeWidth:r,className:i=``,children:a,iconNode:o,...s},c)=>{let{size:l=24,strokeWidth:u=2,absoluteStrokeWidth:d=!1,color:f=`currentColor`,className:p=``}=ne()??{},m=r??d?Number(n??u)*24/Number(t??l):n??u;return(0,_.createElement)(`svg`,{ref:c,...S,width:t??l??S.width,height:t??l??S.height,stroke:e??f,strokeWidth:m,className:y(`lucide`,p,i),...!a&&!C(s)&&{"aria-hidden":`true`},...s},[...o.map(([e,t])=>(0,_.createElement)(e,t)),...Array.isArray(a)?a:[a]])}),w=(e,t)=>{let n=(0,_.forwardRef)(({className:n,...r},i)=>(0,_.createElement)(re,{ref:i,iconNode:t,className:y(`lucide-${b(ee(e))}`,`lucide-${e}`,n),...r}));return n.displayName=ee(e),n},ie=w(`arrow-left`,[[`path`,{d:`m12 19-7-7 7-7`,key:`1l729n`}],[`path`,{d:`M19 12H5`,key:`x3x0zl`}]]),ae=w(`arrow-right`,[[`path`,{d:`M5 12h14`,key:`1ays0h`}],[`path`,{d:`m12 5 7 7-7 7`,key:`xquz4c`}]]),oe=w(`building-2`,[[`path`,{d:`M10 12h4`,key:`a56b0p`}],[`path`,{d:`M10 8h4`,key:`1sr2af`}],[`path`,{d:`M14 21v-3a2 2 0 0 0-4 0v3`,key:`1rgiei`}],[`path`,{d:`M6 10H4a2 2 0 0 0-2 2v7a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2V9a2 2 0 0 0-2-2h-2`,key:`secmi2`}],[`path`,{d:`M6 21V5a2 2 0 0 1 2-2h8a2 2 0 0 1 2 2v16`,key:`16ra0t`}]]),se=w(`external-link`,[[`path`,{d:`M15 3h6v6`,key:`1q9fwt`}],[`path`,{d:`M10 14 21 3`,key:`gplh6r`}],[`path`,{d:`M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6`,key:`a6xqqp`}]]),ce=w(`hard-drive`,[[`path`,{d:`M10 16h.01`,key:`1bzywj`}],[`path`,{d:`M2.212 11.577a2 2 0 0 0-.212.896V18a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-5.527a2 2 0 0 0-.212-.896L18.55 5.11A2 2 0 0 0 16.76 4H7.24a2 2 0 0 0-1.79 1.11z`,key:`18tbho`}],[`path`,{d:`M21.946 12.013H2.054`,key:`zqlbp7`}],[`path`,{d:`M6 16h.01`,key:`1pmjb7`}]]),le=w(`log-out`,[[`path`,{d:`m16 17 5-5-5-5`,key:`1bji2h`}],[`path`,{d:`M21 12H9`,key:`dn1m92`}],[`path`,{d:`M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4`,key:`1uf3rs`}]]),ue=w(`moon`,[[`path`,{d:`M20.985 12.486a9 9 0 1 1-9.473-9.472c.405-.022.617.46.402.803a6 6 0 0 0 8.268 8.268c.344-.215.825-.004.803.401`,key:`kfwtm`}]]),T=w(`sun`,[[`circle`,{cx:`12`,cy:`12`,r:`4`,key:`4exip2`}],[`path`,{d:`M12 2v2`,key:`tus03m`}],[`path`,{d:`M12 20v2`,key:`1lh1kg`}],[`path`,{d:`m4.93 4.93 1.41 1.41`,key:`149t6j`}],[`path`,{d:`m17.66 17.66 1.41 1.41`,key:`ptbguv`}],[`path`,{d:`M2 12h2`,key:`1t8f8n`}],[`path`,{d:`M20 12h2`,key:`1q8mjw`}],[`path`,{d:`m6.34 17.66-1.41 1.41`,key:`1m8zz5`}],[`path`,{d:`m19.07 4.93-1.41 1.41`,key:`1shlcs`}]]),E=class extends Error{issues;status;constructor(e,t=[],n=0){super(e),this.name=`ApiError`,this.issues=t,this.status=n}},de=200;function fe(e){let t=e.replace(/\s+/g,` `).trim();return t.length>de?`${t.slice(0,de)}…`:t}function pe(e){let t=e.headers.get(`content-type`)?.split(`;`,1)[0].trim().toLowerCase();return t===`application/json`||!!t?.endsWith(`+json`)}async function D(e,t){let n=await fetch(e,t);if(n.ok)return await n.json();let r=await n.text(),i;if(pe(n))try{i=JSON.parse(r)}catch{i=void 0}if(i){let e=i.detail,t=typeof e==`object`&&e?`errors`in e&&Array.isArray(e.errors)?e.errors:`code`in e&&`path`in e&&`message`in e?[e]:[]:[];throw new E(typeof e==`string`?e:`Request failed (${n.status})`,t,n.status)}let a=fe(r);throw new E(`Request failed (${n.status})${a?`: ${a}`:``}`,[],n.status)}function O(e,t){return D(e,{method:`POST`,headers:t===void 0?void 0:{"Content-Type":`application/json`},body:t===void 0?void 0:JSON.stringify(t)})}var k=()=>D(`/api/status`),me=e=>O(`/api/auth/login`,e),he=(e=`success`)=>O(`/api/auth/demo?scenario=${encodeURIComponent(e)}`),ge=()=>O(`/api/auth/logout`),_e=()=>D(`/api/discovery/tenants`),ve=e=>D(`/api/discovery/services${e?`?workspace_id=${encodeURIComponent(e)}`:``}`),ye=e=>D(`/api/discovery/services?tenant_ids=${e.map(encodeURIComponent).join(`,`)}`),be=()=>D(`/api/discovery/devices`),xe=()=>D(`/api/discovery/subscriptions`),Se=(e,t)=>O(`/api/import/csv`,{csv_type:`devices`,csv_text:e,tenant_names:t}),Ce=e=>O(`/api/import/csv`,{csv_type:`tenants`,csv_text:e}),we=e=>O(`/api/jobs/plan`,e),Te=e=>O(`/api/jobs/${encodeURIComponent(e)}/confirm`),Ee=e=>D(`/api/jobs/${encodeURIComponent(e)}`),De=()=>D(`/api/jobs`),Oe=e=>O(`/api/jobs/${encodeURIComponent(e)}/stop`),ke=e=>`/api/jobs/${encodeURIComponent(e)}/report.csv`,Ae=o((e=>{var t=Symbol.for(`react.transitional.element`),n=Symbol.for(`react.fragment`);function r(e,n,r){var i=null;if(r!==void 0&&(i=``+r),n.key!==void 0&&(i=``+n.key),`key`in n)for(var a in r={},n)a!==`key`&&(r[a]=n[a]);else r=n;return n=r.ref,{$$typeof:t,type:e,key:i,ref:n===void 0?null:n,props:r}}e.Fragment=n,e.jsx=r,e.jsxs=r})),je=o(((e,t)=>{t.exports=Ae()})),A=je(),Me=`https://developer.arubanetworks.com/new-central/docs/msp-token-exchange#step-1-create-an-api-credential-for-the-greenlake-platform`,Ne={client_id:``,client_secret:``,workspace_id:``};function Pe(e){return e instanceof E?e.message:`Unable to authenticate. Check your connection and try again.`}function Fe({initialError:e,onAuthenticated:t}){let[n,r]=(0,_.useState)(Ne),[i,a]=(0,_.useState)(e??``),[o,s]=(0,_.useState)(!1);(0,_.useEffect)(()=>{Promise.resolve().then(()=>a(e??``))},[e]);let c=(e,t)=>{r(n=>({...n,[e]:t}))},l=async e=>{s(!0),a(``);try{await e(),t()}catch(e){a(Pe(e))}finally{s(!1)}};return(0,A.jsx)(`main`,{className:`login-page`,children:(0,A.jsxs)(`section`,{className:`login-panel`,"aria-labelledby":`login-title`,children:[(0,A.jsx)(`p`,{className:`product-name login-eyebrow`,children:`MSP Onboarding`}),(0,A.jsx)(`h1`,{id:`login-title`,children:`Sign in`}),(0,A.jsx)(`p`,{className:`login-copy`,children:`Use MSP credentials to onboard tenants and assign devices with a preflight before every run.`}),(0,A.jsx)(`p`,{className:`demo-callout`,children:`Evaluating the workflow? Use demo mode below. It uses sample data and makes no live changes.`}),(0,A.jsx)(`p`,{className:`login-help-link`,children:(0,A.jsxs)(`a`,{href:Me,rel:`noopener noreferrer`,target:`_blank`,children:[`Where do I find these credentials?`,(0,A.jsx)(se,{"aria-hidden":`true`,size:14})]})}),(0,A.jsxs)(`form`,{"aria-describedby":i?`login-error`:void 0,className:`login-form`,onSubmit:e=>{e.preventDefault(),l(()=>me(n))},children:[(0,A.jsxs)(`div`,{className:`login-field`,children:[(0,A.jsxs)(`label`,{children:[`Client ID`,(0,A.jsx)(`input`,{"aria-describedby":`client-id-help`,autoComplete:`username`,id:`client-id`,onChange:e=>c(`client_id`,e.target.value),required:!0,value:n.client_id})]}),(0,A.jsx)(`span`,{className:`field-hint`,id:`client-id-help`,children:`From the API client created in your HPE GreenLake MSP workspace.`})]}),(0,A.jsxs)(`div`,{className:`login-field`,children:[(0,A.jsxs)(`label`,{children:[`Client secret`,(0,A.jsx)(`input`,{"aria-describedby":`client-secret-help`,autoComplete:`off`,id:`client-secret`,onChange:e=>c(`client_secret`,e.target.value),required:!0,type:`password`,value:n.client_secret})]}),(0,A.jsx)(`span`,{className:`field-hint`,id:`client-secret-help`,children:`The secret issued with that API client.`})]}),(0,A.jsxs)(`div`,{className:`login-field`,children:[(0,A.jsxs)(`label`,{children:[`MSP workspace ID`,(0,A.jsx)(`input`,{"aria-describedby":`workspace-id-help`,id:`workspace-id`,onChange:e=>c(`workspace_id`,e.target.value),required:!0,value:n.workspace_id})]}),(0,A.jsx)(`span`,{className:`field-hint`,id:`workspace-id-help`,children:`From the MSP workspace details in HPE GreenLake, not a tenant workspace.`})]}),i?(0,A.jsx)(`p`,{className:`form-error`,id:`login-error`,role:`alert`,children:i}):null,(0,A.jsx)(`button`,{"aria-describedby":i?`login-error`:void 0,className:`primary-button`,disabled:o,type:`submit`,children:o?`Signing in…`:`Sign in`})]}),(0,A.jsx)(`div`,{className:`demo-divider`,children:`or explore the guided demo`}),(0,A.jsx)(`button`,{className:`secondary-button`,"aria-describedby":i?`login-error`:void 0,disabled:o,onClick:()=>void l(()=>he()),type:`button`,children:`Use demo mode`})]})})}function Ie(){let e=()=>{let e=document.querySelector(`main h1`);return e?(e.tabIndex=-1,e.focus(),!0):!1};if(e())return()=>void 0;let t=new MutationObserver(()=>{e()&&t.disconnect()});return t.observe(document.body,{childList:!0,subtree:!0}),()=>t.disconnect()}var Le=`msp-onboarding-theme`,Re=e=>e===`dark`||e===`light`,ze=()=>{try{let e=localStorage.getItem(Le);return Re(e)?e:null}catch{return null}},Be=()=>typeof window.matchMedia==`function`&&window.matchMedia(`(prefers-color-scheme: light)`).matches?`light`:`dark`,Ve=()=>ze()??Be(),He=e=>(document.documentElement.dataset.theme=e,e),Ue=()=>He(Ve()),We=e=>{He(e);try{localStorage.setItem(Le,e)}catch{}return e},Ge=()=>{let e=document.documentElement.dataset.theme;return Re(e)?e:Ve()},Ke=()=>{let[e,t]=(0,_.useState)(Ge);return{theme:e,setTheme:(0,_.useCallback)(e=>{t(We(e))},[])}},qe=`modulepreload`,Je=function(e){return`/static/`+e},Ye={},Xe=function(e,t,n){let r=Promise.resolve();if(t&&t.length>0){let e=document.getElementsByTagName(`link`),i=document.querySelector(`meta[property=csp-nonce]`),a=i?.nonce||i?.getAttribute(`nonce`);function o(e){return Promise.all(e.map(e=>Promise.resolve(e).then(e=>({status:`fulfilled`,value:e}),e=>({status:`rejected`,reason:e}))))}function s(e){return import.meta.resolve?import.meta.resolve(e):new URL(e,import.meta.url).href}r=o(t.map(t=>{if(t=Je(t,n),t=s(t),t in Ye)return;Ye[t]=!0;let r=t.endsWith(`.css`);for(let n=e.length-1;n>=0;n--){let i=e[n];if(i.href===t&&(!r||i.rel===`stylesheet`))return}let i=document.createElement(`link`);if(i.rel=r?`stylesheet`:qe,r||(i.as=`script`),i.crossOrigin=``,i.href=t,a&&i.setAttribute(`nonce`,a),document.head.appendChild(i),r)return new Promise((e,n)=>{i.addEventListener(`load`,e),i.addEventListener(`error`,()=>n(Error(`Unable to preload CSS for ${t}`)))})}))}function i(e){let t=new Event(`vite:preloadError`,{cancelable:!0});if(t.payload=e,window.dispatchEvent(t),!t.defaultPrevented)throw e}return r.then(t=>{for(let e of t||[])e.status===`rejected`&&i(e.reason);return e().catch(i)})},Ze=(0,_.lazy)(()=>Xe(()=>import(`./TenantsPage-C1BNyjuJ.js`).then(e=>({default:e.TenantsPage})),__vite__mapDeps([0,1,2]))),Qe=(0,_.lazy)(()=>Xe(()=>import(`./AddDevicesPage-CA5mdvdK.js`).then(e=>({default:e.AddDevicesPage})),__vite__mapDeps([3,1,2])));(0,_.lazy)(()=>Xe(()=>import(`./CsvImportPanel.preview-e06k2viG.js`).then(e=>({default:e.CsvImportPanelPreview})),__vite__mapDeps([4,2])));var $e=`msp-onboarding:active-job:v1`,et=()=>sessionStorage.getItem($e)?.trim()||null;function tt(e){return e instanceof E||e instanceof Error&&e.message.trim()&&!(e instanceof TypeError)?e.message:`Unable to reach the onboarding server. Check that it is running and try again.`}function nt(){let{theme:e,setTheme:t}=Ke(),n=e===`dark`?`light`:`dark`;return(0,A.jsx)(`button`,{"aria-label":`Use ${n} theme`,className:`theme-toggle`,onClick:()=>t(n),type:`button`,children:e===`dark`?(0,A.jsx)(T,{"aria-hidden":`true`,size:18}):(0,A.jsx)(ue,{"aria-hidden":`true`,size:18})})}function rt({onSignOut:e,signingOut:t,status:n}){return(0,A.jsxs)(`header`,{className:`app-header simple-app-header`,children:[(0,A.jsx)(`p`,{className:`product-name`,children:`MSP Onboarding`}),n.demo?(0,A.jsxs)(`span`,{className:`session-badge is-demo`,title:`Demo mode. No live changes will be made.`,children:[`Demo mode`,(0,A.jsx)(`span`,{className:`visually-hidden`,children:`. No live changes will be made.`})]}):null,(0,A.jsxs)(`div`,{className:`header-actions`,children:[(0,A.jsxs)(`button`,{className:`secondary-button toolbar-sign-out`,disabled:t,onClick:e,type:`button`,children:[(0,A.jsx)(le,{"aria-hidden":`true`,size:17}),(0,A.jsx)(`span`,{children:t?`Signing out…`:`Sign out`})]}),(0,A.jsx)(nt,{})]})]})}function it({onOpenDevices:e,onOpenTenants:t}){return(0,A.jsxs)(`main`,{"aria-labelledby":`hub-title`,className:`simple-hub-main`,children:[(0,A.jsx)(`p`,{className:`section-label`,children:`Start a guided run`}),(0,A.jsx)(`h1`,{id:`hub-title`,children:`What needs to happen?`}),(0,A.jsx)(`p`,{className:`hub-intro`,children:`Choose the journey that matches the tenant work in front of you.`}),(0,A.jsxs)(`div`,{className:`simple-journey-list`,children:[(0,A.jsxs)(`button`,{className:`simple-journey-button`,onClick:t,type:`button`,children:[(0,A.jsx)(`span`,{className:`journey-mark`,children:(0,A.jsx)(oe,{"aria-hidden":`true`,size:19})}),(0,A.jsxs)(`span`,{children:[(0,A.jsx)(`strong`,{children:`Onboard tenants`}),(0,A.jsx)(`small`,{children:`Create MSP-owned tenants and provision their applications.`})]}),(0,A.jsx)(ae,{"aria-hidden":`true`,size:20})]}),(0,A.jsxs)(`button`,{className:`simple-journey-button`,onClick:e,type:`button`,children:[(0,A.jsx)(`span`,{className:`journey-mark`,children:(0,A.jsx)(ce,{"aria-hidden":`true`,size:19})}),(0,A.jsxs)(`span`,{children:[(0,A.jsx)(`strong`,{children:`Assign devices to tenants`}),(0,A.jsx)(`small`,{children:`Assign MSP-owned inventory and subscription seats to existing tenants.`})]}),(0,A.jsx)(ae,{"aria-hidden":`true`,size:20})]})]})]})}function at(){return(0,A.jsxs)(`section`,{"aria-live":`polite`,className:`journey-loading`,children:[(0,A.jsx)(`div`,{className:`skeleton skeleton-title`}),(0,A.jsx)(`div`,{className:`skeleton skeleton-line`}),(0,A.jsx)(`div`,{className:`skeleton skeleton-line`})]})}function ot(){let[e,t]=(0,_.useState)(`login`),[n,r]=(0,_.useState)(!0),[i,a]=(0,_.useState)(null),[o,s]=(0,_.useState)(``),[c,l]=(0,_.useState)(!1),[u,d]=(0,_.useState)(et),f=(0,_.useRef)(0),p=(0,_.useCallback)(()=>{let e=++f.current,n=!0,i,o=()=>n&&e===f.current;r(!0),s(``);let c=et(),l=k(),u=De(),p=c?Ee(c):null;return l.then(e=>{o()&&(a(e),e.authenticated?t(i?i.mode===`existing`?`devices`:`tenants`:`hub`):(t(`login`),d(null)),r(!1))}).catch(e=>{o()&&(s(tt(e)),a(null),t(`login`),r(!1))}),(p?p.then(e=>e):u.then(e=>e.find(e=>e.status===`queued`||e.status===`running`))).then(e=>{if(o()){if(!e||![`queued`,`running`].includes(e.status)){c&&sessionStorage.removeItem($e);return}i=e,sessionStorage.setItem($e,e.id),d(e.id),t(e.mode===`existing`?`devices`:`tenants`)}}).catch(()=>{o()&&c&&sessionStorage.removeItem($e)}),u.catch(()=>void 0),()=>{n=!1}},[]);(0,_.useEffect)(()=>{let e=!0,t;return Promise.resolve().then(()=>{e&&(t=p())}),()=>{e=!1,t?.()}},[p]),(0,_.useEffect)(()=>{if(!n)return Ie()},[n,e]);let m=async()=>{f.current+=1,l(!0);try{await ge(),sessionStorage.removeItem($e),d(null),a(null),t(`login`)}catch(e){s(tt(e))}finally{l(!1)}};return n?(0,A.jsx)(`main`,{className:`loading-page`,"aria-live":`polite`,children:(0,A.jsxs)(`section`,{className:`loading-panel`,children:[(0,A.jsx)(`div`,{className:`skeleton skeleton-title`}),(0,A.jsx)(`div`,{className:`skeleton skeleton-line`}),(0,A.jsx)(`div`,{className:`skeleton skeleton-line`})]})}):e===`login`||!i?.authenticated?(0,A.jsx)(Fe,{initialError:o,onAuthenticated:p}):(0,A.jsxs)(`div`,{className:`app-shell simple-app-shell`,children:[(0,A.jsx)(rt,{onSignOut:()=>void m(),signingOut:c,status:i}),e===`hub`?(0,A.jsx)(it,{onOpenDevices:()=>t(`devices`),onOpenTenants:()=>t(`tenants`)}):(0,A.jsxs)(`main`,{className:`simple-journey-main`,children:[(0,A.jsxs)(`button`,{className:`journey-home-link`,onClick:()=>t(`hub`),type:`button`,children:[(0,A.jsx)(ie,{"aria-hidden":`true`,size:16}),` Home`]}),(0,A.jsx)(_.Suspense,{fallback:(0,A.jsx)(at,{}),children:e===`tenants`?(0,A.jsx)(Ze,{demo:i.demo,initialJobId:u,onJobDone:()=>{sessionStorage.removeItem($e),d(null),t(`hub`)},onJobStarted:e=>{sessionStorage.setItem($e,e),d(e)}}):(0,A.jsx)(Qe,{demo:i.demo,initialJobId:u,onJobDone:()=>{sessionStorage.removeItem($e),d(null),t(`hub`)},onJobStarted:e=>{sessionStorage.setItem($e,e),d(e)}})})]})]})}function st(){return(0,A.jsx)(ot,{})}Ue(),(0,v.createRoot)(document.getElementById(`root`)).render((0,A.jsx)(_.StrictMode,{children:(0,A.jsx)(st,{})}));export{m as _,Ee as a,c as b,ve as c,Se as d,Ce as f,w as g,Oe as h,Te as i,xe as l,ke as m,je as n,be as o,we as p,E as r,ye as s,Ie as t,_e as u,u as v,o as y}; \ No newline at end of file diff --git a/msp-onboarding/static/index.html b/msp-onboarding/static/index.html new file mode 100644 index 0000000..ba3cdea --- /dev/null +++ b/msp-onboarding/static/index.html @@ -0,0 +1,14 @@ + + + + + + + MSP Tenant Onboarding + + + + + + + diff --git a/msp-onboarding/verify_live_contract.py b/msp-onboarding/verify_live_contract.py new file mode 100644 index 0000000..5c62695 --- /dev/null +++ b/msp-onboarding/verify_live_contract.py @@ -0,0 +1,162 @@ +"""R1 live-contract prober. + +Runs every read/parse the engine relies on against the real workspace and +reports ALL failures at once, instead of one preflight error per UI round +trip. Optionally (--write) performs the single-device assignment probe. + +Usage, from msp-onboarding/ with token.yaml present: + .venv/bin/python3 verify_live_contract.py --tenant test-tenant-1 \ + --serial CNK4K5104W --key AKJHHDX9BD7JHMA7 + ... --write # phase C: real single-device assignment + poll + MSP_API_LOG=api-verification.log ... # keep the raw wire log too +""" +from __future__ import annotations + +import argparse +import sys +from dataclasses import asdict + +from msp_onboarding.engine import OnboardingEngine +from msp_onboarding.parser import parse_yaml_manifest +from msp_onboarding.pycentral_adapter import PycentralAdapter +from msp_onboarding.store import MemoryStore + +CHECKS: list[tuple[str, bool, str]] = [] + + +def check(name: str, ok: bool, detail: str = "") -> None: + CHECKS.append((name, ok, detail)) + print(f" {'PASS' if ok else 'FAIL'} {name}" + (f" — {detail}" if detail else "")) + + +def main() -> int: + ap = argparse.ArgumentParser() + ap.add_argument("--tenant", required=True, help="tenant workspace name") + ap.add_argument("--serial", required=True, help="device serial number") + ap.add_argument("--key", required=True, help="subscription key") + ap.add_argument("--write", action="store_true", help="run the assignment probe") + args = ap.parse_args() + + adapter = PycentralAdapter("token.yaml") + + print("\n== Phase A: adapter reads, raw vs parsed ==") + tenants = adapter.list_tenants() + tenant = next((t for t in tenants if t.workspace_name == args.tenant), None) + check("tenant found by name", tenant is not None, + f"{len(tenants)} tenants listed") + if tenant is None: + print([t.workspace_name for t in tenants]) + return finish() + print(f" tenant: {asdict(tenant)}") + check("tenant ownership parsed", tenant.ownership == "MSP_OWNED_INVENTORY", + repr(tenant.ownership)) + check("tenant id is 36-char uuid", len(tenant.workspace_id) == 36, + tenant.workspace_id) + + device = adapter.resolve_device_by_serial(args.serial) + print(f" device: {asdict(device)}") + check("device serial matches request", device.serial_number == args.serial) + check("device management parsed", device.management == "MSP", + repr(device.management)) + check("device assigned_state parsed", device.assigned_state == "UNASSIGNED", + repr(device.assigned_state)) + check("device refs are str-or-None", all( + v is None or isinstance(v, str) + for v in (device.in_use_workspace, device.tenant_workspace_id, + device.subscription))) + + subscription = adapter.resolve_subscription(args.key) + print(f" subscription: {asdict(subscription)}") + check("subscription key matches request", subscription.key == args.key) + check("subscription status parsed", subscription.status == "STARTED", + repr(subscription.status)) + check("subscription product_type parsed", + subscription.product_type == "DEVICE", repr(subscription.product_type)) + check("available_quantity is digit string", + subscription.available_quantity.isdigit(), + repr(subscription.available_quantity)) + check("quantity is digit string", subscription.quantity.isdigit(), + repr(subscription.quantity)) + for label, value in (("start_date", subscription.start_date), + ("end_date", subscription.end_date)): + ok = value is None or (len(value) == 10 and value[4] == value[7] == "-") + check(f"subscription {label} is YYYY-MM-DD or absent", ok, repr(value)) + + services = adapter.list_eligible_services(tenant.workspace_id) + print(f" services: {[asdict(s) for s in services]}") + check("at least one eligible service", bool(services)) + if not services: + return finish() + service = services[0] + + print("\n== Phase B: engine.plan preflight, all errors at once ==") + manifest_yaml = f""" +version: 2 +mode: existing +tenants: + - name: {tenant.workspace_name} + workspace_id: {tenant.workspace_id} + service: + service_manager_id: {service.service_manager_id} + region: {service.region} +devices: + - serial_number: {args.serial} + tenant: {tenant.workspace_name} + subscription_key: {args.key} +""" + manifest = parse_yaml_manifest(manifest_yaml) + engine = OnboardingEngine(adapter, MemoryStore()) + plan = engine.plan(manifest) + for error in plan.errors: + print(f" preflight error: {error.path} [{error.code}] {error.message}") + check("preflight clean", not plan.errors, f"{len(plan.errors)} error(s)") + statuses = [(g.tenant_name, g.status) for g in plan.tenant_groups] + check("tenant group runnable", + all(status == "pending" for _, status in statuses), repr(statuses)) + + if args.write and not plan.errors: + print("\n== Phase C: single-device assignment probe ==") + transaction = adapter.assign_devices( + [device.glp_id], tenant.workspace_id, service.service_manager_id, + service.region, + ) + print(f" device transaction accepted: {transaction}") + result = poll(adapter, transaction) + check("device assignment succeeded", device.glp_id in result.succeeded_ids, + f"succeeded={result.succeeded_ids} failed={result.failed_ids}") + transaction = adapter.assign_subscriptions( + [(device.glp_id, subscription.subscription_id)], + ) + print(f" subscription transaction accepted: {transaction}") + result = poll(adapter, transaction) + check("subscription assignment succeeded", + device.glp_id in result.succeeded_ids, + f"succeeded={result.succeeded_ids} failed={result.failed_ids}") + + return finish() + + +def poll(adapter: PycentralAdapter, transaction: str): + import time + + from msp_onboarding.adapter import AdapterError + + for _ in range(30): + try: + return adapter.poll_transaction(transaction) + except AdapterError as exc: + if not exc.retryable: + raise + time.sleep(2) + raise RuntimeError("transaction did not complete within 60s") + + +def finish() -> int: + failed = [name for name, ok, _ in CHECKS if not ok] + print(f"\n{len(CHECKS) - len(failed)}/{len(CHECKS)} checks passed" + + (f"; FAILED: {failed}" if failed else " — all green")) + return 1 if failed else 0 + + +if __name__ == "__main__": + sys.exit(main())
Create tenants
Assign devices to tenants
Authenticate
⇄ Token exchange
MSP API Credentials
Select devices & subscription
Assign devices
Assign subscription
Create tenant
Provision Central service