diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index ab9ddaa..5dfea99 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -212,3 +212,54 @@ project/ - Callouts: `> [!NOTE]`, `> [!TIP]`, `> [!WARNING]` - Language tags on all code blocks - `
` for optional content + +--- + +## ⚠️ CRITICAL: Environment Safety Rules + +> [!WARNING] +> **MANDATORY** rules for all AI agents. Violations will break the development environment. + +### Rule 1: NEVER Kill Node/Vite Processes + +```bash +# ❌ FORBIDDEN - Will shut down the dev container +pkill -f node +pkill -f vite +pkill -9 node +kill $(pgrep -f vite) +``` + +**Why:** The dev container depends on Node.js processes. Killing them terminates the container and disconnects your session. + +**Safe alternatives:** +- Use Ctrl+C in the terminal running the server +- Close the terminal tab +- Let the process run (it doesn't hurt) + +### Rule 2: ALWAYS Use Timeouts + +```bash +# ❌ BAD - Can hang forever +npx playwright test +curl http://localhost:3000 + +# ✅ GOOD - Always wrap with timeout +timeout 120 npx playwright test --reporter=line +timeout 10 curl -s http://localhost:3000 +``` + +**Test timeout requirements:** +- Global: 180s (3 min max total) +- Per-test: 30s +- Per-action: 10s +- Assertions: 5s + +```typescript +// In test files +test.setTimeout(30000); +await page.click('#btn', { timeout: 10000 }); +await expect(loc).toBeVisible({ timeout: 5000 }); +``` + +**Why:** Hanging processes block CI/CD pipelines and waste development time. diff --git a/.github/docs/AzureLocal_NetworkConfTool_Project_Design_Doc.md b/.github/docs/AzureLocal_NetworkConfTool_Project_Design_Doc.md new file mode 100644 index 0000000..f6eba6e --- /dev/null +++ b/.github/docs/AzureLocal_NetworkConfTool_Project_Design_Doc.md @@ -0,0 +1,588 @@ +# Azure Local Network Configuration Tool — Design Document + +**Version:** 3.1 +**Date:** January 30, 2025 +**Status:** Ready for Implementation + +--- + +## Table of Contents + +1. [Overview](#overview) +2. [Repository Structure](#repository-structure) +3. [Deployment Patterns](#deployment-patterns) +4. [Wizard Workflow](#wizard-workflow) +5. [JSON Schema](#json-schema) +6. [Validation Rules](#validation-rules) +7. [Examples](#examples) +8. [Appendix](#appendix) + +--- + +## Overview + +### Purpose + +This tool generates vendor-specific switch configurations for Azure Local deployments. Users fill a web wizard → tool outputs Standard JSON → backend renders vendor `.cfg` files. + +### Scope + +| In Scope | Out of Scope | +|----------|--------------| +| VLANs, interfaces, port-channels | ACLs, NTP/Syslog/SNMP | +| BGP routing, static routes | AAA/RADIUS | +| MLAG/vPC, QoS for RDMA | VXLAN/EVPN | +| Cisco NXOS, Dell OS10 | Server/cluster config | + +### Design Principles + +| Principle | How | +|-----------|-----| +| **Pattern-first** | User selects deployment pattern → drives all defaults | +| **90% coverage** | Minimal required fields cover most scenarios | +| **Vendor neutral** | Same JSON works for Cisco, Dell, etc. | +| **Fail early** | Validate before generating | + +--- + +## Repository Structure + +``` +azure-local-network-config-tool/ +│ +├── .github/ +│ ├── docs/ # Project documentation +│ │ ├── AzureLocal_NetworkConfTool_Project_Design_Doc.md +│ │ └── Project_Roadmap.md +│ └── workflows/ # CI/CD pipelines +│ └── pages.yml +│ +├── backend/ # Python CLI (self-contained) +│ ├── src/ +│ │ ├── cli.py # Entry point +│ │ ├── validator.py # JSON Schema validation +│ │ ├── transformer.py # Data enrichment +│ │ ├── context.py # Template context +│ │ └── renderer.py # Jinja2 rendering +│ ├── schema/ +│ │ └── standard.json # JSON Schema (source of truth) +│ ├── templates/ +│ │ ├── cisco/nxos/*.j2 # Cisco NXOS templates +│ │ └── dellemc/os10/*.j2 # Dell OS10 templates +│ ├── tests/ +│ └── pyproject.toml +│ +├── frontend/ # TypeScript wizard (self-contained) +│ ├── src/ +│ │ ├── main.ts # Entry point +│ │ ├── app.ts # Wizard logic +│ │ ├── types.ts # TypeScript interfaces +│ │ ├── state.ts # State management +│ │ ├── validator.ts # Client-side validation +│ │ └── utils.ts # Helpers +│ ├── examples/ # Sample configs (by pattern) +│ │ ├── switchless/ +│ │ │ └── sample-tor1.json +│ │ ├── switched/ +│ │ │ └── sample-tor1.json +│ │ └── fully-converged/ +│ │ └── sample-tor1.json +│ ├── media/ # Topology images +│ │ └── pattern-*.png +│ ├── index.html +│ ├── style.css +│ ├── package.json +│ ├── tsconfig.json +│ └── vite.config.ts +│ +├── tests/ # E2E tests (Playwright) +│ ├── *.spec.ts # Test specs +│ └── fixtures/ # Test data +│ └── */ # Sample switch configs +│ +├── archive/ # Legacy code reference +├── README.md +├── LICENSE +├── SECURITY.md +├── CODE_OF_CONDUCT.md +├── package.json # Monorepo scripts +└── playwright.config.ts +``` + +### Naming Conventions + +| Element | Convention | Example | +|---------|------------|---------| +| Folders | `kebab-case` | `fully-converged/` | +| TypeScript | `camelCase` | `validateConfig()` | +| JSON files | `kebab-case` | `sample-tor1.json` | +| Documents | `PascalCase_Underscores` | `Project_Roadmap.md` | + +### Monorepo Commands + +```bash +npm run dev # Start frontend dev server +npm run build # Build frontend for production +npm run test # Run Playwright E2E tests +npm run test:ui # Run tests with UI +npm run backend:test # Run Python backend tests +``` + +--- + +## Deployment Patterns + +**The foundation of every configuration.** Pattern selection determines VLANs, port assignments, and validation rules. + +📚 **Reference:** [Azure Local Deployment Patterns](https://github.com/Azure/AzureLocal-Supportability/blob/main/TSG/Networking/Top-Of-Rack-Switch/Overview-Azure-Local-Deployment-Pattern.md) + +### Pattern Comparison + +| Aspect | 🔌 Switchless | 💾 Switched | 🔄 Fully Converged | +|--------|--------------|-------------|--------------------| +| **Use Case** | Edge, cost-sensitive | Enterprise, isolation | General purpose ★ | +| **Storage Traffic** | Direct host-to-host | On switch (dedicated ports) | On switch (shared ports) | +| **VLANs on Switch** | M, C only | M, C, S1 or S2 | M, C, S1, S2 | +| **Storage per ToR** | None | S1→ToR1, S2→ToR2 | Both on both | +| **Host Port VLANs** | `7,201` | M+C: `7,201` / Storage: `711` or `712` | `7,201,711,712` | + +### Pattern Topology Images + +| Pattern | Image URL | +|---------|-----------| +| Switchless | `https://raw.githubusercontent.com/Azure/AzureLocal-Supportability/.../AzureLocalPhysicalNetworkDiagram_Switchless.png` | +| Switched | `https://raw.githubusercontent.com/Azure/AzureLocal-Supportability/.../AzureLocalPhysicalNetworkDiagram_Switched.png` | +| Fully Converged | `https://raw.githubusercontent.com/Azure/AzureLocal-Supportability/.../AzureLocalPhysicalNetworkDiagram_FullyConverged.png` | + +### Critical Rule + +> **Storage VLANs are NEVER on the peer-link** — in any pattern. This prevents storage traffic from crossing between switches. + +--- + +## Wizard Workflow + +### Flow Overview + +```mermaid +flowchart LR + P1["Phase 1
Pattern + Switch"] --> P2["Phase 2
Network"] + P2 --> P3["Phase 3
Routing"] + P3 --> Gen["Generate JSON"] + Gen -.-> Cfg[".cfg files"] +``` + +### What User Provides vs Auto-Generated + +| Phase | User Provides | Auto-Generated | +|-------|---------------|----------------| +| **1. Pattern & Switch** | Pattern (visual), vendor, model, role, hostname | Firmware, port ranges | +| **2. Network** | VLAN IDs/IPs, keepalive IPs | VLAN names, HSRP, peer-link | +| **3. Routing** | ASN, neighbor IPs (or static routes) | Router-ID, prefix lists | + +--- + +### Phase 1: Pattern & Switch + +**Goal:** Select deployment pattern visually, then hardware. + +#### Steps +1. **Select Pattern** — Click visual card (Switchless / Switched / Fully Converged) +2. **Select Hardware** — Vendor dropdown → Model dropdown +3. **Select Role** — TOR1 or TOR2 +4. **Review Hostname** — Auto-filled, user can modify + +#### Output: `switch{}` + +```json +{ + "switch": { + "vendor": "cisco", + "model": "93180YC-FX3", + "firmware": "nxos", + "hostname": "sample-tor1", + "role": "TOR1", + "deployment_pattern": "fully_converged" + } +} +``` + +--- + +### Phase 2: Network + +**Goal:** Define VLANs, assign to ports, configure redundancy. + +Phase 2 has 4 sub-steps: + +| Step | Purpose | User Provides | Auto-Generated | +|------|---------|---------------|----------------| +| **2.1 VLANs** | Define networks | VLAN IDs, IPs, gateways | Names, HSRP config | +| **2.2 Host Ports** | Assign VLANs to ports | Confirm port range, select VLANs | QoS settings | +| **2.3 Redundancy** | vPC peer-link | Keepalive IPs | Port-channel, domain | +| **2.4 Uplinks** | Border connectivity | Uplink IPs, Loopback IP | — | + +#### Output: `vlans[]`, `interfaces[]`, `port_channels[]`, `mlag{}` + +```json +{ + "vlans": [ + { "vlan_id": 7, "name": "Mgmt_7", "purpose": "management", + "interface": { "ip": "192.168.7.2", "cidr": 24, + "redundancy": { "type": "hsrp", "priority": 150, "virtual_ip": "192.168.7.1" }}}, + { "vlan_id": 201, "name": "Compute_201", "purpose": "compute" }, + { "vlan_id": 711, "name": "Storage1_711", "purpose": "storage_1" }, + { "vlan_id": 712, "name": "Storage2_712", "purpose": "storage_2" } + ], + "interfaces": [ + { "name": "Host_Facing", "type": "Trunk", "start_intf": "1/1", "end_intf": "1/16", + "native_vlan": "7", "tagged_vlans": "7,201,711,712", "qos": true }, + { "name": "Loopback0", "type": "L3", "intf": "loopback0", "ipv4": "10.255.255.1/32" }, + { "name": "Uplink", "type": "L3", "intf": "1/49", "ipv4": "10.0.0.2/30" } + ], + "port_channels": [ + { "id": 10, "description": "vPC_Peer_Link", "type": "Trunk", + "tagged_vlans": "7,201", "vpc_peer_link": true, "members": ["1/53", "1/54"] } + ], + "mlag": { + "domain_id": 1, + "peer_keepalive": { "source_ip": "10.255.255.1", "destination_ip": "10.255.255.2", "vrf": "management" } + } +} +``` + +--- + +### Phase 3: Routing + +**Goal:** Configure BGP (recommended) or static routes. + +#### Option A: BGP (Production) + +| User Provides | Auto-Generated | +|---------------|----------------| +| Local ASN | Router-ID (from Loopback) | +| Neighbor IPs + Remote ASNs | Networks to advertise | +| — | Prefix lists | + +#### Option B: Static Routes (Lab/Simple) + +| User Provides | +|---------------| +| Destination networks | +| Next-hop IPs | + +#### Output: `bgp{}` OR `static_routes[]` + +```json +{ + "bgp": { + "asn": 65001, + "router_id": "10.255.255.1", + "networks": ["10.255.255.1/32", "10.0.0.0/30"], + "neighbors": [ + { "ip": "10.0.0.1", "remote_as": 65000, "description": "TO_Border" }, + { "ip": "10.255.255.2", "remote_as": 65001, "description": "iBGP_TOR2" } + ] + } +} +``` + +--- + +### Persistent Pattern Reference (UI Feature) + +**Problem:** Users need to reference the topology diagram while filling forms. + +**Solution:** Sticky sidebar showing selected pattern + thumbnail. Click to expand full image. + +| Component | Behavior | +|-----------|----------| +| **Thumbnail** | 150×100px, always visible in sidebar | +| **Expand** | Click opens lightbox with full resolution | +| **Key Info** | Shows pattern name + storage rule reminder | +| **Change** | Returns to Phase 1 (with confirmation) | + +--- + +## JSON Schema + +### Structure Overview + +```json +{ + "switch": { }, // Phase 1 + "vlans": [ ], // Phase 2.1 + "interfaces": [ ], // Phase 2.2, 2.4 + "port_channels": [ ], // Phase 2.3 + "mlag": { }, // Phase 2.3 + "bgp": { }, // Phase 3 (if BGP) + "static_routes": [ ], // Phase 3 (if static) + "prefix_lists": { } // Phase 3 (BGP only) +} +``` + +### Processing Order + +``` +switch → vlans → interfaces → port_channels → mlag → bgp +``` + +Each section depends on the previous for validation. + +--- + +### Field Reference + +#### `switch` (Required) + +| Field | Required | Type | Description | +|-------|:--------:|------|-------------| +| `vendor` | ✅ | string | `"cisco"` or `"dellemc"` | +| `model` | ✅ | string | e.g., `"93180YC-FX3"` | +| `firmware` | Auto | string | `"nxos"` or `"os10"` | +| `hostname` | ✅ | string | Switch hostname | +| `role` | ✅ | enum | `"TOR1"` or `"TOR2"` | +| `deployment_pattern` | ✅ | enum | `"switchless"`, `"switched"`, `"fully_converged"` | + +#### `vlans[]` + +| Field | Required | Type | Description | +|-------|:--------:|------|-------------| +| `vlan_id` | ✅ | int | 2-4094 | +| `name` | ✅ | string | Max 32 chars | +| `purpose` | ❌ | enum | `"management"`, `"compute"`, `"storage_1"`, `"storage_2"` | +| `shutdown` | ❌ | bool | Default: `false` | +| `interface.ip` | ⚠️ | string | Required for L3 VLANs | +| `interface.cidr` | ⚠️ | int | Required for L3 VLANs | +| `redundancy.type` | ❌ | enum | `"hsrp"` (Cisco) or `"vrrp"` (Dell) | +| `redundancy.virtual_ip` | ⚠️ | string | Gateway IP | +| `redundancy.priority` | ❌ | int | TOR1=150, TOR2=100 | + +#### `interfaces[]` + +| Field | Required | Type | Description | +|-------|:--------:|------|-------------| +| `name` | ✅ | string | Description | +| `type` | ✅ | enum | `"Access"`, `"Trunk"`, `"L3"` | +| `intf` | ⚠️ | string | Single port (e.g., `"1/49"`) | +| `start_intf` / `end_intf` | ⚠️ | string | Port range | +| `native_vlan` | ⚠️ | string | For Trunk type | +| `tagged_vlans` | ⚠️ | string | For Trunk type | +| `ipv4` | ⚠️ | string | For L3 type (CIDR) | +| `qos` | ❌ | bool | Enable RDMA QoS | + +#### `port_channels[]` + +| Field | Required | Type | Description | +|-------|:--------:|------|-------------| +| `id` | ✅ | int | Port-channel ID | +| `description` | ✅ | string | Purpose | +| `type` | ✅ | enum | `"Trunk"` or `"L3"` | +| `members` | ✅ | array | Physical ports | +| `vpc_peer_link` | ❌ | bool | `true` for peer-link | +| `tagged_vlans` | ⚠️ | string | For Trunk type | + +#### `mlag{}` + +| Field | Required | Type | Description | +|-------|:--------:|------|-------------| +| `domain_id` | ❌ | int | Default: 1 | +| `peer_keepalive.source_ip` | ✅ | string | This switch | +| `peer_keepalive.destination_ip` | ✅ | string | Peer switch | +| `peer_keepalive.vrf` | ❌ | string | Default: `"management"` | + +#### `bgp{}` + +| Field | Required | Type | Description | +|-------|:--------:|------|-------------| +| `asn` | ✅ | int | Local AS number | +| `router_id` | ✅ | string | Must match Loopback0 IP | +| `networks` | ❌ | array | Networks to advertise | +| `neighbors[].ip` | ✅ | string | Peer IP | +| `neighbors[].remote_as` | ✅ | int | Peer ASN | +| `neighbors[].description` | ❌ | string | Peer name | + +#### `static_routes[]` + +| Field | Required | Type | Description | +|-------|:--------:|------|-------------| +| `destination` | ✅ | string | CIDR (e.g., `"0.0.0.0/0"`) | +| `next_hop` | ✅ | string | Gateway IP | +| `name` | ❌ | string | Route description | + +--- + +## Validation Rules + +### Pattern-Specific Rules + +| Pattern | Storage VLANs | Host Port VLANs | Peer-link VLANs | +|---------|---------------|-----------------|-----------------| +| **Switchless** | ❌ None | `7,201` only | `7,201` | +| **Switched TOR1** | S1 only | M+C: `7,201`, Storage: `711` | `7,201` | +| **Switched TOR2** | S2 only | M+C: `7,201`, Storage: `712` | `7,201` | +| **Fully Converged** | S1 + S2 | `7,201,711,712` | `7,201` | + +### Cross-Reference Rules + +| From | To | Rule | +|------|----|------| +| `interfaces.tagged_vlans` | `vlans[].vlan_id` | All VLANs must exist | +| `port_channels.members` | `interfaces` | Ports must exist | +| `bgp.router_id` | `interfaces[loopback].ipv4` | Must match | +| `mlag` | `port_channels` | One must have `vpc_peer_link: true` | + +### Business Rules + +| Rule | Description | +|------|-------------| +| No VLAN 1 | Reserved, don't use | +| Parking VLAN | VLAN 2 with `shutdown: true` | +| Routing exclusive | Use BGP OR static_routes, not both | +| **Peer-link no storage** | Storage VLANs never on peer-link | + +--- + +## Examples + +### Example File Structure + +``` +frontend/examples/ +├── switchless/sample-tor1.json +├── switched/sample-tor1.json +└── fully-converged/sample-tor1.json +``` + +### Fully Converged (TOR1) — Complete Example + +```json +{ + "switch": { + "vendor": "cisco", + "model": "93180YC-FX3", + "firmware": "nxos", + "hostname": "sample-tor1-fconv", + "role": "TOR1", + "deployment_pattern": "fully_converged" + }, + "vlans": [ + { "vlan_id": 2, "name": "UNUSED", "purpose": "parking", "shutdown": true }, + { "vlan_id": 7, "name": "Mgmt_7", "purpose": "management", + "interface": { "ip": "192.168.7.2", "cidr": 24, + "redundancy": { "type": "hsrp", "priority": 150, "virtual_ip": "192.168.7.1" }}}, + { "vlan_id": 201, "name": "Compute_201", "purpose": "compute", + "interface": { "ip": "192.168.201.2", "cidr": 24, + "redundancy": { "type": "hsrp", "priority": 150, "virtual_ip": "192.168.201.1" }}}, + { "vlan_id": 711, "name": "Storage1_711", "purpose": "storage_1" }, + { "vlan_id": 712, "name": "Storage2_712", "purpose": "storage_2" } + ], + "interfaces": [ + { "name": "Host_Facing", "type": "Trunk", "intf_type": "Ethernet", + "start_intf": "1/1", "end_intf": "1/16", + "native_vlan": "7", "tagged_vlans": "7,201,711,712", "qos": true }, + { "name": "Loopback0", "type": "L3", "intf_type": "loopback", + "intf": "loopback0", "ipv4": "10.255.255.1/32" }, + { "name": "Uplink_Border1", "type": "L3", "intf_type": "Ethernet", + "intf": "1/49", "ipv4": "10.0.0.2/30" } + ], + "port_channels": [ + { "id": 10, "description": "vPC_Peer_Link_To_TOR2", "type": "Trunk", + "native_vlan": "99", "tagged_vlans": "7,201", + "vpc_peer_link": true, "members": ["1/53", "1/54"] } + ], + "mlag": { + "domain_id": 1, + "peer_keepalive": { + "source_ip": "10.255.255.1", + "destination_ip": "10.255.255.2", + "vrf": "management" + } + }, + "prefix_lists": { + "DefaultRoute": [ + { "seq": 10, "action": "permit", "prefix": "0.0.0.0/0" } + ] + }, + "bgp": { + "asn": 65001, + "router_id": "10.255.255.1", + "networks": ["10.255.255.1/32", "10.0.0.0/30"], + "neighbors": [ + { "ip": "10.0.0.1", "description": "TO_Border1", "remote_as": 65000, + "af_ipv4_unicast": { "prefix_list_in": "DefaultRoute" }}, + { "ip": "10.255.255.2", "description": "iBGP_To_TOR2", "remote_as": 65001, + "af_ipv4_unicast": {} } + ] + } +} +``` + +**Key Points:** +- `tagged_vlans` on host ports: `7,201,711,712` (all VLANs) +- `tagged_vlans` on peer-link: `7,201` (NO storage) +- Cisco uses HSRP, port format `1/1` + +### Pattern Comparison Table + +| JSON Path | Switchless | Switched (TOR1) | Fully Converged | +|-----------|------------|-----------------|-----------------| +| `vlans[]` storage | None | S1 only | S1 + S2 | +| Host `tagged_vlans` | `7,201` | `7,201` + `711` | `7,201,711,712` | +| Peer-link `tagged_vlans` | `7,201` | `7,201` | `7,201` | +| Separate storage ports | No | Yes | No | + +--- + +## Appendix + +### Derived Values (Auto-calculated from Role) + +| Value | TOR1 | TOR2 | +|-------|------|------| +| HSRP priority | 150 | 100 | +| vPC role priority | 1 | 32667 | +| MST priority | 8192 | 16384 | + +### Technology Stack + +| Component | Technology | +|-----------|------------| +| Frontend | TypeScript + Vite | +| Backend | Python + Jinja2 | +| Validation | jsonschema | + +### Template File Structure + +``` +backend/templates/ +├── cisco/nxos/*.j2 +└── dellemc/os10/*.j2 +``` + +### Data Relationships + +``` +switch.deployment_pattern + ↓ determines +vlans[] (which storage VLANs) + ↓ referenced by +interfaces[].tagged_vlans + ↓ ports used by +port_channels[].members + ↓ peer-link for +mlag{} +``` + +``` +interfaces[loopback].ipv4 + ↓ must equal +bgp.router_id + ↓ references +prefix_lists{} +``` + +--- + +**Document End** diff --git a/.github/docs/Project_Roadmap.md b/.github/docs/Project_Roadmap.md new file mode 100644 index 0000000..adae6ab --- /dev/null +++ b/.github/docs/Project_Roadmap.md @@ -0,0 +1,565 @@ +# Azure Local Switch Configuration Wizard — Project Roadmap + +**Version:** 9.0 +**Date:** January 30, 2026 +**Status:** Frontend Redesign (Odin UI Integration) +**Reference:** [Odin for Azure Local](https://neilbird.github.io/Odin-for-AzureLocal/) + +--- + +## Executive Summary + +Complete frontend redesign to match Odin for Azure Local's UI design system while preserving all existing switch configuration logic. The redesign adopts Odin's dark theme, single-page scroll layout, numbered sections, sticky summary sidebar, breadcrumb navigation, theme toggle, and accessibility controls. + +--- + +## Architecture Overview + +``` +┌─────────────────────────────────────────────────────────────────────────────────┐ +│ HEADER: Azure Local Switch Configuration Wizard [📋 Load] [📁 Import] │ +├─────────────────────────────────────────────────────────────────────────────────┤ +│ STATS: 👁️ Page Views: 268 📄 Configs Generated: 45 📦 Exports: 35 │ +├─────────────────────────────────────────────────────────────────────────────────┤ +│ BREADCRUMB: [1 Pattern ✓] › [2 VLANs ✓] › [3 Ports] › [4 Redund] › ... │ +├────────────────────────────────────────────────────┬────────────────────────────┤ +│ │ │ +│ ┌──────────────────────────────────────────────┐ │ ┌──────────────────────┐ │ +│ │ 01 Pattern & Switch │ │ │ Progress 75% 5/7 │ │ +│ │ ┌─────────┐ ┌─────────┐ ┌─────────┐ │ │ │ ████████████░░░░░░░ │ │ +│ │ │Switchless│ │ Switched │ │Fully FC │ │ │ ├──────────────────────┤ │ +│ │ └─────────┘ └─────────┘ └─────────┘ │ │ │ Font: [A-][A+] │ │ +│ │ │ │ │ Theme: [🌙/☀️] │ │ +│ │ Vendor: [▼ Dell EMC ] │ │ ├──────────────────────┤ │ +│ │ Model: [▼ S5248F-ON ] │ │ │ CONFIG SUMMARY │ │ +│ │ │ │ │ │ │ +│ │ Role: [TOR1 ✓] [TOR2] Hostname: [______] │ │ │ Pattern: Fully Conv │ │ +│ └──────────────────────────────────────────────┘ │ │ Vendor: Dell EMC │ │ +│ │ │ Model: S5248F-ON │ │ +│ ┌──────────────────────────────────────────────┐ │ │ Role: TOR1 │ │ +│ │ 02 VLANs │ │ │ Hostname: tor1 │ │ +│ │ │ │ │ │ │ +│ │ Management: [7 ] Name: [Infra_7 ] │ │ │ VLANs: 4 configured │ │ +│ │ Compute: [201 ] Name: [Compute_201] │ │ │ Routing: BGP │ │ +│ │ Storage 1: [711 ] Name: [Storage1_711] │ │ └──────────────────────┘ │ +│ │ Storage 2: [712 ] Name: [Storage2_712] │ │ │ +│ └──────────────────────────────────────────────┘ │ ┌──────────────────────┐ │ +│ │ │ [📋 Load Template ]│ │ +│ ┌──────────────────────────────────────────────┐ │ ├──────────────────────┤ │ +│ │ 03 Host Ports │ │ │[📁Import][💾Export] │ │ +│ │ ... │ │ ├──────────────────────┤ │ +│ └──────────────────────────────────────────────┘ │ │ [ ↺ Start Over ] │ │ +│ │ └──────────────────────┘ │ +│ ... (04 Redundancy, 05 Uplinks, 06 Routing, │ │ +│ 07 Review & Export) │ │ +│ │ │ +└────────────────────────────────────────────────────┴────────────────────────────┘ +``` + +--- + +## Odin UI Design Patterns Analysis + +### 1. Layout Structure + +| Component | Odin Pattern | Implementation | +|-----------|--------------|----------------| +| **Container** | `.layout-flex` with 2 columns | Steps column + Summary column | +| **Steps Column** | `.steps-column` flex:1 | All numbered sections | +| **Summary Column** | `.summary-column` width:460px fixed | Sticky sidebar | +| **Sidebar** | `position: sticky; top: 2rem; max-height: calc(100vh - 4rem); overflow-y: auto` | Own scrollbar, pinned | + +### 2. CSS Variables (Theme System) + +```css +/* Dark Theme (Default) */ +:root { + --bg-dark: #000000; + --card-bg: #111111; + --card-bg-transparent: rgba(17, 17, 17, 0.95); + --text-primary: #ffffff; + --text-secondary: #a1a1aa; + --accent-blue: #0078d4; + --accent-purple: #8b5cf6; + --success: #10b981; + --glass-border: rgba(255, 255, 255, 0.1); + --subtle-bg: rgba(255, 255, 255, 0.03); + --subtle-bg-hover: rgba(255, 255, 255, 0.06); +} + +/* Light Theme (Toggle) */ +body.light-theme { + --bg-dark: #f5f5f7; + --card-bg: #ffffff; + --text-primary: #1a1a1a; + --text-secondary: #6b7280; + --glass-border: rgba(0, 0, 0, 0.1); +} +``` + +### 3. Step/Section Structure + +```html +
+
+ 01 +

Section Title

+
+ +
+``` + +```css +.step { + margin-bottom: 3rem; + background: var(--card-bg); + border: 1px solid var(--glass-border); + border-radius: 16px; + padding: 2rem; +} + +.step-number { + font-size: 0.9rem; + font-weight: 700; + color: var(--accent-blue); + background: rgba(0, 120, 212, 0.1); + padding: 0.25rem 0.75rem; + border-radius: 20px; + border: 1px solid rgba(0, 120, 212, 0.2); +} +``` + +### 4. Option Card Pattern + +```html +
+
...
+

Title

+

Description

+
+``` + +```css +.option-card { + background: rgba(255, 255, 255, 0.03); + border: 1px solid var(--glass-border); + border-radius: 12px; + padding: 1.5rem; + cursor: pointer; + transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); +} + +.option-card:hover { + background: rgba(255, 255, 255, 0.06); + transform: translateY(-2px); +} + +.option-card.selected { + background: rgba(0, 120, 212, 0.1); + border-color: var(--accent-blue); + box-shadow: 0 0 0 1px var(--accent-blue), 0 4px 20px rgba(0, 120, 212, 0.2); +} + +.option-card.selected::after { + content: '✓'; + position: absolute; + top: 8px; right: 8px; + width: 20px; height: 20px; + background: var(--accent-blue); + border-radius: 50%; + color: white; + font-size: 12px; +} +``` + +### 5. Progress Bar + +```html +
+
+
Progress
+
75% • 5/7
+
+
+
+
+
+``` + +```css +.wizard-progress__fill { + height: 100%; + background: linear-gradient(90deg, rgba(0, 120, 212, 0.85), rgba(139, 92, 246, 0.85)); +} +``` + +### 6. Breadcrumb Navigation + +```html + +``` + +### 7. Summary Sidebar Structure + +```html +
+ +
...
+ + +
+ Font: + Theme: +
+ + +

Configuration Summary

+
+
+
Switch
+
+ Pattern + Fully Converged +
+
+
+ + + +
+ + +
+ +
+``` + +### 8. Toast Notifications + +```javascript +function showToast(message, type = 'info', duration = 3000) { + const toast = document.createElement('div'); + toast.className = `toast toast-${type}`; + toast.style.cssText = ` + position: fixed; bottom: 20px; right: 20px; + padding: 12px 20px; + background: ${type === 'success' ? '#10b981' : type === 'error' ? '#ef4444' : '#3b82f6'}; + color: white; border-radius: 8px; + box-shadow: 0 4px 12px rgba(0,0,0,0.3); + z-index: 10000; + `; + document.body.appendChild(toast); + setTimeout(() => toast.remove(), duration); +} +``` + +### 9. State Management Pattern + +```javascript +const state = { + // UI state + theme: 'dark', + fontSize: 'medium', + + // Config state (preserve from current app.ts) + config: { + switch: { vendor, model, firmware, hostname, role, deployment_pattern }, + vlans: [], + interfaces: [], + port_channels: [], + mlag: {}, + bgp: {}, + static_routes: [] + } +}; + +// Auto-save to localStorage +function saveStateToLocalStorage() { + localStorage.setItem('wizardState', JSON.stringify(state)); +} + +// Load on init +function loadStateFromLocalStorage() { + const saved = localStorage.getItem('wizardState'); + if (saved) Object.assign(state, JSON.parse(saved)); +} +``` + +--- + +## Implementation Plan + +### Phase 1: CSS Foundation (Priority: High) + +**File:** `frontend/odin-theme.css` (replace current) + +| Task | Status | +|------|--------| +| Extract Odin CSS variables (dark/light themes) | ⏳ | +| Copy `.step`, `.step-header`, `.step-number` styles | ⏳ | +| Copy `.option-card` with selected/hover states | ⏳ | +| Copy `.layout-flex`, `.steps-column`, `.summary-column` | ⏳ | +| Copy `#summary-panel` sticky sidebar styles | ⏳ | +| Copy `.wizard-progress` progress bar | ⏳ | +| Copy `.breadcrumb-nav` styles | ⏳ | +| Copy `.info-box`, `.hidden`, animations | ⏳ | +| Copy responsive breakpoints (768px, 480px) | ⏳ | +| Add light theme overrides | ⏳ | + +### Phase 2: HTML Restructure (Priority: High) + +**File:** `frontend/index.html` (major rewrite) + +| Task | Status | +|------|--------| +| Add `
` for subtle gradients | ⏳ | +| Add `
- -
-

🖥️ BMC VLAN BMC Switch Only

-
-
- - -
-
- - - Optional - defaults to BMC_{vlan_id} -
+ +
+
+

🖥️ BMC VLAN Optional - Lab/BMC Switch Only

+
-
-
- - + - -
- - -
+ +
-
-

Step 3: Ports & MLAG Configuration

-

Host ports, uplinks, and switch redundancy (Steps 2.2-2.4)

- +
+
+
03
+
+

Host Ports

+

Configure host-facing trunk ports

+
+
+
- -
-

🖥️ Host-Facing Trunk Ports (Step 2.2)

+ +
+ Deployment Pattern: fully_converged
+ Port configuration adapts based on your selected deployment pattern from Step 1 +
+ + +
+

🖥️ Host-Facing Trunk Ports (Fully Converged)

+

All VLANs (Management + Compute + Storage) on same ports

+
- - + +
- - + +
+
+ +
+
+ + + Usually management VLAN +
+
+ + + Auto-populated from Step 2 +
+
- -
-

🌐 Border Uplink Ports - L3 (Step 2.4)

+ + - -
-

🔄 Loopback Interface

+ - -
-

🔗 iBGP Peer-Link Port-Channel

+ + + + +
+
+
- + +
+
+
04
+
+

Redundancy

+

Configure MLAG/VPC for switch redundancy

+
+
+
+
+
-

🔗 Switch Redundancy - MLAG/VPC (Step 2.3)

-

TOR1/TOR2 only. BMC switches skip this section.

+

🔗 Switch Redundancy - MLAG/VPC

+

TOR1/TOR2 only. BMC switches skip this step.

- Peer-Link Port-Channel: ID=101, vpc_peer_link=true
- Members: 1/1/49, 1/1/50, 1/1/51, 1/1/52
- Type: Trunk, native_vlan=99, tagged_vlans from VLANs + Peer-Link Port-Channel (Editable):
+ Loaded from template, but you can adjust if needed. +
+ +
+
+ + +
+
+ + +
- - peer_keepalive.source_ip + + This switch's management IP
- - peer_keepalive.destination_ip + + Peer switch's management IP
+
- - - Default: 1 + + +
+
+ + +
+

🔗 iBGP Peer-Link Port-Channel

+

For iBGP routing between TOR1/TOR2 pair

+ +
+
+ + +
+
+ + +
+
+
+ +
- -
- - -
+
-
-

Step 4: Routing Configuration

-

Configure BGP or static routing (Phase 3)

+
+
+
05
+
+

Routing

+

Configure uplinks, loopback, and routing protocol

+
+
+
+ +
+

🌐 Border Uplink Ports - L3

+

Point-to-point links to border/spine switches. These IPs are used as BGP neighbors.

+ +
+
+ + +
+
+ + +
+
+
+
+ + +
+
+ + +
+
+
+ + +
+

🔄 Loopback Interface

+

Used as BGP router-id

+ +
+ + + Must be /32 (single host) +
+
+
@@ -498,35 +666,22 @@

🔧 BGP Configuration

- +
- - Auto-filled from Loopback0 IP + + Auto-filled from Loopback0, editable if needed

BGP Neighbors

+

Add eBGP neighbors to border/spine switches

+
-
-
-
- - -
-
- - -
-
- - -
-
-
+
- +
@@ -540,26 +695,28 @@

📋 Static Routes

- +
- -
- - -
+
-
-

✅ Configuration Ready

-

Review your configuration and export Standard JSON

+
+
+
06
+
+

Review & Export

+

Review your configuration and export Standard JSON

+
+
+
@@ -575,11 +732,96 @@

📄 Standard JSON Preview

+
+ + + +
- - + diff --git a/frontend/index.html.v1-reference b/frontend/index.html.v1-reference new file mode 100644 index 0000000..40c3708 --- /dev/null +++ b/frontend/index.html.v1-reference @@ -0,0 +1,758 @@ + + + + + + Azure Local Switch Configuration Wizard + + + +
+ +
+

🌐 Azure Local Switch Configuration Wizard

+

Generate Standard JSON and Sample Switch Configs for Azure Local. Save for reference and debugging — always review before applying to production.

+
+ + + +
+
+ + + + + + + + + + + + +
+ +
+

Step 1: Switch Identity

+

Select your switch vendor, model, and role

+ +
+ +
+ +
+
+

Dell EMC

+
+
+

Cisco

+
+
+
+ + +
+ +
+ +
+

S5248F-ON

+
+
+

S5232F-ON

+
+ + + +
+
+ + +
+ +
+
+

🔵 TOR1

+
+
+

🟢 TOR2

+
+
+

🟠 BMC

+ Lab/Optional +
+
+
+ + +
+ + +
+ + +
+ +
+
+

🔄 Fully Converged

+
+
+

💾 Storage Switched

+
+
+

🔌 Switchless

+
+
+
+
+ +
+
+ +
+
+ + +
+

Step 2: VLAN Configuration

+

Define your network VLANs

+ +
+ +
+

🚫 Unused Port VLAN (Shutdown)

+
+
+ + + Unused ports assigned here (shutdown=true) +
+
+
+ + +
+
+

📡 Management VLANs *

+ +
+
+
+
+

Management VLAN #1

+
+
+
+ + +
+
+ + + Optional - defaults to Infra_{vlan_id} +
+
+
+
+ + +
+
+ + +
+
+
+ + +
+
+
+
+ + +
+
+

💻 Compute VLANs *

+ +
+
+
+
+

Compute VLAN #1

+
+
+
+ + +
+
+ + + Optional - defaults to Compute_{vlan_id} +
+
+
+
+ + +
+
+ + +
+
+
+ + +
+
+
+
+ + +
+

💾 Storage VLANs L2 Only

+
+
+ + +
+
+ + + Optional - defaults to Storage1_{vlan_id} +
+
+
+
+ + +
+
+ + + Optional - defaults to Storage2_{vlan_id} +
+
+
+ + +
+
+

🖥️ BMC VLAN Optional - Lab/BMC Switch Only

+ +
+ +
+
+ +
+ + +
+
+ + +
+

Step 3: Host Port Assignment

+

Configure host-facing trunk ports (Step 2.2)

+ +
+ +
+ Deployment Pattern: fully_converged
+ Port configuration adapts based on your selected deployment pattern from Step 1 +
+ + +
+

🖥️ Host-Facing Trunk Ports (Fully Converged)

+

All VLANs (Management + Compute + Storage) on same ports

+ +
+
+ + +
+
+ + +
+
+ +
+ +
+ +
+
+ + + Usually management VLAN +
+
+ + + Auto-populated from Step 2 +
+
+
+ + + + + + + + +
+ +
+ + +
+
+ + +
+

Step 4: Redundancy Configuration

+

Configure MLAG/VPC for switch redundancy (Step 2.3)

+ +
+ +
+

🔗 Switch Redundancy - MLAG/VPC

+

TOR1/TOR2 only. BMC switches skip this step.

+ +
+ Peer-Link Port-Channel:
+ • Port-Channel ID: 101
+ • Members: 1/1/49, 1/1/50, 1/1/51, 1/1/52
+ • Type: Trunk (vpc_peer_link=true)
+ • Auto-configured from template +
+ +
+
+ + + This switch's management IP +
+
+ + + Peer switch's management IP +
+
+ +
+ + +
+
+ + +
+

🔗 iBGP Peer-Link Port-Channel

+

For iBGP routing between TOR1/TOR2 pair

+ +
+
+ + +
+
+ + +
+
+
+ + +
+
+
+ +
+ + +
+
+ + +
+

Step 5: Uplink Configuration

+

Configure border uplinks and loopback (Step 2.4)

+ +
+ +
+

🌐 Border Uplink Ports - L3

+

Point-to-point links to border/spine switches

+ +
+
+ + +
+
+ + +
+
+
+
+ + +
+
+ + +
+
+
+ + +
+

🔄 Loopback Interface

+

Used as BGP router-id in next step

+ +
+ + + Must be /32 (single host) +
+
+
+ +
+ + +
+
+ + +
+

Step 6: Routing Configuration

+

Configure BGP or static routing (Phase 3)

+ +
+ +
+ +
+
+

📡 BGP

+
+
+

📋 Static

+
+
+
+ + +
+

🔧 BGP Configuration

+
+
+ + +
+
+ + + Auto-filled from Loopback0 IP +
+
+ +

BGP Neighbors

+
+
+
+
+ + +
+
+ + +
+
+ + +
+
+
+
+ +
+ + + +
+ +
+ + +
+
+ + +
+

✅ Configuration Ready

+

Review your configuration and export Standard JSON

+ +
+
+
+ +
+

📄 Standard JSON Preview

+

+                
+ +
+ + + +
+
+
+
+ + + + diff --git a/frontend/media/pattern-fully-converged.png b/frontend/media/pattern-fully-converged.png new file mode 100644 index 0000000..5d80232 Binary files /dev/null and b/frontend/media/pattern-fully-converged.png differ diff --git a/frontend/media/pattern-switched.png b/frontend/media/pattern-switched.png new file mode 100644 index 0000000..c8d9863 Binary files /dev/null and b/frontend/media/pattern-switched.png differ diff --git a/frontend/media/pattern-switchless.png b/frontend/media/pattern-switchless.png new file mode 100644 index 0000000..4cd099e Binary files /dev/null and b/frontend/media/pattern-switchless.png differ diff --git a/frontend/odin-theme.css b/frontend/odin-theme.css new file mode 100644 index 0000000..d563ad1 --- /dev/null +++ b/frontend/odin-theme.css @@ -0,0 +1,1949 @@ +/* Azure Local Switch Config Wizard - Odin Dark Theme + Inspired by https://neilbird.github.io/Odin-for-AzureLocal/ + ================================================================ */ + +* { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +:root { + /* Odin Dark Theme Colors */ + --bg-primary: #0a0f1a; + --bg-secondary: #111827; + --bg-card: #1a2332; + --bg-card-hover: #243044; + --bg-selected: #1e3a5f; + --border-color: #2d3748; + --border-selected: #3b82f6; + --text-primary: #f1f5f9; + --text-secondary: #94a3b8; + --text-muted: #64748b; + --accent-blue: #3b82f6; + --accent-green: #22c55e; + --accent-cyan: #06b6d4; + --accent-purple: #8b5cf6; + --accent-orange: #f97316; + --success: #22c55e; + --warning: #eab308; + --error: #ef4444; + + /* Font sizing for A-/A+ controls */ + --font-size-base: 14px; + --font-size-small: 12px; + --font-size-large: 16px; +} + +/* Light Theme Override - Better Contrast */ +body.light-theme { + --bg-primary: #e8ecf2; + --bg-secondary: #f0f4f8; + --bg-card: #ffffff; + --bg-card-hover: #e2e8f0; + --bg-selected: #c7d8f0; + --border-color: #c8d1db; + --border-selected: #2563eb; + --text-primary: #0f172a; + --text-secondary: #334155; + --text-muted: #475569; + --accent-blue: #2563eb; + --accent-green: #16a34a; + --accent-purple: #7c3aed; +} + +body { + font-family: 'Inter', 'Segoe UI', system-ui, -apple-system, sans-serif; + background: var(--bg-primary); + min-height: 100vh; + color: var(--text-primary); + line-height: 1.6; + font-size: var(--font-size-base); +} + +/* Font size variations - scale everything */ +body.font-small { + --font-size-base: 12px; + --font-size-small: 10px; + --font-size-large: 14px; +} +body.font-small h1, body.font-small h2, body.font-small h3, body.font-small h4 { + font-size: 90%; +} + +body.font-large { + --font-size-base: 16px; + --font-size-small: 14px; + --font-size-large: 20px; +} +body.font-large h1, body.font-large h2, body.font-large h3, body.font-large h4 { + font-size: 115%; +} +body.font-large .form-group label, +body.font-large .form-group input, +body.font-large .form-group select, +body.font-large p, +body.font-large span { + font-size: var(--font-size-base); +} + +/* ================================================================ + HEADER BAR WITH THEME/FONT CONTROLS (Odin Style) + ================================================================ */ +.header-bar { + position: sticky; + top: 0; + z-index: 100; + background: var(--bg-secondary); + border-bottom: 1px solid var(--border-color); + padding: 12px 24px; + display: flex; + align-items: center; + justify-content: space-between; +} + +.header-left { + display: flex; + align-items: center; + gap: 16px; +} + +.header-title { + font-size: 18px; + font-weight: 600; + color: var(--text-primary); +} + +.header-subtitle { + font-size: var(--font-size-small); + color: var(--text-muted); +} + +.header-right { + display: flex; + align-items: center; + gap: 12px; +} + +/* Font size controls */ +.font-controls { + display: flex; + align-items: center; + gap: 4px; + background: var(--bg-card); + border: 1px solid var(--border-color); + border-radius: 6px; + padding: 4px 8px; +} + +.font-btn { + width: 28px; + height: 28px; + background: transparent; + border: none; + border-radius: 4px; + color: var(--text-secondary); + font-size: 14px; + font-weight: 600; + cursor: pointer; + transition: all 0.2s; +} + +.font-btn:hover { + background: var(--bg-card-hover); + color: var(--text-primary); +} + +/* Theme toggle */ +.theme-toggle { + width: 36px; + height: 36px; + background: var(--bg-card); + border: 1px solid var(--border-color); + border-radius: 6px; + color: var(--text-secondary); + font-size: 18px; + cursor: pointer; + transition: all 0.2s; + display: flex; + align-items: center; + justify-content: center; +} + +.theme-toggle:hover { + background: var(--bg-card-hover); + color: var(--accent-blue); +} + +/* ================================================================ + BREADCRUMB NAVIGATION (Odin Style) - STICKY + ================================================================ */ +.breadcrumb-nav { + display: flex; + align-items: center; + gap: 8px; + padding: 12px 24px; + background: var(--bg-secondary); + border-bottom: 1px solid var(--border-color); + flex-wrap: wrap; + /* Sticky positioning */ + position: sticky; + top: 60px; /* Below header */ + z-index: 90; + backdrop-filter: blur(8px); +} + +.breadcrumb-item { + display: flex; + align-items: center; + gap: 6px; + padding: 8px 16px; + background: var(--bg-card); + border: 2px solid var(--border-color); + border-radius: 24px; + color: var(--text-secondary); + font-size: 13px; + font-weight: 500; + cursor: pointer; + transition: all 0.3s ease; + text-decoration: none; +} + +.breadcrumb-item:hover { + background: var(--bg-card-hover); + color: var(--text-primary); + transform: translateY(-1px); +} + +.breadcrumb-item.active { + background: var(--accent-blue); + border-color: var(--accent-blue); + color: white; + box-shadow: 0 4px 12px rgba(59, 130, 246, 0.3); +} + +/* Completed step - green with checkmark */ +.breadcrumb-item.completed { + background: rgba(34, 197, 94, 0.15); + border-color: var(--accent-green); + color: var(--accent-green); +} + +.breadcrumb-item.completed::before { + content: "✓"; + font-weight: bold; + margin-right: 2px; +} + +/* Completed AND active */ +.breadcrumb-item.completed.active { + background: var(--accent-green); + border-color: var(--accent-green); + color: white; + box-shadow: 0 4px 12px rgba(34, 197, 94, 0.3); +} + +.breadcrumb-separator { + color: var(--text-muted); + font-size: 14px; +} + +/* ================================================================ + TOP NAVIGATION BAR (Step Pills) - Legacy Support + ================================================================ */ +.top-nav-bar { + position: sticky; + top: 0; + z-index: 100; + background: var(--bg-secondary); + border-bottom: 1px solid var(--border-color); + padding: 12px 20px; + display: flex; + gap: 8px; + flex-wrap: wrap; + justify-content: center; +} + +.nav-pill { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 6px 14px; + background: var(--bg-card); + border: 1px solid var(--border-color); + border-radius: 20px; + color: var(--text-secondary); + font-size: 13px; + font-weight: 500; + cursor: pointer; + transition: all 0.2s; +} + +.nav-pill:hover { + background: var(--bg-card-hover); + color: var(--text-primary); +} + +.nav-pill.active { + background: var(--accent-blue); + border-color: var(--accent-blue); + color: white; +} + +.nav-pill.completed { + background: transparent; + border-color: var(--accent-green); + color: var(--accent-green); +} + +.nav-pill.completed::before { + content: "✓"; + margin-right: 4px; +} + +/* ================================================================ + MAIN LAYOUT + ================================================================ */ +.main-layout { + display: flex; + min-height: calc(100vh - 60px); +} + +.content-area { + flex: 1; + padding: 30px 40px; + max-width: 900px; + margin: 0 auto; +} + +/* ================================================================ + HEADER SECTION + ================================================================ */ +.header-section { + text-align: center; + padding: 40px 20px; + border-bottom: 1px solid var(--border-color); + margin-bottom: 30px; +} + +.header-section h1 { + font-size: 32px; + font-weight: 700; + color: var(--text-primary); + margin-bottom: 16px; +} + +.header-section .subtitle { + font-size: 16px; + color: var(--text-secondary); + max-width: 700px; + margin: 0 auto 24px; + line-height: 1.6; +} + +.header-actions { + display: flex; + gap: 12px; + justify-content: center; + flex-wrap: wrap; +} + +.header-btn { + display: inline-flex; + align-items: center; + gap: 8px; + padding: 10px 20px; + background: var(--bg-card); + border: 1px solid var(--border-color); + border-radius: 8px; + color: var(--text-primary); + font-size: 14px; + font-weight: 500; + cursor: pointer; + transition: all 0.2s; +} + +.header-btn:hover { + background: var(--bg-card-hover); + border-color: var(--accent-blue); +} + +.disclaimer { + background: rgba(234, 179, 8, 0.1); + border: 1px solid rgba(234, 179, 8, 0.3); + border-radius: 8px; + padding: 12px 20px; + margin: 20px auto; + max-width: 900px; + font-size: 13px; + color: var(--warning); +} + +/* ================================================================ + MODULE SECTIONS (Numbered Cards - Odin Style) + ================================================================ */ +.module-section, +.wizard-step { + background: var(--bg-secondary); + border: 1px solid var(--border-color); + border-radius: 12px; + margin-bottom: 24px; + overflow: hidden; +} + +.module-header, +.step-header { + display: flex; + align-items: center; + gap: 16px; + padding: 20px 24px; + border-bottom: 1px solid var(--border-color); + background: var(--bg-card); +} + +.module-number, +.step-number { + width: 40px; + height: 40px; + background: linear-gradient(135deg, var(--accent-blue) 0%, var(--accent-purple) 100%); + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + font-size: 16px; + font-weight: 700; + color: white; + flex-shrink: 0; +} + +.module-number.completed, +.step-number.completed { + background: linear-gradient(135deg, var(--accent-green) 0%, #10b981 100%); +} + +.module-title, +.step-title { + flex: 1; +} + +.module-title h2, +.step-title h2 { + font-size: 18px; + font-weight: 600; + color: var(--text-primary); + margin-bottom: 4px; +} + +.module-title p, +.step-title p { + font-size: 13px; + color: var(--text-secondary); +} + +.module-help, +.step-help { + color: var(--accent-blue); + font-size: 13px; + text-decoration: none; +} + +.module-help:hover, +.step-help:hover { + text-decoration: underline; +} + +.module-content, +.step-content { + padding: 24px; +} + +/* ================================================================ + OPTION CARDS (Selection Cards) + ================================================================ */ +.option-cards { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); + gap: 16px; +} + +.option-card { + background: var(--bg-card); + border: 2px solid var(--border-color); + border-radius: 12px; + padding: 20px; + cursor: pointer; + transition: all 0.2s; + position: relative; +} + +.option-card:hover { + background: var(--bg-card-hover); + border-color: var(--text-muted); +} + +.option-card.selected { + background: var(--bg-selected); + border-color: var(--accent-blue); +} + +.option-card.selected::after { + content: ""; + position: absolute; + top: 12px; + right: 12px; + width: 24px; + height: 24px; + background: var(--accent-blue); + border-radius: 50%; + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='white' viewBox='0 0 24 24'%3E%3Cpath d='M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z'/%3E%3C/svg%3E"); + background-size: 16px; + background-position: center; + background-repeat: no-repeat; +} + +.option-card .card-icon { + font-size: 28px; + margin-bottom: 12px; +} + +.option-card h3 { + font-size: 15px; + font-weight: 600; + color: var(--text-primary); + margin-bottom: 8px; +} + +.option-card p { + font-size: 13px; + color: var(--text-secondary); + line-height: 1.5; +} + +.option-card .tag { + display: inline-block; + margin-top: 12px; + padding: 4px 10px; + background: rgba(59, 130, 246, 0.15); + border-radius: 12px; + font-size: 11px; + color: var(--accent-blue); + font-weight: 500; +} + +.option-card.recommended::before { + content: "RECOMMENDED"; + position: absolute; + top: -1px; + left: 20px; + padding: 4px 10px; + background: var(--accent-green); + color: white; + font-size: 10px; + font-weight: 700; + border-radius: 0 0 6px 6px; +} + +/* ================================================================ + FORM ELEMENTS + ================================================================ */ +.form-row { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); + gap: 16px; + margin-bottom: 16px; +} + +.form-group { + margin-bottom: 16px; +} + +.form-group label { + display: block; + font-size: 13px; + font-weight: 600; + color: var(--text-secondary); + margin-bottom: 8px; + text-transform: uppercase; + letter-spacing: 0.5px; +} + +.form-group label .required { + color: var(--error); +} + +.form-group input, +.form-group select { + width: 100%; + padding: 12px 14px; + background: var(--bg-card); + border: 1px solid var(--border-color); + border-radius: 8px; + color: var(--text-primary); + font-size: 14px; + transition: all 0.2s; +} + +.form-group input:focus, +.form-group select:focus { + outline: none; + border-color: var(--accent-blue); + box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.15); +} + +.form-group input::placeholder { + color: var(--text-muted); +} + +.form-group small { + display: block; + margin-top: 6px; + font-size: 12px; + color: var(--text-muted); +} + +.form-group select { + cursor: pointer; + appearance: none; + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='%2394a3b8' viewBox='0 0 24 24'%3E%3Cpath d='M7 10l5 5 5-5z'/%3E%3C/svg%3E"); + background-repeat: no-repeat; + background-position: right 12px center; + background-size: 20px; + padding-right: 40px; +} + +.form-group select option { + background: var(--bg-secondary); + color: var(--text-primary); +} + +/* ================================================================ + COMPACT HARDWARE SECTION + ================================================================ */ +.hardware-compact { + background: var(--bg-card); + border: 1px solid var(--border-color); + border-radius: 12px; + padding: 20px; + margin-bottom: 24px; +} + +.hardware-compact h3 { + margin-bottom: 16px; + font-size: 16px; +} + +.hardware-grid { + display: grid; + grid-template-columns: repeat(4, 1fr); + gap: 16px; + align-items: end; +} + +.hardware-grid .form-group { + margin-bottom: 0; +} + +.hardware-grid .form-group label { + font-size: 12px; + margin-bottom: 6px; +} + +/* Role buttons in compact layout */ +.role-buttons { + display: flex; + gap: 8px; +} + +.role-btn { + flex: 1; + padding: 10px 12px; + border: 2px solid var(--border-color); + border-radius: 8px; + background: var(--bg-card); + color: var(--text-primary); + font-size: 13px; + font-weight: 500; + cursor: pointer; + transition: all 0.2s ease; +} + +.role-btn:hover { + border-color: var(--accent-blue); + background: var(--bg-card-hover); +} + +.role-btn.selected { + border-color: var(--accent-blue); + background: var(--bg-selected); + color: var(--accent-blue); +} + +/* Responsive: Stack on smaller screens */ +@media (max-width: 1200px) { + .hardware-grid { + grid-template-columns: repeat(2, 1fr); + } +} + +@media (max-width: 768px) { + .hardware-grid { + grid-template-columns: 1fr; + } +} + +/* Checkbox styling */ +.checkbox-label { + display: flex; + align-items: center; + gap: 10px; + cursor: pointer; + font-size: 14px; + color: var(--text-primary); +} + +.checkbox-label input[type="checkbox"] { + width: 18px; + height: 18px; + accent-color: var(--accent-blue); +} + +/* ================================================================ + CONFIGURATION SUMMARY SIDEBAR (Odin Sticky Style) + ================================================================ */ +.config-summary-sidebar { + width: 340px; + flex-shrink: 0; + background: var(--bg-secondary); + border-left: 1px solid var(--border-color); + position: sticky; + top: 60px; + height: calc(100vh - 60px); + overflow-y: auto; + /* Custom scrollbar styling */ + scrollbar-width: thin; + scrollbar-color: var(--border-color) var(--bg-secondary); +} + +.config-summary-sidebar::-webkit-scrollbar { + width: 8px; +} + +.config-summary-sidebar::-webkit-scrollbar-track { + background: var(--bg-secondary); +} + +.config-summary-sidebar::-webkit-scrollbar-thumb { + background: var(--border-color); + border-radius: 4px; +} + +.config-summary-sidebar::-webkit-scrollbar-thumb:hover { + background: var(--text-muted); +} + +.sidebar-header { + padding: 20px; + border-bottom: 1px solid var(--border-color); + background: var(--bg-card); +} + +.sidebar-header h3 { + font-size: 14px; + font-weight: 600; + color: var(--text-primary); + margin-bottom: 16px; +} + +.progress-indicator { + display: flex; + align-items: center; + gap: 12px; +} + +.progress-bar { + flex: 1; + height: 8px; + background: var(--bg-primary); + border-radius: 4px; + overflow: hidden; +} + +.progress-fill { + height: 100%; + background: linear-gradient(90deg, var(--accent-blue) 0%, var(--accent-purple) 50%, var(--accent-green) 100%); + transition: width 0.3s ease; + border-radius: 4px; +} + +.progress-text { + font-size: 13px; + font-weight: 600; + color: var(--accent-green); + min-width: 50px; + text-align: right; +} + +/* Section quick links in sidebar */ +.sidebar-nav { + padding: 12px 20px; + border-bottom: 1px solid var(--border-color); +} + +.sidebar-nav-title { + font-size: 10px; + font-weight: 700; + color: var(--text-muted); + text-transform: uppercase; + letter-spacing: 1px; + margin-bottom: 8px; +} + +.sidebar-nav-links { + display: flex; + flex-direction: column; + gap: 4px; +} + +.sidebar-nav-link { + display: flex; + align-items: center; + gap: 8px; + padding: 8px 12px; + background: transparent; + border: none; + border-radius: 6px; + color: var(--text-secondary); + font-size: 13px; + cursor: pointer; + transition: all 0.2s; + text-align: left; + width: 100%; +} + +.sidebar-nav-link:hover { + background: var(--bg-card); + color: var(--text-primary); +} + +.sidebar-nav-link.active { + background: var(--bg-selected); + color: var(--accent-blue); +} + +.sidebar-nav-link .nav-number { + width: 24px; + height: 24px; + background: var(--bg-card); + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + font-size: 11px; + font-weight: 600; + flex-shrink: 0; +} + +.sidebar-nav-link.completed .nav-number { + background: var(--accent-green); + color: white; +} + +.sidebar-content { + padding: 16px 20px; +} + +.summary-section { + margin-bottom: 20px; + padding-bottom: 20px; + border-bottom: 1px solid var(--border-color); +} + +.summary-section:last-child { + margin-bottom: 0; + padding-bottom: 0; + border-bottom: none; +} + +.summary-section h4 { + font-size: 11px; + font-weight: 700; + color: var(--accent-cyan); + text-transform: uppercase; + letter-spacing: 1px; + margin-bottom: 12px; +} + +.summary-grid { + display: grid; + grid-template-columns: auto 1fr; + gap: 8px 16px; + font-size: 13px; +} + +.summary-label { + color: var(--text-muted); +} + +.summary-value { + color: var(--text-primary); + font-weight: 500; + text-align: right; + word-break: break-word; +} + +/* ================================================================ + BUTTONS + ================================================================ */ +.btn-group { + display: flex; + justify-content: space-between; + gap: 16px; + margin-top: 24px; + padding-top: 24px; + border-top: 1px solid var(--border-color); +} + +.btn-primary { + padding: 12px 28px; + background: var(--accent-blue); + border: none; + border-radius: 8px; + color: white; + font-size: 14px; + font-weight: 600; + cursor: pointer; + transition: all 0.2s; +} + +.btn-primary:hover { + background: #2563eb; + transform: translateY(-1px); +} + +.btn-secondary { + padding: 12px 28px; + background: var(--bg-card); + border: 1px solid var(--border-color); + border-radius: 8px; + color: var(--text-primary); + font-size: 14px; + font-weight: 500; + cursor: pointer; + transition: all 0.2s; +} + +.btn-secondary:hover { + background: var(--bg-card-hover); +} + +.btn-add, +.btn-add-small { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 8px; + padding: 12px 20px; + background: linear-gradient(135deg, rgba(59, 130, 246, 0.1) 0%, rgba(139, 92, 246, 0.1) 100%); + border: 1px solid rgba(59, 130, 246, 0.3); + border-radius: 10px; + color: var(--accent-blue); + font-size: 13px; + font-weight: 600; + cursor: pointer; + transition: all 0.3s ease; +} + +.btn-add:hover, +.btn-add-small:hover { + background: linear-gradient(135deg, rgba(59, 130, 246, 0.2) 0%, rgba(139, 92, 246, 0.2) 100%); + border-color: var(--accent-blue); + transform: translateY(-2px); + box-shadow: 0 4px 12px rgba(59, 130, 246, 0.2); +} + +.btn-add::before, +.btn-add-small::before { + content: "+"; + font-size: 16px; + font-weight: bold; +} + +/* Section header with add button */ +.section-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 16px; +} + +.section-header h3 { + margin: 0; +} + +/* ================================================================ + MESSAGES & ALERTS + ================================================================ */ +.message { + padding: 14px 20px; + border-radius: 8px; + margin: 16px 0; + font-size: 14px; +} + +.message.error-message { + background: rgba(239, 68, 68, 0.1); + border: 1px solid rgba(239, 68, 68, 0.3); + color: var(--error); +} + +.message.success-message { + background: rgba(34, 197, 94, 0.1); + border: 1px solid rgba(34, 197, 94, 0.3); + color: var(--success); +} + +.info-box { + background: rgba(59, 130, 246, 0.1); + border: 1px solid rgba(59, 130, 246, 0.3); + border-radius: 8px; + padding: 14px 18px; + margin-bottom: 20px; + font-size: 13px; + color: var(--text-secondary); +} + +/* Section help text and links */ +.section-help { + font-size: 13px; + color: var(--text-secondary); + margin-bottom: 16px; +} + +.section-link { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 4px 12px; + margin-left: 8px; + background: var(--accent-blue); + color: white; + text-decoration: none; + font-size: 12px; + font-weight: 500; + border-radius: 16px; + transition: all 0.2s; +} + +.section-link:hover { + background: var(--accent-purple); + transform: translateY(-1px); +} + +body.light-theme .section-link { + background: #2563eb; + color: white; +} + +body.light-theme .section-link:hover { + background: #1d4ed8; +} + +/* ================================================================ + MODAL + ================================================================ */ +.modal { + display: none; + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + background: rgba(0, 0, 0, 0.8); + z-index: 1000; + align-items: center; + justify-content: center; +} + +.modal-content { + background: var(--bg-secondary); + border: 1px solid var(--border-color); + border-radius: 16px; + max-width: 700px; + width: 90%; + max-height: 80vh; + overflow-y: auto; +} + +.modal-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 20px 24px; + border-bottom: 1px solid var(--border-color); +} + +.modal-header h2 { + font-size: 18px; + color: var(--text-primary); +} + +.modal-close { + width: 32px; + height: 32px; + background: var(--bg-card); + border: none; + border-radius: 8px; + color: var(--text-secondary); + font-size: 20px; + cursor: pointer; + transition: all 0.2s; +} + +.modal-close:hover { + background: var(--bg-card-hover); + color: var(--text-primary); +} + +.modal-description { + padding: 16px 24px; + font-size: 14px; + color: var(--text-secondary); + border-bottom: 1px solid var(--border-color); +} + +/* ================================================================ + TEMPLATE LIST + ================================================================ */ +#template-list { + padding: 20px 24px; +} + +.template-category { + font-size: 14px; + font-weight: 600; + color: var(--text-primary); + margin: 16px 0 12px; + padding-bottom: 8px; + border-bottom: 1px solid var(--border-color); +} + +.template-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); + gap: 12px; + margin-bottom: 16px; +} + +.template-card { + background: var(--bg-card); + border: 1px solid var(--border-color); + border-radius: 10px; + padding: 16px; + cursor: pointer; + transition: all 0.2s; +} + +.template-card:hover { + background: var(--bg-card-hover); + border-color: var(--accent-blue); +} + +.template-card h4 { + font-size: 14px; + color: var(--text-primary); + margin-bottom: 6px; +} + +.template-card p { + font-size: 12px; + color: var(--text-muted); +} + +/* ================================================================ + LIGHTBOX + ================================================================ */ +.lightbox { + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + background: rgba(0, 0, 0, 0.9); + z-index: 2000; + display: flex; + align-items: center; + justify-content: center; + cursor: zoom-out; +} + +.lightbox img { + max-width: 90%; + max-height: 90%; + border-radius: 8px; +} + +.lightbox-close { + position: absolute; + top: 20px; + right: 30px; + color: white; + font-size: 36px; + cursor: pointer; +} + +/* ================================================================ + NEIGHBOR ENTRIES (BGP) + ================================================================ */ +.neighbor-entry { + background: var(--bg-card); + border: 1px solid var(--border-color); + border-radius: 8px; + margin-bottom: 12px; + overflow: hidden; +} + +.neighbor-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 10px 14px; + background: var(--bg-primary); + border-bottom: 1px solid var(--border-color); +} + +.neighbor-title { + font-size: 13px; + font-weight: 600; + color: var(--text-secondary); +} + +.btn-remove-neighbor { + width: 24px; + height: 24px; + background: transparent; + border: none; + color: var(--text-muted); + border-radius: 4px; + cursor: pointer; + font-size: 14px; + transition: all 0.2s; +} + +.btn-remove-neighbor:hover { + background: rgba(239, 68, 68, 0.2); + color: var(--error); +} + +.neighbor-fields { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 12px; + padding: 14px; +} + +.neighbor-fields .form-group { + margin-bottom: 0; +} + +.neighbor-fields .form-group label { + font-size: 10px; +} + +.neighbor-fields .form-group input { + padding: 8px 10px; + font-size: 13px; +} + +/* ================================================================ + RESPONSIVE + ================================================================ */ +@media (max-width: 1200px) { + .config-summary-sidebar { + display: none; + } + + .header-bar { + flex-wrap: wrap; + gap: 12px; + } +} + +@media (max-width: 768px) { + .content-area { + padding: 20px; + } + + .option-cards { + grid-template-columns: 1fr; + } + + .form-row { + grid-template-columns: 1fr; + } + + .breadcrumb-nav { + padding: 8px 12px; + } + + .breadcrumb-item { + padding: 4px 10px; + font-size: 12px; + } +} + +/* ================================================================ + TOAST NOTIFICATIONS (Odin Style) + ================================================================ */ +.toast-container { + position: fixed; + bottom: 24px; + right: 24px; + z-index: 3000; + display: flex; + flex-direction: column; + gap: 8px; +} + +.toast { + display: flex; + align-items: center; + gap: 12px; + padding: 14px 20px; + background: var(--bg-secondary); + border: 1px solid var(--border-color); + border-radius: 8px; + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.3); + font-size: 14px; + color: var(--text-primary); + animation: slideIn 0.3s ease; +} + +.toast.success { + border-color: var(--accent-green); +} + +.toast.success::before { + content: "✓"; + color: var(--accent-green); + font-weight: bold; +} + +.toast.error { + border-color: var(--error); +} + +.toast.error::before { + content: "✕"; + color: var(--error); + font-weight: bold; +} + +.toast.info { + border-color: var(--accent-blue); +} + +.toast.info::before { + content: "ℹ"; + color: var(--accent-blue); +} + +@keyframes slideIn { + from { + transform: translateX(100%); + opacity: 0; + } + to { + transform: translateX(0); + opacity: 1; + } +} + +@keyframes slideOut { + from { + transform: translateX(0); + opacity: 1; + } + to { + transform: translateX(100%); + opacity: 0; + } +} + +.toast.dismissing { + animation: slideOut 0.3s ease forwards; +} + +/* ================================================================ + LEGACY CLASSES (for backward compatibility) + ================================================================ */ +.container { + max-width: 100%; +} + +.wizard-container { + flex: 1; + padding: 0 40px 40px; +} + +/* Single-page scroll layout - all sections visible */ +.phase, +.wizard-step { + display: block; + margin-bottom: 24px; +} + +/* Hide old top-nav since we use breadcrumbs only */ +.top-nav { + display: none; +} + +/* Hide old sub-nav since sections are separate */ +.sub-nav { + display: none; +} + +.substep { + display: block; +} + +.old-top-nav { + display: flex; + justify-content: center; + gap: 8px; + padding: 16px 20px; + background: var(--bg-secondary); + border-bottom: 1px solid var(--border-color); + flex-wrap: wrap; + position: sticky; + top: 0; + z-index: 100; +} + +.nav-phase { + display: flex; + align-items: center; + gap: 8px; + padding: 8px 16px; + background: var(--bg-card); + border: 1px solid var(--border-color); + border-radius: 20px; + cursor: pointer; + transition: all 0.2s; +} + +.nav-phase:hover { + background: var(--bg-card-hover); +} + +.nav-phase.active { + background: var(--accent-blue); + border-color: var(--accent-blue); +} + +.nav-phase.active .phase-number, +.nav-phase.active .phase-label { + color: white; +} + +.nav-phase.completed { + border-color: var(--accent-green); +} + +.nav-phase.completed .phase-number { + background: var(--accent-green); +} + +.phase-number { + width: 28px; + height: 28px; + background: var(--bg-primary); + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + font-size: 13px; + font-weight: 700; + color: var(--text-secondary); +} + +.phase-label { + font-size: 13px; + font-weight: 500; + color: var(--text-secondary); +} + +.sub-steps { + display: none; +} + +.nav-phase.active .sub-steps { + display: flex; + gap: 8px; + margin-left: 8px; + padding-left: 8px; + border-left: 1px solid rgba(255,255,255,0.3); +} + +.sub-step { + font-size: 12px; + color: rgba(255,255,255,0.7); + cursor: pointer; + padding: 2px 6px; + border-radius: 4px; +} + +.sub-step:hover { + background: rgba(255,255,255,0.1); +} + +.sub-step.active { + color: white; + font-weight: 600; +} + +/* Header */ +header { + background: var(--bg-secondary); + padding: 40px 30px; + text-align: center; + border-bottom: 1px solid var(--border-color); +} + +header h1 { + font-size: 28px; + font-weight: 700; + color: var(--text-primary); + margin-bottom: 12px; +} + +.subtitle { + font-size: 15px; + color: var(--text-secondary); + margin-bottom: 24px; + max-width: 700px; + margin-left: auto; + margin-right: auto; +} + +.import-section { + display: flex; + gap: 12px; + justify-content: center; + flex-wrap: wrap; +} + +.import-btn { + display: inline-flex; + align-items: center; + gap: 8px; + padding: 10px 20px; + background: var(--bg-card); + border: 1px solid var(--border-color); + border-radius: 8px; + color: var(--text-primary); + font-size: 14px; + font-weight: 500; + cursor: pointer; + transition: all 0.2s; +} + +.import-btn:hover { + background: var(--bg-card-hover); + border-color: var(--accent-blue); +} + +.import-btn.secondary { + background: transparent; +} + +/* Section cards */ +.form-section { + background: var(--bg-secondary); + border: 1px solid var(--border-color); + border-radius: 12px; + padding: 24px; + margin-bottom: 20px; +} + +.form-section h3 { + font-size: 16px; + font-weight: 600; + color: var(--text-primary); + margin-bottom: 16px; +} + +/* Pattern cards */ +.pattern-cards { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 16px; +} + +.pattern-card { + background: var(--bg-card); + border: 2px solid var(--border-color); + border-radius: 12px; + padding: 20px; + cursor: pointer; + transition: all 0.2s; + text-align: center; + position: relative; +} + +.pattern-card:hover { + background: var(--bg-card-hover); + border-color: var(--text-muted); +} + +.pattern-card.selected { + background: var(--bg-selected); + border-color: var(--accent-blue); +} + +.pattern-card.selected::after { + content: "✓"; + position: absolute; + top: 12px; + right: 12px; + width: 24px; + height: 24px; + background: var(--accent-blue); + border-radius: 50%; + color: white; + font-size: 14px; + display: flex; + align-items: center; + justify-content: center; +} + +.pattern-card img { + width: 100%; + max-width: 200px; + height: auto; + border-radius: 8px; + margin-bottom: 12px; +} + +.pattern-card h4 { + font-size: 15px; + font-weight: 600; + color: var(--text-primary); + margin-bottom: 8px; +} + +.pattern-card p { + font-size: 13px; + color: var(--text-secondary); +} + +.pattern-tag { + display: inline-block; + margin-top: 12px; + padding: 4px 10px; + background: rgba(59, 130, 246, 0.15); + border-radius: 12px; + font-size: 11px; + color: var(--accent-blue); +} + +.pattern-card.recommended::before { + content: "★ RECOMMENDED"; + position: absolute; + top: -1px; + left: 50%; + transform: translateX(-50%); + padding: 4px 12px; + background: var(--accent-green); + color: white; + font-size: 10px; + font-weight: 700; + border-radius: 0 0 8px 8px; +} + +/* Role cards */ +.role-cards { + display: flex; + gap: 16px; +} + +.role-card { + flex: 1; + background: var(--bg-card); + border: 2px solid var(--border-color); + border-radius: 10px; + padding: 20px; + text-align: center; + cursor: pointer; + transition: all 0.2s; +} + +.role-card:hover { + background: var(--bg-card-hover); +} + +.role-card.selected { + background: var(--bg-selected); + border-color: var(--accent-blue); +} + +.role-card h4 { + font-size: 16px; + color: var(--text-primary); +} + +/* Port/VLAN cards */ +.port-card, +.vlan-card, +.bgp-card { + background: var(--bg-card); + border: 1px solid var(--border-color); + border-radius: 10px; + padding: 20px; + margin-bottom: 16px; +} + +.port-card h3, +.vlan-card h3, +.bgp-card h3 { + font-size: 15px; + font-weight: 600; + color: var(--text-primary); + margin-bottom: 8px; +} + +.section-note { + font-size: 13px; + color: var(--text-muted); + margin-bottom: 16px; +} + +/* Substep navigation */ +.sub-nav { + display: flex; + gap: 8px; + margin-bottom: 20px; + padding-bottom: 16px; + border-bottom: 1px solid var(--border-color); +} + +.sub-nav-btn { + padding: 8px 16px; + background: var(--bg-card); + border: 1px solid var(--border-color); + border-radius: 6px; + color: var(--text-secondary); + font-size: 13px; + cursor: pointer; + transition: all 0.2s; +} + +.sub-nav-btn:hover { + background: var(--bg-card-hover); +} + +.sub-nav-btn.active { + background: var(--accent-blue); + border-color: var(--accent-blue); + color: white; +} + +.substep { + display: none; +} + +.substep.active { + display: block; +} + +/* Inline cards for routing type */ +.cards.inline { + display: flex; + gap: 12px; +} + +.card.small { + flex: 1; + background: var(--bg-card); + border: 2px solid var(--border-color); + border-radius: 8px; + padding: 16px; + text-align: center; + cursor: pointer; + transition: all 0.2s; +} + +.card.small:hover { + background: var(--bg-card-hover); +} + +.card.small.selected { + background: var(--bg-selected); + border-color: var(--accent-blue); +} + +.card.small h4 { + font-size: 14px; + color: var(--text-primary); + margin: 0; +} + +/* Pattern sidebar */ +.pattern-sidebar { + position: fixed; + left: 20px; + top: 150px; + width: 180px; + background: var(--bg-secondary); + border: 1px solid var(--border-color); + border-radius: 12px; + padding: 16px; + z-index: 50; +} + +.sidebar-thumbnail img { + width: 100%; + border-radius: 8px; + margin-bottom: 12px; + cursor: zoom-in; +} + +.sidebar-info strong { + display: block; + font-size: 14px; + color: var(--text-primary); + margin-bottom: 8px; +} + +.change-pattern-btn { + width: 100%; + padding: 8px; + background: var(--bg-card); + border: 1px solid var(--border-color); + border-radius: 6px; + color: var(--text-secondary); + font-size: 12px; + cursor: pointer; + margin-top: 12px; +} + +.change-pattern-btn:hover { + background: var(--bg-card-hover); +} + +/* Export buttons */ +.export-buttons { + display: flex; + gap: 12px; + flex-wrap: wrap; +} + +.btn-export { + padding: 12px 24px; + background: var(--accent-blue); + border: none; + border-radius: 8px; + color: white; + font-size: 14px; + font-weight: 600; + cursor: pointer; + transition: all 0.2s; +} + +.btn-export:hover { + background: #2563eb; +} + +.btn-export.secondary { + background: var(--bg-card); + border: 1px solid var(--border-color); + color: var(--text-primary); +} + +.btn-export.secondary:hover { + background: var(--bg-card-hover); +} + +.btn-reset { + padding: 12px 24px; + background: transparent; + border: 1px solid var(--error); + border-radius: 8px; + color: var(--error); + font-size: 14px; + cursor: pointer; +} + +.btn-reset:hover { + background: rgba(239, 68, 68, 0.1); +} + +/* JSON preview */ +.preview-container { + background: var(--bg-card); + border: 1px solid var(--border-color); + border-radius: 10px; + padding: 20px; + margin-bottom: 20px; +} + +.preview-container h3 { + font-size: 15px; + color: var(--text-primary); + margin-bottom: 16px; +} + +#json-preview { + background: var(--bg-primary); + border: 1px solid var(--border-color); + border-radius: 8px; + padding: 16px; + font-family: 'Fira Code', 'Consolas', monospace; + font-size: 12px; + color: var(--text-secondary); + overflow-x: auto; + max-height: 400px; + white-space: pre-wrap; +} + +/* Summary container */ +.summary-container { + margin-bottom: 24px; +} + +.config-summary { + background: var(--bg-card); + border: 1px solid var(--border-color); + border-radius: 10px; + padding: 20px; +} + +/* Btn next/back */ +.btn-next, +.btn-back { + padding: 12px 28px; + border: none; + border-radius: 8px; + font-size: 14px; + font-weight: 600; + cursor: pointer; + transition: all 0.2s; +} + +.btn-next { + background: var(--accent-blue); + color: white; +} + +.btn-next:hover { + background: #2563eb; +} + +.btn-back { + background: var(--bg-card); + border: 1px solid var(--border-color); + color: var(--text-primary); +} + +.btn-back:hover { + background: var(--bg-card-hover); +} diff --git a/frontend/package-lock.json b/frontend/package-lock.json new file mode 100644 index 0000000..aabcd62 --- /dev/null +++ b/frontend/package-lock.json @@ -0,0 +1,1193 @@ +{ + "name": "azure-local-network-config-wizard", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "azure-local-network-config-wizard", + "version": "1.0.0", + "dependencies": { + "ajv": "^8.17.1" + }, + "devDependencies": { + "@types/node": "^25.1.0", + "typescript": "^5.7.2", + "vite": "^6.0.7" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", + "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", + "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", + "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", + "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", + "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", + "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", + "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", + "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", + "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", + "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", + "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", + "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", + "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", + "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", + "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", + "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", + "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", + "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", + "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", + "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", + "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", + "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", + "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", + "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", + "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", + "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.57.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.57.0.tgz", + "integrity": "sha512-tPgXB6cDTndIe1ah7u6amCI1T0SsnlOuKgg10Xh3uizJk4e5M1JGaUMk7J4ciuAUcFpbOiNhm2XIjP9ON0dUqA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.57.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.57.0.tgz", + "integrity": "sha512-sa4LyseLLXr1onr97StkU1Nb7fWcg6niokTwEVNOO7awaKaoRObQ54+V/hrF/BP1noMEaaAW6Fg2d/CfLiq3Mg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.57.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.57.0.tgz", + "integrity": "sha512-/NNIj9A7yLjKdmkx5dC2XQ9DmjIECpGpwHoGmA5E1AhU0fuICSqSWScPhN1yLCkEdkCwJIDu2xIeLPs60MNIVg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.57.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.57.0.tgz", + "integrity": "sha512-xoh8abqgPrPYPr7pTYipqnUi1V3em56JzE/HgDgitTqZBZ3yKCWI+7KUkceM6tNweyUKYru1UMi7FC060RyKwA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.57.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.57.0.tgz", + "integrity": "sha512-PCkMh7fNahWSbA0OTUQ2OpYHpjZZr0hPr8lId8twD7a7SeWrvT3xJVyza+dQwXSSq4yEQTMoXgNOfMCsn8584g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.57.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.57.0.tgz", + "integrity": "sha512-1j3stGx+qbhXql4OCDZhnK7b01s6rBKNybfsX+TNrEe9JNq4DLi1yGiR1xW+nL+FNVvI4D02PUnl6gJ/2y6WJA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.57.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.57.0.tgz", + "integrity": "sha512-eyrr5W08Ms9uM0mLcKfM/Uzx7hjhz2bcjv8P2uynfj0yU8GGPdz8iYrBPhiLOZqahoAMB8ZiolRZPbbU2MAi6Q==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.57.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.57.0.tgz", + "integrity": "sha512-Xds90ITXJCNyX9pDhqf85MKWUI4lqjiPAipJ8OLp8xqI2Ehk+TCVhF9rvOoN8xTbcafow3QOThkNnrM33uCFQA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.57.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.57.0.tgz", + "integrity": "sha512-Xws2KA4CLvZmXjy46SQaXSejuKPhwVdaNinldoYfqruZBaJHqVo6hnRa8SDo9z7PBW5x84SH64+izmldCgbezw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.57.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.57.0.tgz", + "integrity": "sha512-hrKXKbX5FdaRJj7lTMusmvKbhMJSGWJ+w++4KmjiDhpTgNlhYobMvKfDoIWecy4O60K6yA4SnztGuNTQF+Lplw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.57.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.57.0.tgz", + "integrity": "sha512-6A+nccfSDGKsPm00d3xKcrsBcbqzCTAukjwWK6rbuAnB2bHaL3r9720HBVZ/no7+FhZLz/U3GwwZZEh6tOSI8Q==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.57.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.57.0.tgz", + "integrity": "sha512-4P1VyYUe6XAJtQH1Hh99THxr0GKMMwIXsRNOceLrJnaHTDgk1FTcTimDgneRJPvB3LqDQxUmroBclQ1S0cIJwQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.57.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.57.0.tgz", + "integrity": "sha512-8Vv6pLuIZCMcgXre6c3nOPhE0gjz1+nZP6T+hwWjr7sVH8k0jRkH+XnfjjOTglyMBdSKBPPz54/y1gToSKwrSQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.57.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.57.0.tgz", + "integrity": "sha512-r1te1M0Sm2TBVD/RxBPC6RZVwNqUTwJTA7w+C/IW5v9Ssu6xmxWEi+iJQlpBhtUiT1raJ5b48pI8tBvEjEFnFA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.57.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.57.0.tgz", + "integrity": "sha512-say0uMU/RaPm3CDQLxUUTF2oNWL8ysvHkAjcCzV2znxBr23kFfaxocS9qJm+NdkRhF8wtdEEAJuYcLPhSPbjuQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.57.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.57.0.tgz", + "integrity": "sha512-/MU7/HizQGsnBREtRpcSbSV1zfkoxSTR7wLsRmBPQ8FwUj5sykrP1MyJTvsxP5KBq9SyE6kH8UQQQwa0ASeoQQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.57.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.57.0.tgz", + "integrity": "sha512-Q9eh+gUGILIHEaJf66aF6a414jQbDnn29zeu0eX3dHMuysnhTvsUvZTCAyZ6tJhUjnvzBKE4FtuaYxutxRZpOg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.57.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.57.0.tgz", + "integrity": "sha512-OR5p5yG5OKSxHReWmwvM0P+VTPMwoBS45PXTMYaskKQqybkS3Kmugq1W+YbNWArF8/s7jQScgzXUhArzEQ7x0A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.57.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.57.0.tgz", + "integrity": "sha512-XeatKzo4lHDsVEbm1XDHZlhYZZSQYym6dg2X/Ko0kSFgio+KXLsxwJQprnR48GvdIKDOpqWqssC3iBCjoMcMpw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.57.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.57.0.tgz", + "integrity": "sha512-Lu71y78F5qOfYmubYLHPcJm74GZLU6UJ4THkf/a1K7Tz2ycwC2VUbsqbJAXaR6Bx70SRdlVrt2+n5l7F0agTUw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.57.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.57.0.tgz", + "integrity": "sha512-v5xwKDWcu7qhAEcsUubiav7r+48Uk/ENWdr82MBZZRIm7zThSxCIVDfb3ZeRRq9yqk+oIzMdDo6fCcA5DHfMyA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.57.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.57.0.tgz", + "integrity": "sha512-XnaaaSMGSI6Wk8F4KK3QP7GfuuhjGchElsVerCplUuxRIzdvZ7hRBpLR0omCmw+kI2RFJB80nenhOoGXlJ5TfQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.57.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.57.0.tgz", + "integrity": "sha512-3K1lP+3BXY4t4VihLw5MEg6IZD3ojSYzqzBG571W3kNQe4G4CcFpSUQVgurYgib5d+YaCjeFow8QivWp8vuSvA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.57.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.57.0.tgz", + "integrity": "sha512-MDk610P/vJGc5L5ImE4k5s+GZT3en0KoK1MKPXCRgzmksAMk79j4h3k1IerxTNqwDLxsGxStEZVBqG0gIqZqoA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.57.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.57.0.tgz", + "integrity": "sha512-Zv7v6q6aV+VslnpwzqKAmrk5JdVkLUzok2208ZXGipjb+msxBr/fJPZyeEXiFgH7k62Ak0SLIfxQRZQvTuf7rQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "25.1.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.1.0.tgz", + "integrity": "sha512-t7frlewr6+cbx+9Ohpl0NOTKXZNV9xHRmNOvql47BFJKcEG1CxtxlPEEe+gR9uhVWM4DwhnvTF110mIL4yP9RA==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~7.16.0" + } + }, + "node_modules/ajv": { + "version": "8.17.1", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz", + "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/esbuild": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", + "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.12", + "@esbuild/android-arm": "0.25.12", + "@esbuild/android-arm64": "0.25.12", + "@esbuild/android-x64": "0.25.12", + "@esbuild/darwin-arm64": "0.25.12", + "@esbuild/darwin-x64": "0.25.12", + "@esbuild/freebsd-arm64": "0.25.12", + "@esbuild/freebsd-x64": "0.25.12", + "@esbuild/linux-arm": "0.25.12", + "@esbuild/linux-arm64": "0.25.12", + "@esbuild/linux-ia32": "0.25.12", + "@esbuild/linux-loong64": "0.25.12", + "@esbuild/linux-mips64el": "0.25.12", + "@esbuild/linux-ppc64": "0.25.12", + "@esbuild/linux-riscv64": "0.25.12", + "@esbuild/linux-s390x": "0.25.12", + "@esbuild/linux-x64": "0.25.12", + "@esbuild/netbsd-arm64": "0.25.12", + "@esbuild/netbsd-x64": "0.25.12", + "@esbuild/openbsd-arm64": "0.25.12", + "@esbuild/openbsd-x64": "0.25.12", + "@esbuild/openharmony-arm64": "0.25.12", + "@esbuild/sunos-x64": "0.25.12", + "@esbuild/win32-arm64": "0.25.12", + "@esbuild/win32-ia32": "0.25.12", + "@esbuild/win32-x64": "0.25.12" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "license": "MIT" + }, + "node_modules/fast-uri": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz", + "integrity": "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.6", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", + "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/rollup": { + "version": "4.57.0", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.57.0.tgz", + "integrity": "sha512-e5lPJi/aui4TO1LpAXIRLySmwXSE8k3b9zoGfd42p67wzxog4WHjiZF3M2uheQih4DGyc25QEV4yRBbpueNiUA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.57.0", + "@rollup/rollup-android-arm64": "4.57.0", + "@rollup/rollup-darwin-arm64": "4.57.0", + "@rollup/rollup-darwin-x64": "4.57.0", + "@rollup/rollup-freebsd-arm64": "4.57.0", + "@rollup/rollup-freebsd-x64": "4.57.0", + "@rollup/rollup-linux-arm-gnueabihf": "4.57.0", + "@rollup/rollup-linux-arm-musleabihf": "4.57.0", + "@rollup/rollup-linux-arm64-gnu": "4.57.0", + "@rollup/rollup-linux-arm64-musl": "4.57.0", + "@rollup/rollup-linux-loong64-gnu": "4.57.0", + "@rollup/rollup-linux-loong64-musl": "4.57.0", + "@rollup/rollup-linux-ppc64-gnu": "4.57.0", + "@rollup/rollup-linux-ppc64-musl": "4.57.0", + "@rollup/rollup-linux-riscv64-gnu": "4.57.0", + "@rollup/rollup-linux-riscv64-musl": "4.57.0", + "@rollup/rollup-linux-s390x-gnu": "4.57.0", + "@rollup/rollup-linux-x64-gnu": "4.57.0", + "@rollup/rollup-linux-x64-musl": "4.57.0", + "@rollup/rollup-openbsd-x64": "4.57.0", + "@rollup/rollup-openharmony-arm64": "4.57.0", + "@rollup/rollup-win32-arm64-msvc": "4.57.0", + "@rollup/rollup-win32-ia32-msvc": "4.57.0", + "@rollup/rollup-win32-x64-gnu": "4.57.0", + "@rollup/rollup-win32-x64-msvc": "4.57.0", + "fsevents": "~2.3.2" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.15", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", + "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "7.16.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", + "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==", + "dev": true, + "license": "MIT" + }, + "node_modules/vite": { + "version": "6.4.1", + "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.1.tgz", + "integrity": "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.25.0", + "fdir": "^6.4.4", + "picomatch": "^4.0.2", + "postcss": "^8.5.3", + "rollup": "^4.34.9", + "tinyglobby": "^0.2.13" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "jiti": ">=1.21.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + } + } +} diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..2e36ffb --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,20 @@ +{ + "name": "azure-local-network-config-wizard", + "version": "1.0.0", + "description": "TypeScript-based wizard for Azure Local network switch configuration", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc && vite build", + "preview": "vite preview", + "typecheck": "tsc --noEmit" + }, + "devDependencies": { + "@types/node": "^25.1.0", + "typescript": "^5.7.2", + "vite": "^6.0.7" + }, + "dependencies": { + "ajv": "^8.17.1" + } +} diff --git a/frontend/schema.js b/frontend/schema.js deleted file mode 100644 index cfecc94..0000000 --- a/frontend/schema.js +++ /dev/null @@ -1,267 +0,0 @@ -/** - * Azure Local Network Config Wizard - * Schema validation (synced from backend/schema/standard.json) - */ - -// Schema definition (subset for frontend validation) -const SCHEMA = { - switch: { - required: ['vendor', 'model', 'hostname', 'role', 'firmware'], - vendors: ['cisco', 'dellemc'], - roles: ['TOR1', 'TOR2', 'BMC'], - firmwares: ['nxos', 'os10'], - deploymentPatterns: ['fully_converged', 'switched', 'switchless'] - }, - vlans: { - minVlanId: 2, - maxVlanId: 4094, - purposes: ['management', 'compute', 'storage_1', 'storage_2', 'native', 'parking'], - redundancyTypes: ['hsrp', 'vrrp'] - }, - interfaces: { - types: ['Access', 'Trunk', 'L3'], - intfTypes: ['Ethernet', 'loopback'] - }, - portChannels: { - minId: 1, - maxId: 4096, - types: ['Trunk', 'L3', 'Access'] - }, - bgp: { - minAsn: 1, - maxAsn: 4294967295 - } -}; - -/** - * Validate a configuration object against the schema - * @param {Object} config - The configuration to validate - * @returns {Array} - Array of error messages (empty if valid) - */ -function validateConfig(config) { - const errors = []; - - // Validate switch section - if (!config.switch) { - errors.push('Missing required "switch" section'); - return errors; - } - - // Required switch fields - SCHEMA.switch.required.forEach(field => { - if (!config.switch[field]) { - errors.push(`Missing required switch field: ${field}`); - } - }); - - // Vendor validation - if (config.switch.vendor && !SCHEMA.switch.vendors.includes(config.switch.vendor)) { - errors.push(`Invalid vendor: ${config.switch.vendor}. Must be one of: ${SCHEMA.switch.vendors.join(', ')}`); - } - - // Role validation - if (config.switch.role && !SCHEMA.switch.roles.includes(config.switch.role)) { - errors.push(`Invalid role: ${config.switch.role}. Must be one of: ${SCHEMA.switch.roles.join(', ')}`); - } - - // Firmware validation - if (config.switch.firmware && !SCHEMA.switch.firmwares.includes(config.switch.firmware)) { - errors.push(`Invalid firmware: ${config.switch.firmware}. Must be one of: ${SCHEMA.switch.firmwares.join(', ')}`); - } - - // Hostname validation - if (config.switch.hostname) { - if (!/^[a-zA-Z0-9][a-zA-Z0-9._-]*$/.test(config.switch.hostname)) { - errors.push('Invalid hostname format'); - } - if (config.switch.hostname.length > 64) { - errors.push('Hostname exceeds 64 characters'); - } - } - - // Validate VLANs - if (config.vlans && Array.isArray(config.vlans)) { - const vlanIds = new Set(); - - config.vlans.forEach((vlan, index) => { - if (!vlan.vlan_id) { - errors.push(`VLAN at index ${index}: missing vlan_id`); - } else { - if (vlan.vlan_id < SCHEMA.vlans.minVlanId || vlan.vlan_id > SCHEMA.vlans.maxVlanId) { - errors.push(`VLAN ${vlan.vlan_id}: ID must be between ${SCHEMA.vlans.minVlanId} and ${SCHEMA.vlans.maxVlanId}`); - } - if (vlanIds.has(vlan.vlan_id)) { - errors.push(`VLAN ${vlan.vlan_id}: duplicate VLAN ID`); - } - vlanIds.add(vlan.vlan_id); - } - - if (!vlan.name) { - errors.push(`VLAN at index ${index}: missing name`); - } - - if (vlan.purpose && !SCHEMA.vlans.purposes.includes(vlan.purpose)) { - errors.push(`VLAN ${vlan.vlan_id}: invalid purpose "${vlan.purpose}"`); - } - }); - } - - // Validate interfaces - if (config.interfaces && Array.isArray(config.interfaces)) { - config.interfaces.forEach((intf, index) => { - if (!intf.name) { - errors.push(`Interface at index ${index}: missing name`); - } - if (!intf.type || !SCHEMA.interfaces.types.includes(intf.type)) { - errors.push(`Interface ${intf.name || index}: invalid type "${intf.type}"`); - } - if (!intf.intf_type || !SCHEMA.interfaces.intfTypes.includes(intf.intf_type)) { - errors.push(`Interface ${intf.name || index}: invalid intf_type "${intf.intf_type}"`); - } - - // Type-specific validations - if (intf.type === 'Access' && !intf.access_vlan) { - errors.push(`Interface ${intf.name || index}: Access type requires access_vlan`); - } - if (intf.type === 'Trunk' && !intf.native_vlan) { - errors.push(`Interface ${intf.name || index}: Trunk type requires native_vlan`); - } - if (intf.type === 'L3' && !intf.ipv4) { - errors.push(`Interface ${intf.name || index}: L3 type requires ipv4`); - } - }); - } - - // Validate port channels - if (config.port_channels && Array.isArray(config.port_channels)) { - config.port_channels.forEach((pc, index) => { - if (!pc.id) { - errors.push(`Port-channel at index ${index}: missing id`); - } else if (pc.id < SCHEMA.portChannels.minId || pc.id > SCHEMA.portChannels.maxId) { - errors.push(`Port-channel ${pc.id}: ID must be between ${SCHEMA.portChannels.minId} and ${SCHEMA.portChannels.maxId}`); - } - - if (!pc.type || !SCHEMA.portChannels.types.includes(pc.type)) { - errors.push(`Port-channel ${pc.id || index}: invalid type "${pc.type}"`); - } - - if (!pc.members || !Array.isArray(pc.members) || pc.members.length === 0) { - errors.push(`Port-channel ${pc.id || index}: requires at least one member`); - } - }); - } - - // Validate MLAG (required for TOR1/TOR2) - if (config.switch.role && ['TOR1', 'TOR2'].includes(config.switch.role)) { - if (!config.mlag) { - errors.push('MLAG configuration required for TOR switches'); - } else if (!config.mlag.peer_keepalive) { - errors.push('MLAG peer_keepalive configuration required'); - } else { - if (!config.mlag.peer_keepalive.source_ip) { - errors.push('MLAG peer_keepalive.source_ip required'); - } - if (!config.mlag.peer_keepalive.destination_ip) { - errors.push('MLAG peer_keepalive.destination_ip required'); - } - } - - // Check for peer-link port-channel - if (config.port_channels) { - const hasPeerLink = config.port_channels.some(pc => pc.vpc_peer_link); - if (!hasPeerLink) { - errors.push('MLAG requires a port-channel with vpc_peer_link: true'); - } - } - } - - // Validate BGP - if (config.bgp) { - if (!config.bgp.asn) { - errors.push('BGP requires asn'); - } else if (config.bgp.asn < SCHEMA.bgp.minAsn || config.bgp.asn > SCHEMA.bgp.maxAsn) { - errors.push(`BGP ASN must be between ${SCHEMA.bgp.minAsn} and ${SCHEMA.bgp.maxAsn}`); - } - - if (!config.bgp.router_id) { - errors.push('BGP requires router_id'); - } - - if (!config.bgp.neighbors || !Array.isArray(config.bgp.neighbors) || config.bgp.neighbors.length === 0) { - errors.push('BGP requires at least one neighbor'); - } else { - config.bgp.neighbors.forEach((neighbor, index) => { - if (!neighbor.ip) { - errors.push(`BGP neighbor at index ${index}: missing ip`); - } - if (!neighbor.remote_as) { - errors.push(`BGP neighbor ${neighbor.ip || index}: missing remote_as`); - } - }); - } - - // Cross-reference: router_id should match loopback - if (config.bgp.router_id && config.interfaces) { - const loopback = config.interfaces.find(i => i.intf_type === 'loopback'); - if (loopback && loopback.ipv4) { - const loopbackIp = loopback.ipv4.split('/')[0]; - if (loopbackIp !== config.bgp.router_id) { - errors.push(`BGP router_id (${config.bgp.router_id}) should match Loopback0 IP (${loopbackIp})`); - } - } - } - } - - // Validate cross-references: VLANs referenced in interfaces must exist - if (config.interfaces && config.vlans) { - const vlanIds = new Set(config.vlans.map(v => String(v.vlan_id))); - - config.interfaces.forEach(intf => { - if (intf.access_vlan && !vlanIds.has(intf.access_vlan)) { - errors.push(`Interface ${intf.name}: references non-existent VLAN ${intf.access_vlan}`); - } - if (intf.native_vlan && !vlanIds.has(intf.native_vlan)) { - // Allow common native VLANs like 99 that might not be in the list - if (!['99', '1'].includes(intf.native_vlan)) { - errors.push(`Interface ${intf.name}: references non-existent native VLAN ${intf.native_vlan}`); - } - } - }); - } - - return errors; -} - -/** - * Check if a value is a valid IPv4 address - * @param {string} ip - IP address to validate - * @returns {boolean} - */ -function isValidIPv4(ip) { - if (!ip) return false; - const parts = ip.split('.'); - if (parts.length !== 4) return false; - return parts.every(part => { - const num = parseInt(part, 10); - return !isNaN(num) && num >= 0 && num <= 255 && String(num) === part; - }); -} - -/** - * Check if a value is a valid CIDR notation - * @param {string} cidr - CIDR notation (e.g., "10.0.0.1/24") - * @returns {boolean} - */ -function isValidCIDR(cidr) { - if (!cidr) return false; - const parts = cidr.split('/'); - if (parts.length !== 2) return false; - if (!isValidIPv4(parts[0])) return false; - const prefix = parseInt(parts[1], 10); - return !isNaN(prefix) && prefix >= 0 && prefix <= 32; -} - -// Export for use in app.js -if (typeof module !== 'undefined' && module.exports) { - module.exports = { validateConfig, isValidIPv4, isValidCIDR, SCHEMA }; -} diff --git a/frontend/src/app.ts b/frontend/src/app.ts new file mode 100644 index 0000000..f771c50 --- /dev/null +++ b/frontend/src/app.ts @@ -0,0 +1,2698 @@ +/** + * Azure Local Switch Configuration Wizard - Main Application + * TypeScript conversion from app.js + */ + +import type { + StandardConfig, + VLAN, + Interface, + PortChannel, + BGPNeighbor, + Vendor, + Role, + VLANPurpose, + RedundancyType, + Firmware, + DeploymentPattern +} from './types'; +import { + DISPLAY_NAMES, + VENDOR_FIRMWARE_MAP, + VENDOR_REDUNDANCY_TYPE, + ROLE_DEFAULTS, + getElement, + getElements, + getInputValue, + setInputValue, + toggleElement, + downloadJSON, + copyToClipboard, + parseIntSafe, + formatJSON +} from './utils'; +import { updateBreadcrumbCompletion } from './main'; + +// ============================================================================ +// TYPES +// ============================================================================ + +interface WizardState { + currentStep: number; + totalSteps: number; + config: StandardConfig & { + routing_type?: 'bgp' | 'static'; + static_routes?: Array<{ + destination: string; + next_hop: string; + name?: string; + }>; + }; +} + +interface VLANConfig { + label: string; + purpose: VLANPurpose; + defaultVlanId: (idx: number) => number; + namePrefix: string; + switchIpPlaceholder: string | ((idx: number) => string); + gatewayPlaceholder: string | ((idx: number) => string); + cssClass: string; + counter: number; +} + +// ============================================================================ +// STATE MANAGEMENT +// ============================================================================ + +const state: WizardState = { + currentStep: 1, + totalSteps: 7, + config: { + switch: { + vendor: '' as Vendor, + model: '', + firmware: '' as Firmware, + hostname: '', + role: '' as Role, + deployment_pattern: '' as DeploymentPattern + }, + vlans: [], + interfaces: [], + port_channels: [], + mlag: undefined, + routing_type: 'bgp', + bgp: undefined, + prefix_lists: {}, + static_routes: [] + } +}; + +// MLAG constants +const MLAG_PEER_LINK_ID = 101; +const MLAG_NATIVE_VLAN = '99'; +const MLAG_PEER_LINK_MEMBERS = ['1/1/49', '1/1/50', '1/1/51', '1/1/52']; + +const VLAN_CONFIGS: Record<'management' | 'compute', VLANConfig> = { + management: { + label: 'Management', + purpose: 'management', + defaultVlanId: (idx) => 7 + idx, + namePrefix: 'Infra', + switchIpPlaceholder: (idx) => `100.69.176.${idx + 2}/24`, + gatewayPlaceholder: '100.69.176.1/24', + cssClass: 'mgmt', + counter: 1 + }, + compute: { + label: 'Compute', + purpose: 'compute', + defaultVlanId: (idx) => 201 + idx, + namePrefix: 'Compute', + switchIpPlaceholder: (idx) => `10.${idx}.0.2/24`, + gatewayPlaceholder: (idx) => `10.${idx}.0.1/24`, + cssClass: 'compute', + counter: 1 + } +}; + +// ============================================================================ +// PATTERN-FIRST FUNCTIONS +// ============================================================================ + +/** + * Select deployment pattern - Phase 1.1 + */ +export function selectPattern(pattern: DeploymentPattern): void { + // Update state + state.config.switch.deployment_pattern = pattern; + + // Update UI - mark selected + getElements('.pattern-card').forEach(card => { + card.classList.remove('selected'); + if (card.dataset.pattern === pattern) { + card.classList.add('selected'); + } + }); + + // Reveal hardware selection section + const hardwareSection = getElement('.hardware-selection'); + if (hardwareSection) { + hardwareSection.style.display = 'block'; + } + + // Sync host port sections with selected pattern + updateHostPortsSections(); + + // Enable next button when all Phase 1 fields are filled + updatePhase1NextButton(); + + // Update config summary sidebar + updateConfigSummary(); +} + +/** + * Expand pattern image in lightbox + */ +export function expandPatternImage(): void { + const sidebarImg = getElement('#sidebar-pattern-img'); + const lightbox = getElement('#pattern-lightbox'); + const lightboxImg = getElement('#lightbox-img'); + + if (!sidebarImg || !lightbox || !lightboxImg) return; + + lightboxImg.src = sidebarImg.src; + lightboxImg.alt = sidebarImg.alt; + lightbox.style.display = 'flex'; +} + +/** + * Expand pattern preview image from selection cards + */ +export function expandPatternPreview(src: string, alt: string): void { + const lightbox = getElement('#pattern-lightbox'); + const lightboxImg = getElement('#lightbox-img'); + + if (!lightbox || !lightboxImg) return; + + lightboxImg.src = src; + lightboxImg.alt = alt; + lightbox.style.display = 'flex'; +} + +/** + * Close lightbox + */ +export function closeLightbox(): void { + const lightbox = getElement('#pattern-lightbox'); + if (lightbox) lightbox.style.display = 'none'; +} + +/** + * Change pattern (return to Phase 1.1) + */ +export function changePattern(): void { + if (confirm('Changing the pattern will reset your configuration. Continue?')) { + showPhase(1); + // Reset pattern selection + getElements('.pattern-card').forEach(card => card.classList.remove('selected')); + // Hide sidebar + const sidebar = getElement('#pattern-sidebar'); + if (sidebar) sidebar.style.display = 'none'; + // Hide later sections + getElement('.hardware-selection')?.setAttribute('style', 'display: none'); + getElement('#role-selection-section')?.setAttribute('style', 'display: none'); + getElement('#hostname-section')?.setAttribute('style', 'display: none'); + } +} + +/** + * Vendor change handler for dropdown + */ +export function onVendorChange(): void { + const vendorSelect = getElement('#vendor-select'); + const modelSelect = getElement('#model-select'); + + if (!vendorSelect || !modelSelect) return; + + const vendor = vendorSelect.value as Vendor; + + // Clear and populate model dropdown + modelSelect.innerHTML = ''; + + const models: Record> = { + cisco: [ + { value: '93180yc-fx3', label: 'Nexus 93180YC-FX3' }, + { value: '9336c-fx2', label: 'Nexus 9336C-FX2' } + ], + dellemc: [ + { value: 's5248f-on', label: 'S5248F-ON' }, + { value: 's5232f-on', label: 'S5232F-ON' } + ] + }; + + if (vendor && models[vendor]) { + modelSelect.disabled = false; + models[vendor].forEach(model => { + const option = document.createElement('option'); + option.value = model.value; + option.textContent = model.label; + modelSelect.appendChild(option); + }); + + // Update state + state.config.switch.vendor = vendor; + state.config.switch.firmware = VENDOR_FIRMWARE_MAP[vendor] as Firmware; + + // Update config summary + updateConfigSummary(); + } else { + modelSelect.disabled = true; + } + + updatePhase1NextButton(); +} + +/** + * Handle model selection change + */ +export function onModelChange(): void { + const modelSelect = getElement('#model-select'); + if (!modelSelect) { + console.error('Model select not found'); + return; + } + + console.log('onModelChange called, model value:', modelSelect.value); + + state.config.switch.model = modelSelect.value; + + // Show role selection AND hostname section when model is selected + if (modelSelect.value) { + const roleSection = getElement('#role-selection-section'); + console.log('Role section found:', !!roleSection); + if (roleSection) { + roleSection.style.display = 'block'; + console.log('Role section visible'); + } + + // Also show hostname section + const hostnameSection = getElement('#hostname-section'); + if (hostnameSection) { + hostnameSection.style.display = 'block'; + console.log('Hostname section visible'); + } + + // Update config summary + updateConfigSummary(); + } + + updatePhase1NextButton(); +} + +/** + * Role selection handler + */ +export function selectRole(role: Role): void { + console.log('selectRole called with:', role); + + // Update state + state.config.switch.role = role; + + // Update UI - support both role-card and role-btn + getElements('.role-card, .role-btn').forEach(card => { + card.classList.remove('selected'); + if (card.dataset.role === role) { + card.classList.add('selected'); + } + }); + + // Auto-generate hostname + const hostname = `sample-${role.toLowerCase()}`; + + const hostnameInput = getElement('#hostname'); + if (hostnameInput) { + hostnameInput.value = hostname; + state.config.switch.hostname = hostname; + // Re-apply in next tick to override any stale value + setTimeout(() => { + hostnameInput.value = hostname; + state.config.switch.hostname = hostname; + }, 0); + } + + // Show hostname section + const hostnameSection = getElement('#hostname-section'); + console.log('Hostname section found:', !!hostnameSection); + if (hostnameSection) { + hostnameSection.style.display = 'block'; + console.log('Hostname section display set to block'); + } else { + console.error('Hostname section element not found!'); + } + + updateHostPortsSections(); + + updatePhase1NextButton(); + + // Update config summary + updateConfigSummary(); +} + +/** + * Update hostname in state when user edits it + */ +export function updateHostname(): void { + const hostnameInput = getElement('#hostname'); + if (hostnameInput) { + state.config.switch.hostname = hostnameInput.value; + updatePhase1NextButton(); + updateConfigSummary(); + } +} + +/** + * Update Phase 1 next button state + */ +function updatePhase1NextButton(): void { + const nextBtn = getElement('#phase1-next-btn'); + if (!nextBtn) return; + + const pattern = state.config.switch.deployment_pattern; + const vendor = state.config.switch.vendor; + const model = state.config.switch.model; + const role = state.config.switch.role; + const hostname = getElement('#hostname')?.value; + + const allFilled = pattern && vendor && model && role && hostname; + nextBtn.disabled = !allFilled; + + if (allFilled) { + nextBtn.style.opacity = '1'; + nextBtn.style.cursor = 'pointer'; + } else { + nextBtn.style.opacity = '0.5'; + nextBtn.style.cursor = 'not-allowed'; + } +} + +/** + * Get VLANs allowed for a pattern + */ +export function getPatternVlans(pattern: DeploymentPattern, role?: Role): VLANPurpose[] { + switch (pattern) { + case 'switchless': + return ['management', 'compute']; + case 'switched': + return role === 'TOR1' + ? ['management', 'compute', 'storage_1'] + : ['management', 'compute', 'storage_2']; + case 'fully_converged': + return ['management', 'compute', 'storage_1', 'storage_2']; + default: + return ['management', 'compute']; + } +} + +/** + * Get host port tagged VLANs string for pattern + */ +export function getPatternHostVlans(pattern: DeploymentPattern, role?: Role): string { + // These are the default VLAN IDs - user can override + switch (pattern) { + case 'switchless': + return '7,201'; + case 'switched': + return role === 'TOR1' ? '7,201,711' : '7,201,712'; + case 'fully_converged': + return '7,201,711,712'; + default: + return '7,201'; + } +} + +// ============================================================================ +// CONFIGURATION SUMMARY SIDEBAR +// ============================================================================ + +/** + * Update the configuration summary sidebar with current state + * Called whenever state changes + */ +export function updateConfigSummary(): void { + const config = state.config; + + // Switch section + const patternDisplay = config.switch.deployment_pattern?.replace('_', ' ') || '—'; + setTextContent('#sum-pattern', patternDisplay ? capitalize(patternDisplay) : '—'); + setTextContent('#sum-vendor', config.switch.vendor ? DISPLAY_NAMES.vendors[config.switch.vendor] || config.switch.vendor : '—'); + setTextContent('#sum-model', config.switch.model?.toUpperCase() || '—'); + setTextContent('#sum-role', config.switch.role || '—'); + setTextContent('#sum-hostname', config.switch.hostname || '—'); + + // VLANs section + const vlans = config.vlans || []; + const mgmtVlan = vlans.find(v => v.purpose === 'management'); + const computeVlan = vlans.find(v => v.purpose === 'compute'); + const storage1Vlan = vlans.find(v => v.purpose === 'storage_1'); + const storage2Vlan = vlans.find(v => v.purpose === 'storage_2'); + + setTextContent('#sum-vlan-mgmt', mgmtVlan ? `${mgmtVlan.vlan_id}` : '—'); + setTextContent('#sum-vlan-compute', computeVlan ? `${computeVlan.vlan_id}` : '—'); + setTextContent('#sum-vlan-storage1', storage1Vlan ? `${storage1Vlan.vlan_id}` : '—'); + setTextContent('#sum-vlan-storage2', storage2Vlan ? `${storage2Vlan.vlan_id}` : '—'); + + // Host Ports section + const hostTrunk = (config.interfaces || []).find(i => + i.name?.includes('Host') || i.name?.includes('Converged') || i.name?.includes('Trunk') + ); + if (hostTrunk) { + const range = hostTrunk.start_intf && hostTrunk.end_intf + ? `${hostTrunk.start_intf}-${hostTrunk.end_intf}` + : hostTrunk.intf || '—'; + setTextContent('#sum-port-range', range); + setTextContent('#sum-tagged-vlans', hostTrunk.tagged_vlans || '—'); + } else { + setTextContent('#sum-port-range', '—'); + setTextContent('#sum-tagged-vlans', '—'); + } + + // Redundancy section + const peerLink = (config.port_channels || []).find(pc => pc.vpc_peer_link); + if (peerLink) { + setTextContent('#sum-peerlink', `PC${peerLink.id}`); + } else { + setTextContent('#sum-peerlink', '—'); + } + + const mlag = config.mlag; + if (mlag?.peer_keepalive) { + setTextContent('#sum-keepalive', `${mlag.peer_keepalive.source_ip || '—'}`); + } else { + setTextContent('#sum-keepalive', '—'); + } + + // Routing section + const routingType = config.routing_type || 'bgp'; + setTextContent('#sum-routing-type', routingType.toUpperCase()); + + if (config.bgp) { + setTextContent('#sum-asn', config.bgp.asn ? String(config.bgp.asn) : '—'); + setTextContent('#sum-router-id', config.bgp.router_id || '—'); + const neighborCount = config.bgp.neighbors?.length || 0; + setTextContent('#sum-neighbors', neighborCount > 0 ? `${neighborCount} configured` : '—'); + } else { + setTextContent('#sum-asn', '—'); + setTextContent('#sum-router-id', '—'); + setTextContent('#sum-neighbors', '—'); + } + + // Update progress + updateProgressIndicator(); + + // Update JSON preview in real-time + updateJsonPreview(); +} + +/** + * Helper to set text content safely + */ +function setTextContent(selector: string, text: string): void { + const elem = getElement(selector); + if (elem) elem.textContent = text; +} + +/** + * Helper to capitalize first letter + */ +function capitalize(str: string): string { + return str.charAt(0).toUpperCase() + str.slice(1); +} + +/** + * Update the progress indicator + */ +function updateProgressIndicator(): void { + const config = state.config; + let completed = 0; + const total = 10; // Total checkpoints + + // Phase 1 checks - must have actual non-empty values + if (config.switch.deployment_pattern) completed++; + if (config.switch.vendor) completed++; + if (config.switch.model) completed++; + if (config.switch.role) completed++; + if (config.switch.hostname) completed++; + + // Phase 2 checks + if (config.vlans && config.vlans.length > 0) completed++; + if (config.interfaces && config.interfaces.length > 0) completed++; + if (config.port_channels && config.port_channels.length > 0) completed++; + + // Phase 3 checks - must have actual values + if (config.bgp?.asn || (config.static_routes && config.static_routes.length > 0)) completed++; + if ((config.bgp?.neighbors && config.bgp.neighbors.length > 0) || (config.static_routes && config.static_routes.length > 0)) completed++; + + const percentage = Math.round((completed / total) * 100); + + const fill = getElement('#progress-fill'); + const text = getElement('#progress-text'); + + if (fill) (fill as HTMLElement).style.width = `${percentage}%`; + if (text) text.textContent = `${percentage}%`; +} + +// ============================================================================ +// INITIALIZATION +// ============================================================================ + +export function initializeWizard(): void { + try { + console.log('Initializing wizard...'); + renderTemplateList(); + + // Phase1 already has 'active' class in HTML, just update the navigation + updatePhaseNavigationUI(1); + + // Initialize config summary + updateConfigSummary(); + + console.log('Wizard initialized successfully'); + } catch (error) { + console.error('Error initializing wizard:', error); + } +} + +// ============================================================================ +// LEGACY CARD SELECTION (unused in pattern-first approach) +// Kept for reference - cards now use onclick handlers in HTML +// ============================================================================ + +/* function initializeCardSelections(): void { + initializeCardGroup('.vendor-card', 'vendor', (value) => { + state.config.switch.vendor = value as Vendor; + state.config.switch.firmware = VENDOR_FIRMWARE_MAP[value as Vendor] as Firmware; + updateModelCards(); + }, 'dellemc'); + + initializeCardGroup('.model-card', 'model', (value) => { + state.config.switch.model = value; + }); + updateModelCards(); + + initializeCardGroup('.role-card, .role-btn', 'role', (value) => { + state.config.switch.role = value as Role; + updateRoleBasedSections(); + }, 'TOR1'); + + initializeCardGroup('.pattern-card', 'pattern', (value) => { + state.config.switch.deployment_pattern = value as DeploymentPattern; + }, 'fully_converged'); + + initializeCardGroup('.routing-card', 'routing', (value) => { + state.config.routing_type = value as 'bgp' | 'static'; + updateRoutingSection(); + }, 'bgp'); +} + +function initializeCardGroup( + selector: string, + dataAttr: string, + onChange: ((value: string) => void) | null, + defaultValue: string | null = null +): void { + const cards = getElements(selector); + cards.forEach(card => { + card.addEventListener('click', () => { + cards.forEach(c => c.classList.remove('selected')); + card.classList.add('selected'); + const value = card.dataset[dataAttr]; + if (onChange && value) onChange(value); + }); + }); + + if (defaultValue) { + const defaultCard = getElement(`${selector}[data-${dataAttr}="${defaultValue}"]`); + if (defaultCard) { + defaultCard.classList.add('selected'); + if (onChange) onChange(defaultValue); + } + } +} */ + +export function setupEventListeners(): void { + const importInput = getElement('#import-json'); + if (importInput) { + importInput.addEventListener('change', handleFileImport); + } + + getElements('.btn-next').forEach(btn => { + btn.addEventListener('click', () => nextStep()); + }); + getElements('.btn-back').forEach(btn => { + btn.addEventListener('click', () => prevStep()); + }); + + // Phase navigation tabs + getElements('.nav-phase').forEach(phase => { + phase.addEventListener('click', () => { + const phaseId = phase.dataset.phase; + if (!phaseId) return; + if (phaseId === 'review') { + showPhase('review'); + populateReviewStep(); + return; + } + const numericPhase = parseInt(phaseId, 10); + if (!Number.isNaN(numericPhase)) { + showPhase(numericPhase); + } + }); + }); + + // Phase 2 substep tabs + getElements('.sub-nav-btn').forEach(btn => { + btn.addEventListener('click', () => { + const substep = btn.dataset.substep; + if (substep) { + showPhase(2, substep); + } + }); + }); + + getElements('.nav-step').forEach(step => { + step.addEventListener('click', () => { + const stepNum = parseInt(step.dataset.step || '1'); + // Allow clicking on previous steps, current step, or Review (step 7) + if (stepNum <= state.currentStep || stepNum === 7) { + showStep(stepNum); + } + }); + }); + + const addMgmtBtn = getElement('#btn-add-mgmt'); + if (addMgmtBtn) { + addMgmtBtn.addEventListener('click', () => addDynamicVlan('management')); + } + + const addComputeBtn = getElement('#btn-add-compute'); + if (addComputeBtn) { + addComputeBtn.addEventListener('click', () => addDynamicVlan('compute')); + } + + const addNeighborBtn = getElement('#btn-add-neighbor'); + if (addNeighborBtn) { + addNeighborBtn.addEventListener('click', addBgpNeighbor); + } + + const addRouteBtn = getElement('#btn-add-route'); + if (addRouteBtn) { + addRouteBtn.addEventListener('click', addStaticRoute); + } + + const exportBtn = getElement('#btn-export'); + if (exportBtn) exportBtn.addEventListener('click', exportJSONFile); + + const copyBtn = getElement('#btn-copy'); + if (copyBtn) copyBtn.addEventListener('click', copyJSON); + + const resetBtn = getElement('#btn-reset'); + if (resetBtn) resetBtn.addEventListener('click', startOver); + + const loopbackInput = getElement('#intf-loopback-ip'); + if (loopbackInput) { + loopbackInput.addEventListener('input', (e) => { + const routerIdField = getElement('#bgp-router-id'); + if (routerIdField && e.target instanceof HTMLInputElement) { + const ip = e.target.value.split('/')[0]; + routerIdField.value = ip || ''; + } + }); + } + + // Routing type toggle (BGP/Static) + getElements('.routing-card').forEach(card => { + card.addEventListener('click', () => { + // Remove selected from all routing cards + getElements('.routing-card').forEach(c => c.classList.remove('selected')); + // Add selected to clicked card + card.classList.add('selected'); + // Update state and toggle sections + const routingType = card.dataset.routing as 'bgp' | 'static'; + if (routingType) { + state.config.routing_type = routingType; + updateRoutingSection(); + } + }); + }); + + // Delegate remove button clicks for dynamic entries + document.addEventListener('click', (e) => { + const target = e.target as HTMLElement; + if (target.classList.contains('btn-remove-neighbor')) { + const entry = target.closest('.neighbor-entry'); + if (entry) entry.remove(); + } + }); +} + +// ============================================================================ +// TEMPLATE LOADING +// ============================================================================ + +export function showTemplateModal(): void { + const modal = getElement('#template-modal'); + if (modal) { + modal.style.display = 'flex'; + } +} + +export function closeTemplateModal(): void { + const modal = getElement('#template-modal'); + if (modal) { + modal.style.display = 'none'; + } +} + +export async function loadTemplate(templateName: string): Promise { + closeTemplateModal(); // Close modal immediately + + try { + // templateName is now like "fully-converged/sample-tor1" + const response = await fetch(resolveTemplateUrl(templateName)); + if (!response.ok) { + throw new Error(`Failed to load template: ${response.statusText}`); + } + const config = await response.json() as Partial; + loadConfig(config); + showSuccessMessage(`Loaded template: ${templateName.split('/').pop()}`); + } catch (error) { + showValidationError(`Failed to load template: ${(error as Error).message}`); + } +} + +export function toggleCollapsible(header: HTMLElement): void { + const content = header.nextElementSibling as HTMLElement; + if (content && content.classList.contains('collapsible-content')) { + const isVisible = content.style.display !== 'none'; + content.style.display = isVisible ? 'none' : 'block'; + header.classList.toggle('expanded'); + } +} + +export function updateStorageVlanName(storageNum: number): void { + const vlanIdInput = getElement(`#vlan-storage${storageNum}-id`); + const nameInput = getElement(`#vlan-storage${storageNum}-name`); + + if (vlanIdInput && nameInput && vlanIdInput.value) { + nameInput.value = `Storage${storageNum}_${vlanIdInput.value}`; + nameInput.style.color = '#666'; + } +} + +// ============================================================================ +// NAVIGATION (Phase-based) +// ============================================================================ + +/** + * Show a specific phase + */ +export function showPhase(phase: number | string, substep?: string): void { + try { + console.log('showPhase called with:', phase, substep); + + // Hide all phases + const phases = getElements('.phase'); + console.log('Found phases:', phases.length); + phases.forEach(p => p.classList.remove('active')); + + // Show target phase + const phaseId = phase === 'review' ? 'review' : `phase${phase}`; + const targetPhase = getElement(`#${phaseId}`); + console.log('Target phase:', phaseId, 'found:', !!targetPhase); + + if (targetPhase) { + targetPhase.classList.add('active'); + + // For Phase 2, handle substeps + if (phase === 2 && substep) { + showSubstep(substep); + } else if (phase === 2) { + showSubstep('2.1'); // Default to first substep + } + + updatePhaseNavigationUI(phase); + window.scrollTo({ top: 0, behavior: 'smooth' }); + } else { + console.error('Could not find phase element:', phaseId); + } + } catch (error) { + console.error('Error in showPhase:', error); + } +} + +/** + * Show substep within Phase 2 + */ +function showSubstep(substep: string): void { + // Hide all substeps + getElements('.substep').forEach(s => s.classList.remove('active')); + + // Show target substep + getElements(`.substep[data-substep="${substep}"]`).forEach(s => { + s.classList.add('active'); + }); + + // Update sub-navigation + getElements('.sub-nav-btn').forEach(btn => { + btn.classList.remove('active'); + if (btn.dataset.substep === substep) { + btn.classList.add('active'); + } + }); + + if (substep === '2.2') { + updateHostPortsSections(); + } +} + +/** + * Navigate to next phase or substep + */ +export function nextPhase(): void { + const currentPhase = getCurrentPhase(); + + if (currentPhase === 1) { + // Phase 1 → Phase 2.1 + showPhase(2, '2.1'); + } else if (currentPhase === 2) { + // Phase 2 → Phase 3 + showPhase(3); + } else if (currentPhase === 3) { + // Phase 3 → Review + showPhase('review'); + populateReviewStep(); + } +} + +/** + * Navigate to next substep within Phase 2 + */ +export function nextSubstep(): void { + const substeps = ['2.1', '2.2', '2.3']; + const currentSubstep = getCurrentSubstep(); + const currentIndex = substeps.indexOf(currentSubstep); + + if (currentIndex >= 0 && currentIndex < substeps.length - 1) { + const nextSubstep = substeps[currentIndex + 1]; + if (nextSubstep) showSubstep(nextSubstep); + } else if (currentIndex === substeps.length - 1) { + // Last substep → go to Phase 3 + nextPhase(); + } +} + +/** + * Navigate to previous substep or phase + */ +export function previousSubstep(): void { + const substeps = ['2.1', '2.2', '2.3']; + const currentSubstep = getCurrentSubstep(); + const currentIndex = substeps.indexOf(currentSubstep); + + if (currentIndex > 0) { + const prevSubstep = substeps[currentIndex - 1]; + if (prevSubstep) showSubstep(prevSubstep); + } else { + // First substep → go back to Phase 1 + previousPhase(); + } +} + +/** + * Navigate to previous phase + */ +export function previousPhase(): void { + const currentPhase = getCurrentPhase(); + + if (currentPhase === 2) { + showPhase(1); + } else if (currentPhase === 3) { + showPhase(2, '2.3'); // Go to last substep of Phase 2 (Redundancy) + } else if (currentPhase === 'review') { + showPhase(3); + } +} + +/** + * Get current active phase + */ +function getCurrentPhase(): number | string { + const activePhase = getElement('.phase.active'); + if (!activePhase) return 1; + + const id = activePhase.id; + if (id === 'review') return 'review'; + if (id === 'phase1') return 1; + if (id === 'phase2') return 2; + if (id === 'phase3') return 3; + return 1; +} + +/** + * Get current active substep + */ +function getCurrentSubstep(): string { + const activeSubstep = getElement('.substep.active'); + return activeSubstep?.dataset.substep || '2.1'; +} + +/** + * Update phase navigation UI + */ +function updatePhaseNavigationUI(activePhase: number | string): void { + try { + const navPhases = getElements('.nav-phase'); + console.log('Updating navigation UI for phase:', activePhase, 'found nav elements:', navPhases.length); + + navPhases.forEach(phase => { + phase.classList.remove('active'); + const phaseData = phase.dataset.phase; + + if (phaseData === String(activePhase)) { + phase.classList.add('active'); + console.log('Activated nav phase:', phaseData); + } + }); + } catch (error) { + console.error('Error updating phase navigation UI:', error); + } +} + +// ============================================================================ +// LEGACY NAVIGATION (to be removed/refactored) +// ============================================================================ + +function showStep(stepNum: number): void { + getElements('.step').forEach(s => s.classList.remove('active')); + const targetStep = getElement(`#step${stepNum}`); + if (targetStep) { + targetStep.classList.add('active'); + state.currentStep = stepNum; + updateNavigationUI(); + + if (stepNum === 3) updateHostPortsSections(); + if (stepNum === 7) populateReviewStep(); + } +} + +function nextStep(): void { + if (validateCurrentStep()) { + collectStepData(); + // Mark current step as completed before moving + const steps = getElements('.nav-step'); + const currentStepElem = steps[state.currentStep - 1]; + if (currentStepElem) { + currentStepElem.classList.add('completed'); + } + if (state.currentStep < state.totalSteps) { + showStep(state.currentStep + 1); + window.scrollTo({ top: 0, behavior: 'smooth' }); + } + } +} + +function prevStep(): void { + if (state.currentStep > 1) { + showStep(state.currentStep - 1); + window.scrollTo({ top: 0, behavior: 'smooth' }); + } +} + +function updateNavigationUI(): void { + getElements('.nav-step').forEach(step => { + const stepNum = parseInt(step.dataset.step || '0'); + step.classList.remove('active'); + // Only add active to current step, but preserve completed class + if (stepNum === state.currentStep) { + step.classList.add('active'); + } else if (stepNum < state.currentStep) { + step.classList.add('completed'); + } + }); +} + +function updateHostPortsSections(): void { + const deploymentPattern = state.config.switch.deployment_pattern || 'fully_converged'; + const role = state.config.switch.role || 'TOR1'; + const storagePurposes = ['storage_1', 'storage_2']; + const displayElem = getElement('#deployment-pattern-display'); + if (displayElem) { + displayElem.textContent = deploymentPattern.replace('_', ' ').toUpperCase(); + } + + // Hide all port sections first + const sections = [ + '#port-section-converged', + '#port-section-mgmt-compute', + '#port-section-storage1', + '#port-section-storage2', + '#port-section-switchless' + ]; + sections.forEach(id => { + const elem = getElement(id); + if (elem) (elem as HTMLElement).style.display = 'none'; + }); + + // Show appropriate sections and update VLAN lists + if (deploymentPattern === 'fully_converged') { + const section = getElement('#port-section-converged'); + if (section) (section as HTMLElement).style.display = ''; + updateVlanDisplay('#converged-vlans-display', ['management', 'compute', ...storagePurposes]); + } else if (deploymentPattern === 'switched') { + // Switched pattern: Management+Compute ports for all TORs + const section = getElement('#port-section-mgmt-compute'); + if (section) (section as HTMLElement).style.display = ''; + updateVlanDisplay('#mgmt-compute-vlans-display', ['management', 'compute']); + + // Switched pattern: Show only the relevant storage section based on TOR role + // TOR1 → Storage 1 (VLAN 711), TOR2 → Storage 2 (VLAN 712) + if (role === 'TOR1') { + const storage1Section = getElement('#port-section-storage1'); + if (storage1Section) (storage1Section as HTMLElement).style.display = ''; + updateVlanDisplay('#storage1-vlans-display', ['storage_1']); + } else if (role === 'TOR2') { + const storage2Section = getElement('#port-section-storage2'); + if (storage2Section) (storage2Section as HTMLElement).style.display = ''; + updateVlanDisplay('#storage2-vlans-display', ['storage_2']); + } + } else if (deploymentPattern === 'switchless') { + const section = getElement('#port-section-switchless'); + if (section) (section as HTMLElement).style.display = ''; + updateVlanDisplay('#switchless-vlans-display', ['management', 'compute']); + } +} + +function updateVlanDisplay(containerId: string, purposes: string[]): void { + // Determine which fields to populate based on the container + let nativeFieldId = ''; + let taggedFieldId = ''; + let hintId = ''; + let useNativeVlan99 = false; // Only for storage trunk ports, use dummy VLAN 99 + + if (containerId === '#converged-vlans-display') { + nativeFieldId = '#intf-converged-native'; + taggedFieldId = '#intf-converged-tagged'; + hintId = '#converged-vlans-hint'; + } else if (containerId === '#mgmt-compute-vlans-display') { + nativeFieldId = '#intf-mgmt-compute-native'; + taggedFieldId = '#intf-mgmt-compute-tagged'; + hintId = '#mgmt-compute-vlans-hint'; + // Host trunk uses management VLAN as native (NOT 99) + } else if (containerId === '#storage1-vlans-display') { + nativeFieldId = '#intf-storage1-native'; + taggedFieldId = '#intf-storage1-tagged'; + hintId = '#storage1-vlans-hint'; + useNativeVlan99 = true; // Storage trunk uses dummy VLAN 99 + } else if (containerId === '#storage2-vlans-display') { + nativeFieldId = '#intf-storage2-native'; + taggedFieldId = '#intf-storage2-tagged'; + hintId = '#storage2-vlans-hint'; + useNativeVlan99 = true; // Storage trunk uses dummy VLAN 99 + } else if (containerId === '#switchless-vlans-display') { + nativeFieldId = '#intf-switchless-native'; + taggedFieldId = '#intf-switchless-tagged'; + hintId = '#switchless-vlans-hint'; + } + + // Get VLANs from state matching the specified purposes + const vlans = (state.config.vlans || []) + .filter(v => !v.shutdown && purposes.some(p => v.purpose?.includes(p))); + + const mgmtVlan = vlans.find(v => v.purpose === 'management'); + const vlanIds = vlans.map(v => v.vlan_id).join(','); + const vlanNames = vlans.map(v => `${v.vlan_id} (${v.name})`).join(', '); + + // Populate native VLAN + if (nativeFieldId) { + const nativeField = getElement(nativeFieldId); + if (nativeField) { + if (useNativeVlan99) { + // Switched pattern: use dummy VLAN 99 + nativeField.value = '99'; + nativeField.placeholder = '99'; + } else if (mgmtVlan) { + // Other patterns: use management VLAN + nativeField.value = String(mgmtVlan.vlan_id); + nativeField.placeholder = String(mgmtVlan.vlan_id); + } + } + } + + // Populate tagged VLANs + if (taggedFieldId) { + const taggedField = getElement(taggedFieldId); + if (taggedField) { + // For storage VLANs, use defaults if not configured + if (purposes.includes('storage_1') && !vlanIds) { + taggedField.value = '711'; + taggedField.placeholder = '711'; + } else if (purposes.includes('storage_2') && !vlanIds) { + taggedField.value = '712'; + taggedField.placeholder = '712'; + } else { + taggedField.value = vlanIds; + taggedField.placeholder = vlanIds || 'No VLANs configured'; + } + } + } + + // Update hint with human-readable names + if (hintId) { + const hint = getElement(hintId); + if (hint) { + if (vlans.length === 0) { + // Show default hint for storage VLANs + if (purposes.includes('storage_1')) { + hint.textContent = '711 (Storage1_711)'; + (hint as HTMLElement).style.color = '#4CAF50'; + } else if (purposes.includes('storage_2')) { + hint.textContent = '712 (Storage2_712)'; + (hint as HTMLElement).style.color = '#4CAF50'; + } else { + hint.textContent = 'Complete Step 2 to configure VLANs'; + (hint as HTMLElement).style.color = '#999'; + } + } else { + hint.textContent = vlanNames; + (hint as HTMLElement).style.color = '#4CAF50'; + } + } + } +} + +function markCompletedSteps(): void { + // Mark steps as completed based on populated data in state + const steps = getElements('.nav-step'); + + // Step 1: Switch info (always completed if vendor/model set) + if (state.config.switch.vendor && state.config.switch.model) { + steps[0]?.classList.add('completed'); + } + + // Step 2: VLANs (completed if vlans exist) + if (state.config.vlans && state.config.vlans.length > 0) { + steps[1]?.classList.add('completed'); + } + + // Step 3: Host Ports (completed if trunk interfaces exist that aren't peer-links) + if (state.config.interfaces && state.config.interfaces.some(i => i.type === 'Trunk')) { + steps[2]?.classList.add('completed'); + } + + // Step 4: Redundancy (completed if mlag or port_channels exist) + if (state.config.mlag || (state.config.port_channels && state.config.port_channels.length > 0)) { + steps[3]?.classList.add('completed'); + } + + // Step 5: Uplinks (completed if loopback interface exists) + if (state.config.interfaces && state.config.interfaces.some(i => i.intf_type === 'loopback')) { + steps[4]?.classList.add('completed'); + } + + // Step 6: Routing (completed if bgp or static_routes exist) + if (state.config.bgp || (state.config.static_routes && state.config.static_routes.length > 0)) { + steps[5]?.classList.add('completed'); + } + + // Step 7: Review (mark as completed if we have enough data) + if (steps[0]?.classList.contains('completed') && steps[1]?.classList.contains('completed')) { + steps[6]?.classList.add('completed'); + } +} + +// ============================================================================ +// UI UPDATES +// ============================================================================ + +export function updateModelCards(): void { + const vendor = state.config.switch.vendor; + getElements('.model-card').forEach(card => { + if (card.dataset.vendor === vendor) { + card.style.display = ''; + } else { + card.style.display = 'none'; + card.classList.remove('selected'); + } + }); + + const firstVisible = getElement(`.model-card[data-vendor="${vendor}"]`); + if (firstVisible && !getElement('.model-card.selected[style=""]')) { + firstVisible.classList.add('selected'); + state.config.switch.model = firstVisible.dataset.model || ''; + } +} + +export function updateRoleBasedSections(): void { + const role = state.config.switch.role; + + toggleElement('#section-mlag', role !== 'BMC'); + toggleElement('#section-ibgp-pc', role !== 'BMC'); + toggleElement('#section-bmc-vlan', role === 'BMC'); +} + +export function updateRoutingSection(): void { + const routingType = state.config.routing_type || 'bgp'; + const bgpSection = getElement('#bgp-section'); + const staticSection = getElement('#static-section'); + + if (bgpSection) { + (bgpSection as HTMLElement).style.display = routingType === 'bgp' ? '' : 'none'; + } + if (staticSection) { + (staticSection as HTMLElement).style.display = routingType === 'static' ? '' : 'none'; + } +} + +// ============================================================================ +// DATA COLLECTION +// ============================================================================ + +function collectStepData(): void { + switch (state.currentStep) { + case 1: + collectSwitchData(); + break; + case 2: + collectVlanData(); + break; + case 3: + collectHostPortsData(); + break; + case 4: + collectRedundancyData(); + break; + case 5: + collectUplinksData(); + break; + case 6: + collectRoutingData(); + break; + } +} + +function collectSwitchData(): void { + state.config.switch.hostname = getInputValue('#hostname'); + state.config.switch.firmware = VENDOR_FIRMWARE_MAP[state.config.switch.vendor] as Firmware; +} + +function collectVlanData(): void { + const vlans: VLAN[] = []; + const vendor = state.config.switch.vendor; + const role = state.config.switch.role; + const redundancyType = VENDOR_REDUNDANCY_TYPE[vendor]; + + const parkingId = parseIntSafe(getInputValue('#vlan-parking-id')); + if (parkingId) { + vlans.push({ + vlan_id: parkingId, + name: 'UNUSED_VLAN', + shutdown: true + }); + } + + collectVlansByType('management', vlans, redundancyType, role); + collectVlansByType('compute', vlans, redundancyType, role); + + const storage1Id = parseIntSafe(getInputValue('#vlan-storage1-id')); + if (storage1Id) { + const storage1Name = getInputValue('#vlan-storage1-name'); + vlans.push({ + vlan_id: storage1Id, + name: storage1Name || `Storage1_${storage1Id}`, + purpose: 'storage_1' + }); + } + + const storage2Id = parseIntSafe(getInputValue('#vlan-storage2-id')); + if (storage2Id) { + const storage2Name = getInputValue('#vlan-storage2-name'); + vlans.push({ + vlan_id: storage2Id, + name: storage2Name || `Storage2_${storage2Id}`, + purpose: 'storage_2' + }); + } + + if (role === 'BMC') { + const bmcId = parseIntSafe(getInputValue('#vlan-bmc-id')); + if (bmcId) { + const bmcName = getInputValue('#vlan-bmc-name'); + const bmcVlan: VLAN = { + vlan_id: bmcId, + name: bmcName || `BMC_${bmcId}`, + purpose: 'bmc' + }; + + const bmcIp = getInputValue('#vlan-bmc-ip'); + if (bmcIp) { + bmcVlan.interface = { + ip: bmcIp, + cidr: parseIntSafe(getInputValue('#vlan-bmc-cidr'), 26), + mtu: 9216 + }; + } + vlans.push(bmcVlan); + } + } + + state.config.vlans = vlans; +} + +function collectVlansByType( + type: 'management' | 'compute', + vlans: VLAN[], + redundancyType: RedundancyType, + role: Role +): void { + const config = VLAN_CONFIGS[type]; + if (!config) return; + + const cards = getElements(`[data-vlan-type="${type}"]`); + const cssClass = config.cssClass; + + cards.forEach((card) => { + const vlanIdInput = card.querySelector(`.vlan-${cssClass}-id`); + const vlanId = vlanIdInput ? parseInt(vlanIdInput.value) : 0; + if (!vlanId) return; + + const customNameInput = card.querySelector(`.vlan-${cssClass}-name`); + const customName = customNameInput?.value || ''; + + const vlan: VLAN = { + vlan_id: vlanId, + name: customName || `${config.namePrefix}_${vlanId}`, + purpose: config.purpose + }; + + const ipInput = card.querySelector(`.vlan-${cssClass}-ip`); + const ipValue = ipInput?.value || ''; + if (ipValue) { + const parts = ipValue.includes('/') ? ipValue.split('/') : [ipValue, '24']; + const ip = parts[0] || ''; + const cidr = parts[1] || '24'; + vlan.interface = { + ip: ip, + cidr: parseInt(cidr, 10) || 24, + mtu: 9216 + }; + + const gatewayInput = card.querySelector(`.vlan-${cssClass}-gateway`); + const gatewayValue = gatewayInput?.value || ''; + if (gatewayValue && role !== 'BMC' && vlan.interface) { + const gwIp = (gatewayValue.includes('/') ? gatewayValue.split('/')[0] : gatewayValue) || ''; + vlan.interface.redundancy = { + type: redundancyType, + virtual_ip: gwIp, + preempt: true, + group: vlanId, + priority: ROLE_DEFAULTS[role].hsrp_priority || 100 + }; + } + + const dhcpInput = card.querySelector(`.vlan-${cssClass}-dhcp`); + const dhcpRelay = dhcpInput?.value || ''; + if (dhcpRelay && vlan.interface) { + vlan.interface.dhcp_relay = dhcpRelay.split(',').map(s => s.trim()); + } + } + + vlans.push(vlan); + }); +} + +function collectHostPortsData(): void { + const interfaces: Interface[] = []; + const deploymentPattern = state.config.switch.deployment_pattern; + const role = state.config.switch.role || 'TOR1'; + + const mgmtVlan = (state.config.vlans || []).find(v => v.purpose === 'management'); + const nativeVlan = mgmtVlan ? String(mgmtVlan.vlan_id) : '7'; + + const allVlans = (state.config.vlans || []) + .filter(v => !v.shutdown) + .map(v => v.vlan_id) + .join(','); + + const mgmtComputeVlans = (state.config.vlans || []) + .filter(v => !v.shutdown && (v.purpose === 'management' || v.purpose === 'compute')) + .map(v => v.vlan_id) + .join(','); + + if (deploymentPattern === 'fully_converged') { + // Fully converged: All VLANs on same ports + const start = getInputValue('#intf-converged-start'); + const end = getInputValue('#intf-converged-end'); + const qos = getElement('#intf-converged-qos'); + const nativeVlanInput = getInputValue('#intf-converged-native'); + const taggedVlansInput = getInputValue('#intf-converged-tagged'); + + if (start && end) { + interfaces.push({ + name: 'HyperConverged_To_Hosts', + type: 'Trunk', + intf_type: 'Ethernet', + start_intf: start, + end_intf: end, + native_vlan: nativeVlanInput || nativeVlan, + tagged_vlans: taggedVlansInput || allVlans, + qos: qos?.checked || false + } as Interface); + } + } else if (deploymentPattern === 'switched') { + // Storage switched: Separate mgmt/compute and storage ports + const mgmtStart = getInputValue('#intf-mgmt-compute-start'); + const mgmtEnd = getInputValue('#intf-mgmt-compute-end'); + const mgmtNativeInput = getInputValue('#intf-mgmt-compute-native'); + const mgmtTaggedInput = getInputValue('#intf-mgmt-compute-tagged'); + + if (mgmtStart && mgmtEnd) { + interfaces.push({ + name: 'Host_Trunk', + type: 'Trunk', + intf_type: 'Ethernet', + start_intf: mgmtStart, + end_intf: mgmtEnd, + native_vlan: mgmtNativeInput || nativeVlan, // Use management VLAN as native + tagged_vlans: mgmtTaggedInput || mgmtComputeVlans + } as Interface); + } + + // Storage Trunk: Only collect for the appropriate TOR role + // TOR1 → Storage 1 (VLAN 711), TOR2 → Storage 2 (VLAN 712) + if (role === 'TOR1') { + const storage1Start = getInputValue('#intf-storage1-start'); + const storage1End = getInputValue('#intf-storage1-end'); + const storage1NativeInput = getInputValue('#intf-storage1-native'); + const storage1TaggedInput = getInputValue('#intf-storage1-tagged'); + + if (storage1Start && storage1End) { + const storage1Qos = getElement('#intf-storage1-qos'); + interfaces.push({ + name: 'Storage_Trunk', + type: 'Trunk', + intf_type: 'Ethernet', + start_intf: storage1Start, + end_intf: storage1End, + native_vlan: storage1NativeInput || '99', // Storage trunk uses dummy VLAN 99 + tagged_vlans: storage1TaggedInput || '711', + qos: storage1Qos?.checked || false + } as Interface); + } + } else if (role === 'TOR2') { + const storage2Start = getInputValue('#intf-storage2-start'); + const storage2End = getInputValue('#intf-storage2-end'); + const storage2NativeInput = getInputValue('#intf-storage2-native'); + const storage2TaggedInput = getInputValue('#intf-storage2-tagged'); + + if (storage2Start && storage2End) { + const storage2Qos = getElement('#intf-storage2-qos'); + interfaces.push({ + name: 'Storage_Trunk', + type: 'Trunk', + intf_type: 'Ethernet', + start_intf: storage2Start, + end_intf: storage2End, + native_vlan: storage2NativeInput || '99', // Storage trunk uses dummy VLAN 99 + tagged_vlans: storage2TaggedInput || '712', + qos: storage2Qos?.checked || false + } as Interface); + } + } + } else if (deploymentPattern === 'switchless') { + // Switchless: Only mgmt/compute ports (no storage network) + const start = getInputValue('#intf-switchless-start'); + const end = getInputValue('#intf-switchless-end'); + const nativeVlanInput = getInputValue('#intf-switchless-native'); + const taggedVlansInput = getInputValue('#intf-switchless-tagged'); + + if (start && end) { + interfaces.push({ + name: 'Host_Trunk', + type: 'Trunk', + intf_type: 'Ethernet', + start_intf: start, + end_intf: end, + native_vlan: nativeVlanInput || nativeVlan, + tagged_vlans: taggedVlansInput || mgmtComputeVlans + } as Interface); + } + } + + state.config.interfaces = interfaces; +} + +function collectRedundancyData(): void { + const role = state.config.switch.role; + const portChannels: PortChannel[] = []; + + const taggedVlans = (state.config.vlans || []) + .filter(v => !v.shutdown) + .map(v => v.vlan_id) + .join(','); + + if (role !== 'BMC') { + const ibgpPcId = parseIntSafe(getInputValue('#pc-ibgp-id')); + const ibgpPcIp = getInputValue('#pc-ibgp-ip'); + const ibgpMembers = getInputValue('#pc-ibgp-members'); + if (ibgpPcId && ibgpPcIp) { + portChannels.push({ + id: ibgpPcId, + description: 'iBGP_Peer_Link_To_TOR2', + type: 'L3', + ipv4: ibgpPcIp, + members: ibgpMembers ? ibgpMembers.split(',').map(s => s.trim()) : [] + }); + } + + // MLAG Peer-Link Port-Channel (editable) + const peerLinkId = parseIntSafe(getInputValue('#mlag-peerlink-id'), MLAG_PEER_LINK_ID); + const peerLinkMembersInput = getInputValue('#mlag-peerlink-members'); + const peerLinkMembers = peerLinkMembersInput + ? peerLinkMembersInput.split(',').map(s => s.trim()).filter(Boolean) + : MLAG_PEER_LINK_MEMBERS; + + portChannels.push({ + id: peerLinkId, + description: 'MLAG_Peer_Link_To_TOR2', + type: 'Trunk', + native_vlan: MLAG_NATIVE_VLAN, + tagged_vlans: taggedVlans, + vpc_peer_link: true, + members: peerLinkMembers + }); + + const keepaliveSrc = getInputValue('#mlag-keepalive-src'); + const keepaliveDst = getInputValue('#mlag-keepalive-dst'); + if (keepaliveSrc && keepaliveDst) { + state.config.mlag = { + domain_id: parseIntSafe(getInputValue('#mlag-domain-id'), 1), + peer_keepalive: { + source_ip: keepaliveSrc, + destination_ip: keepaliveDst + } + }; + } + } else { + state.config.mlag = undefined; + } + + state.config.port_channels = portChannels; +} + +function collectUplinksData(): void { + const interfaces = state.config.interfaces || []; + + const loopbackIp = getInputValue('#intf-loopback-ip'); + if (loopbackIp) { + interfaces.push({ + name: 'Loopback0', + type: 'L3', + intf_type: 'loopback', + intf: 'loopback0', + ipv4: loopbackIp + }); + } + + const uplink1Port = getInputValue('#intf-uplink1-port'); + const uplink1Ip = getInputValue('#intf-uplink1-ip'); + if (uplink1Port && uplink1Ip) { + interfaces.push({ + name: 'P2P_Border1', + type: 'L3', + intf_type: 'Ethernet', + intf: uplink1Port, + ipv4: uplink1Ip + }); + } + + const uplink2Port = getInputValue('#intf-uplink2-port'); + const uplink2Ip = getInputValue('#intf-uplink2-ip'); + if (uplink2Port && uplink2Ip) { + interfaces.push({ + name: 'P2P_Border2', + type: 'L3', + intf_type: 'Ethernet', + intf: uplink2Port, + ipv4: uplink2Ip + }); + } + + state.config.interfaces = interfaces; +} + +// Legacy function removed - now split into separate functions + +function collectRoutingData(): void { + if (state.config.routing_type === 'bgp') { + collectBgpData(); + state.config.static_routes = []; + } else { + collectStaticRoutesData(); + state.config.bgp = undefined; + state.config.prefix_lists = {}; + } +} + +function collectBgpData(): void { + const asn = parseIntSafe(getInputValue('#bgp-asn')); + const loopbackIp = getInputValue('#intf-loopback-ip'); + const routerId = loopbackIp ? loopbackIp.split('/')[0] || '' : ''; + + const networks: string[] = []; + if (loopbackIp) networks.push(loopbackIp); + + const uplink1Ip = getInputValue('#intf-uplink1-ip'); + if (uplink1Ip) networks.push(uplink1Ip); + + const neighbors: BGPNeighbor[] = []; + getElements('.neighbor-entry').forEach(entry => { + const ipInput = entry.querySelector('.bgp-neighbor-ip'); + const descInput = entry.querySelector('.bgp-neighbor-desc'); + const asnInput = entry.querySelector('.bgp-neighbor-asn'); + + const ip = ipInput?.value || ''; + const desc = descInput?.value || ''; + const remoteAsn = asnInput ? parseInt(asnInput.value) : 0; + + if (ip && remoteAsn) { + neighbors.push({ + ip: ip, + description: desc || `TO_${ip}`, + remote_as: remoteAsn, + af_ipv4_unicast: { + prefix_list_in: 'DefaultRoute' + } + }); + } + }); + + state.config.bgp = { + asn: asn, + router_id: routerId, + networks: networks, + neighbors: neighbors + }; + + state.config.prefix_lists = { + DefaultRoute: [ + { seq: 10, action: 'permit', prefix: '0.0.0.0/0' }, + { seq: 50, action: 'deny', prefix: '0.0.0.0/0', prefix_filter: 'le 32' } + ] + }; +} + +function collectStaticRoutesData(): void { + const routes: Array<{ destination: string; next_hop: string; name?: string }> = []; + + const defaultEnabled = getElement('#static-default-enabled'); + if (defaultEnabled?.checked) { + const nexthop = getInputValue('#static-default-nexthop'); + if (nexthop) { + routes.push({ + destination: '0.0.0.0/0', + next_hop: nexthop, + name: 'Default_Route' + }); + } + } + + getElements('.static-route-entry').forEach(entry => { + const destInput = entry.querySelector('.route-dest'); + const nexthopInput = entry.querySelector('.route-nexthop'); + const nameInput = entry.querySelector('.route-name'); + + const dest = destInput?.value; + const nexthop = nexthopInput?.value; + const name = nameInput?.value; + + if (dest && nexthop) { + routes.push({ + destination: dest, + next_hop: nexthop, + name: name || `Route_to_${dest}` + }); + } + }); + + state.config.static_routes = routes; +} + +// ============================================================================ +// DYNAMIC UI ELEMENTS +// ============================================================================ + +export function addDynamicVlan(type: 'management' | 'compute', data: VLAN | null = null): void { + const config = VLAN_CONFIGS[type]; + if (!config) return; + + const containerId = type === 'management' ? 'mgmt-vlans-container' : 'compute-vlans-container'; + const container = getElement(`#${containerId}`); + if (!container) return; + + const index = config.counter++; + const vlanId = config.defaultVlanId(index); + + const card = document.createElement('div'); + card.className = 'vlan-card dynamic-vlan'; + card.dataset.vlanType = type; + card.dataset.vlanIndex = String(index); + + card.innerHTML = createVlanCardHTML(config, index, vlanId); + container.appendChild(card); + + if (data) { + populateVlanCard(card, config, data); + } +} + +function createVlanCardHTML(config: VLANConfig, index: number, vlanId: number): string { + const switchIp = typeof config.switchIpPlaceholder === 'function' + ? config.switchIpPlaceholder(index) + : config.switchIpPlaceholder; + + const gateway = typeof config.gatewayPlaceholder === 'function' + ? config.gatewayPlaceholder(index) + : config.gatewayPlaceholder; + + return ` +
+

${config.label} VLAN #${index + 1}

+ +
+
+
+ + +
+
+ + + Optional - defaults to ${config.namePrefix}_{vlan_id} +
+
+
+
+ + +
+
+ + +
+
+
+ + +
+ `; +} + +function populateVlanCard(card: HTMLElement, config: VLANConfig, data: VLAN): void { + const cssClass = config.cssClass; + + const idInput = card.querySelector(`.vlan-${cssClass}-id`); + const nameInput = card.querySelector(`.vlan-${cssClass}-name`); + const ipInput = card.querySelector(`.vlan-${cssClass}-ip`); + const gatewayInput = card.querySelector(`.vlan-${cssClass}-gateway`); + const dhcpInput = card.querySelector(`.vlan-${cssClass}-dhcp`); + + if (idInput && data.vlan_id) { + idInput.value = String(data.vlan_id); + idInput.placeholder = String(data.vlan_id); + } + + if (nameInput) { + const defaultName = `${config.namePrefix}_${data.vlan_id || ''}`; + const customName = data.name || defaultName; + nameInput.value = customName; + nameInput.placeholder = defaultName; + nameInput.style.color = data.name ? '#333' : '#666'; + } + + if (ipInput && data.interface?.ip) { + const cidr = data.interface?.cidr || 24; + ipInput.value = `${data.interface.ip}/${cidr}`; + } + + if (gatewayInput && data.interface?.redundancy?.virtual_ip) { + const cidr = data.interface?.cidr || 24; + gatewayInput.value = `${data.interface.redundancy.virtual_ip}/${cidr}`; + } + + if (dhcpInput && data.interface?.dhcp_relay) { + dhcpInput.value = data.interface.dhcp_relay.join(','); + } +} + +export function setupVlanCardDelegation(): void { + document.addEventListener('click', (e) => { + const target = e.target as HTMLElement; + if (target.dataset.removeVlan === 'true') { + removeDynamicVlan(target); + } + }); + + document.addEventListener('change', (e) => { + const target = e.target as HTMLInputElement; + if (target.classList.contains('vlan-mgmt-id') || target.classList.contains('vlan-compute-id')) { + updateVlanName(target); + } + }); +} + +function removeDynamicVlan(btn: HTMLElement): void { + const card = btn.closest('.vlan-card'); + if (card && confirm('Remove this VLAN?')) { + card.remove(); + } +} + +export function updateVlanName(idInput: HTMLInputElement, type?: string, prefix?: string): void { + const vlanId = idInput.value; + if (!vlanId) return; + + const cssClass = type || idInput.dataset.cssClass; + const namePrefix = prefix || idInput.dataset.namePrefix; + if (!cssClass || !namePrefix) return; + + const card = idInput.closest('.vlan-card'); + const nameInput = card?.querySelector(`.vlan-${cssClass}-name`); + + if (nameInput) { + const newName = `${namePrefix}_${vlanId}`; + nameInput.placeholder = newName; + + const currentValue = nameInput.value.trim(); + const isAutoGenerated = !currentValue || /^(Infra|Compute)_\d+$/.test(currentValue); + + if (isAutoGenerated) { + nameInput.value = newName; + nameInput.style.color = '#666'; + } + } +} + +function addBgpNeighbor(): void { + const container = getElement('#bgp-neighbors'); + if (!container) return; + + const neighborCount = container.querySelectorAll('.neighbor-entry').length + 1; + const defaultDesc = neighborCount === 1 ? 'TO_BORDER1' : `TO_BORDER${neighborCount}`; + + const entry = document.createElement('div'); + entry.className = 'neighbor-entry'; + entry.innerHTML = ` +
+ Neighbor ${neighborCount} + +
+
+
+ + +
+
+ + +
+
+ + +
+
+ `; + container.appendChild(entry); +} + +// ============================================================================ +// TEMPLATE LIST RENDERING (dynamic) +// ============================================================================ + +const EXAMPLE_TEMPLATES = import.meta.glob('../examples/**/*.json', { eager: true, query: '?url', import: 'default' }); + +type TemplateCard = { + key: string; + pattern: string; + name: string; +}; + +function getTemplateCards(): TemplateCard[] { + return Object.keys(EXAMPLE_TEMPLATES).map((path) => { + const rel = path.split('/examples/')[1] || path.split('examples/')[1] || ''; + const [pattern, filename] = rel.split('/'); + const name = (filename || '').replace(/\.json$/i, ''); + return { + key: `${pattern}/${name}`, + pattern: pattern || '', + name + }; + }).filter(card => card.pattern && card.name); +} + +function resolveTemplateUrl(templateName: string): string { + const matchKey = Object.keys(EXAMPLE_TEMPLATES).find((key) => + key.endsWith(`/examples/${templateName}.json`) || key.endsWith(`examples/${templateName}.json`) + ); + if (matchKey) return EXAMPLE_TEMPLATES[matchKey] as string; + return `${import.meta.env.BASE_URL}examples/${templateName}.json`; +} + +function getPatternLabel(pattern: string): string { + switch (pattern) { + case 'fully-converged': + return '🔄 Fully Converged'; + case 'switched': + return '💾 Switched'; + case 'switchless': + return '🔌 Switchless'; + default: + return pattern; + } +} + +function getTemplateTitle(name: string): string { + if (name.includes('tor1')) return '🔵 TOR1 (Primary)'; + if (name.includes('tor2')) return '🟢 TOR2 (Secondary)'; + return name; +} + +function getTemplateDescription(pattern: string, name: string): string { + if (pattern === 'switchless') return 'Management + Compute only (no storage)'; + if (pattern === 'switched' && name.includes('tor1')) return 'Storage on dedicated ports (S1 only)'; + if (pattern === 'switched' && name.includes('tor2')) return 'Storage on dedicated ports (S2 only)'; + if (pattern === 'fully-converged' && name.includes('tor1')) return 'All VLANs (M, C, S1, S2) on shared ports'; + if (pattern === 'fully-converged' && name.includes('tor2')) return 'Paired with TOR1 for high availability'; + return 'Pre-configured example template'; +} + +function getTemplateTags(pattern: string): string[] { + const tags = [] as string[]; + if (pattern === 'fully-converged') tags.push('Fully Converged'); + if (pattern === 'switched') tags.push('Switched'); + if (pattern === 'switchless') tags.push('Switchless'); + tags.push('BGP'); + if (pattern !== 'switchless') tags.push('MLAG'); + return tags; +} + +function renderTemplateList(): void { + const container = getElement('#template-list'); + if (!container) return; + + const cards = getTemplateCards(); + const patterns = ['fully-converged', 'switched', 'switchless']; + + container.innerHTML = ''; + + patterns.forEach(pattern => { + const patternCards = cards.filter(card => card.pattern === pattern); + if (patternCards.length === 0) return; + + const title = document.createElement('h3'); + title.className = 'template-category'; + title.textContent = getPatternLabel(pattern); + container.appendChild(title); + + const grid = document.createElement('div'); + grid.className = 'template-grid'; + + patternCards.forEach(card => { + const cardEl = document.createElement('div'); + cardEl.className = 'template-card'; + cardEl.addEventListener('click', () => loadTemplate(card.key)); + + const h4 = document.createElement('h4'); + h4.textContent = getTemplateTitle(card.name); + + const desc = document.createElement('p'); + desc.textContent = getTemplateDescription(pattern, card.name); + + const tagsWrap = document.createElement('div'); + tagsWrap.className = 'template-tags'; + getTemplateTags(pattern).forEach(tag => { + const span = document.createElement('span'); + span.className = 'tag'; + span.textContent = tag; + tagsWrap.appendChild(span); + }); + + cardEl.appendChild(h4); + cardEl.appendChild(desc); + cardEl.appendChild(tagsWrap); + grid.appendChild(cardEl); + }); + + container.appendChild(grid); + }); +} + +function addStaticRoute(): void { + const container = getElement('#static-routes-container'); + if (!container) return; + + const entry = document.createElement('div'); + entry.className = 'static-route-entry'; + entry.innerHTML = ` +
+
+ + +
+
+ + +
+
+ + +
+ +
+ `; + container.appendChild(entry); +} + +export function setupRouteDelegation(): void { + document.addEventListener('click', (e) => { + const target = e.target as HTMLElement; + if (target.dataset.removeEntry) { + const entry = target.closest('.neighbor-entry, .static-route-entry'); + if (entry) { + entry.remove(); + } + } + }); +} + +// ============================================================================ +// VALIDATION +// ============================================================================ + +function validateCurrentStep(): boolean { + clearValidationErrors(); + + switch (state.currentStep) { + case 1: + return validateSwitchStep(); + case 2: + return validateVlanStep(); + case 3: + return validateHostPortsStep(); + case 4: + return validateRedundancyStep(); + case 5: + return validateUplinksStep(); + case 6: + return validateRoutingStep(); + default: + return true; + } +} + +function validateSwitchStep(): boolean { + const hostname = getInputValue('#hostname'); + if (!hostname) { + showValidationError('⚠️ Hostname is required'); + return false; + } + + if (!/^[a-zA-Z0-9][a-zA-Z0-9._-]*$/.test(hostname)) { + showValidationError('⚠️ Invalid hostname format. Use letters, numbers, dots, hyphens, and underscores only.'); + return false; + } + + return true; +} + +function validateVlanStep(): boolean { + if ((state.config.vlans || []).length === 0) { + showValidationError('⚠️ At least one VLAN is required. Please add a Management or Compute VLAN.'); + return false; + } + + const hasManagement = state.config.vlans?.some(v => v.purpose === 'management'); + if (!hasManagement) { + showValidationError('⚠️ At least one Management VLAN is required for switch management.'); + return false; + } + + return true; +} + +function validateHostPortsStep(): boolean { + const deploymentPattern = state.config.switch.deployment_pattern || 'fully_converged'; + let hostStart = ''; + let hostEnd = ''; + + // Check appropriate fields based on deployment pattern + if (deploymentPattern === 'fully_converged') { + hostStart = getInputValue('#intf-converged-start'); + hostEnd = getInputValue('#intf-converged-end'); + } else if (deploymentPattern === 'switched') { + hostStart = getInputValue('#intf-mgmt-compute-start'); + hostEnd = getInputValue('#intf-mgmt-compute-end'); + } else if (deploymentPattern === 'switchless') { + hostStart = getInputValue('#intf-switchless-start'); + hostEnd = getInputValue('#intf-switchless-end'); + } + + if (!hostStart || !hostEnd) { + showValidationError('⚠️ Host port range is required. Specify both start and end ports.'); + return false; + } + + return true; +} + +function validateRedundancyStep(): boolean { + const role = state.config.switch.role; + + // BMC switches skip MLAG validation + if (role === 'BMC') { + return true; + } + + const keepaliveSrc = getInputValue('#mlag-keepalive-src'); + const keepaliveDst = getInputValue('#mlag-keepalive-dst'); + + if (!keepaliveSrc || !keepaliveDst) { + showValidationError('⚠️ MLAG keepalive IPs are required for TOR switches. Specify both source and destination IPs.'); + return false; + } + + return true; +} + +function validateUplinksStep(): boolean { + const loopbackIp = getInputValue('#intf-loopback-ip'); + + if (!loopbackIp) { + showValidationError('⚠️ Loopback IP is required for BGP router-id.'); + return false; + } + + if (!loopbackIp.includes('/32')) { + showValidationError('⚠️ Loopback IP must be /32 (e.g., 203.0.113.1/32)'); + return false; + } + + return true; +} + +function validateRoutingStep(): boolean { + if (state.config.routing_type === 'bgp') { + const asn = getInputValue('#bgp-asn'); + if (!asn) { + showValidationError('⚠️ BGP ASN is required for BGP routing.'); + return false; + } + } + + return true; +} + +function showValidationError(message: string): void { + const errorDiv = getElement('#validation-error'); + if (errorDiv) { + errorDiv.textContent = message; + errorDiv.style.display = 'block'; + + // Scroll to error message + errorDiv.scrollIntoView({ behavior: 'smooth', block: 'center' }); + + setTimeout(() => { + errorDiv.style.display = 'none'; + }, 5000); + } +} + +function clearValidationErrors(): void { + const errorDiv = getElement('#validation-error'); + if (errorDiv) { + errorDiv.style.display = 'none'; + } +} + +function showSuccessMessage(message: string): void { + const successDiv = getElement('#success-message'); + if (successDiv) { + successDiv.textContent = message; + successDiv.style.display = 'block'; + setTimeout(() => { + successDiv.style.display = 'none'; + }, 3000); + } +} + +// ============================================================================ +// REVIEW & EXPORT +// ============================================================================ + +function populateReviewStep(): void { + const summary = getElement('#config-summary'); + if (summary) { + const vendorDisplay = DISPLAY_NAMES.vendors[state.config.switch.vendor]; + const modelDisplay = DISPLAY_NAMES.models[state.config.switch.model as keyof typeof DISPLAY_NAMES.models] || state.config.switch.model; + const patternDisplay = DISPLAY_NAMES.patterns[state.config.switch.deployment_pattern as keyof typeof DISPLAY_NAMES.patterns]; + const routingSummary = state.config.routing_type === 'bgp' + ? `BGP ASN ${state.config.bgp?.asn || 'N/A'}` + : `Static Routes (${state.config.static_routes?.length || 0})`; + + summary.innerHTML = ` +
+
+

Switch

+
+ Hostname + ${state.config.switch.hostname || 'Not set'} +
+
+ Vendor / Model + ${vendorDisplay} ${modelDisplay} +
+
+ Role + ${state.config.switch.role || 'Not set'} +
+
+ Firmware + ${state.config.switch.firmware || 'Not set'} +
+
+ +
+

Pattern

+
+ Deployment + ${patternDisplay || 'Not set'} +
+
+ VLANs + ${state.config.vlans?.length || 0} configured +
+
+ Interfaces + ${state.config.interfaces?.length || 0} configured +
+
+ +
+

Redundancy

+
+ Port Channels + ${state.config.port_channels?.length || 0} configured +
+
+ MLAG + ${state.config.mlag ? 'Enabled' : 'Disabled'} +
+
+ +
+

Routing

+
+ Type + ${state.config.routing_type === 'bgp' ? 'BGP' : 'Static'} +
+
+ Details + ${routingSummary} +
+
+
+ `; + } + + const jsonPreview = getElement('#json-preview'); + if (jsonPreview) { + const exportConfig = buildExportConfig(); + jsonPreview.textContent = formatJSON(exportConfig); + } +} + +/** + * Update just the JSON preview (called by updateConfigSummary for real-time updates) + */ +function updateJsonPreview(): void { + const jsonPreview = getElement('#json-preview'); + if (jsonPreview) { + const exportConfig = buildExportConfig(); + jsonPreview.textContent = formatJSON(exportConfig); + } +} + +function buildExportConfig(): Partial { + const config: any = { + switch: state.config.switch + }; + + if (state.config.vlans && state.config.vlans.length > 0) { + config.vlans = state.config.vlans; + } + + if (state.config.interfaces && state.config.interfaces.length > 0) { + config.interfaces = state.config.interfaces; + } + + if (state.config.port_channels && state.config.port_channels.length > 0) { + config.port_channels = state.config.port_channels; + } + + if (state.config.mlag) { + config.mlag = state.config.mlag; + } + + if (state.config.routing_type === 'bgp' && state.config.bgp) { + if (state.config.prefix_lists && Object.keys(state.config.prefix_lists).length > 0) { + config.prefix_lists = state.config.prefix_lists; + } + config.bgp = state.config.bgp; + } else if (state.config.static_routes && state.config.static_routes.length > 0) { + config.static_routes = state.config.static_routes; + } + + return config; +} + +function exportJSONFile(): void { + const config = buildExportConfig(); + const filename = `${state.config.switch.hostname || 'switch'}-config.json`; + downloadJSON(config, filename); + showSuccessMessage('Configuration exported successfully!'); +} + +async function copyJSON(): Promise { + const config = buildExportConfig(); + const success = await copyToClipboard(formatJSON(config)); + if (success) { + showSuccessMessage('Configuration copied to clipboard!'); + } else { + showValidationError('Failed to copy to clipboard'); + } +} + +export function startOver(): void { + if (confirm('Are you sure you want to reset all configuration?')) { + location.reload(); + } +} + +// ============================================================================ +// IMPORT +// ============================================================================ + +function handleFileImport(event: Event): void { + const target = event.target as HTMLInputElement; + const file = target.files?.[0]; + if (!file) return; + + const reader = new FileReader(); + reader.onload = (e) => { + try { + const result = e.target?.result; + if (typeof result === 'string') { + const imported = JSON.parse(result); + loadConfig(imported); + showSuccessMessage('Configuration imported successfully!'); + } + } catch (err) { + showValidationError('Failed to parse JSON file: ' + (err as Error).message); + } + }; + reader.readAsText(file); +} + +function loadConfig(config: Partial): void { + console.log('Loading config:', config); + + // Phase 1: Switch config + if (config.switch) { + state.config.switch = { ...state.config.switch, ...config.switch }; + + // Load pattern first (new pattern-first approach) + if (config.switch.deployment_pattern) { + console.log('Loading pattern:', config.switch.deployment_pattern); + selectPattern(config.switch.deployment_pattern); + } + + // Load vendor + if (config.switch.vendor) { + console.log('Loading vendor:', config.switch.vendor); + const vendorSelect = getElement('#vendor-select'); + if (vendorSelect) { + vendorSelect.value = config.switch.vendor; + onVendorChange(); // This will populate model dropdown + } + } + + // Load model + if (config.switch.model) { + console.log('Loading model:', config.switch.model); + const modelValue = config.switch.model; + setTimeout(() => { + const modelSelect = getElement('#model-select'); + if (modelSelect) { + modelSelect.value = modelValue; + onModelChange(); // This will show role section + } + }, 100); + } + + // Load role + if (config.switch.role) { + console.log('Loading role:', config.switch.role); + const roleValue = config.switch.role; + setTimeout(() => { + selectRole(roleValue); + }, 200); + } + + // Load hostname + if (config.switch.hostname) { + console.log('Loading hostname:', config.switch.hostname); + const hostnameValue = config.switch.hostname; + setTimeout(() => { + setInputValue('#hostname', hostnameValue); + state.config.switch.hostname = hostnameValue; + }, 300); + } + } + + // Step 2: VLANs + if (Array.isArray(config.vlans)) { + state.config.vlans = config.vlans; // Update state + populateVlansFromConfig(config.vlans); + + // Populate storage VLANs + const storage1 = config.vlans.find(v => v.purpose === 'storage_1'); + if (storage1) { + setInputValue('#vlan-storage1-id', String(storage1.vlan_id)); + setInputValue('#vlan-storage1-name', storage1.name || `Storage1_${storage1.vlan_id}`); + } + + const storage2 = config.vlans.find(v => v.purpose === 'storage_2'); + if (storage2) { + setInputValue('#vlan-storage2-id', String(storage2.vlan_id)); + setInputValue('#vlan-storage2-name', storage2.name || `Storage2_${storage2.vlan_id}`); + } + } + + updateHostPortsSections(); + + // Step 3: Host ports - adapt to deployment pattern + if (Array.isArray(config.interfaces)) { + state.config.interfaces = config.interfaces; // Update state + const deploymentPattern = config.switch?.deployment_pattern || 'fully_converged'; + + if (deploymentPattern === 'fully_converged') { + const hostInterface = config.interfaces.find(i => i.type === 'Trunk' && i.start_intf); + if (hostInterface) { + setInputValue('#intf-converged-start', hostInterface.start_intf || ''); + setInputValue('#intf-converged-end', hostInterface.end_intf || ''); + setInputValue('#intf-converged-native', hostInterface.native_vlan || ''); + setInputValue('#intf-converged-tagged', hostInterface.tagged_vlans || ''); + const qosCheckbox = getElement('#intf-converged-qos'); + if (qosCheckbox) qosCheckbox.checked = hostInterface.qos || false; + } + } else if (deploymentPattern === 'switched') { + const mgmtInterface = config.interfaces.find(i => i.name?.includes('Management') || i.name?.includes('Compute')); + if (mgmtInterface) { + setInputValue('#intf-mgmt-compute-start', mgmtInterface.start_intf || ''); + setInputValue('#intf-mgmt-compute-end', mgmtInterface.end_intf || ''); + setInputValue('#intf-mgmt-compute-native', mgmtInterface.native_vlan || ''); + setInputValue('#intf-mgmt-compute-tagged', mgmtInterface.tagged_vlans || ''); + } + + const storageInterface = config.interfaces.find(i => i.name?.includes('Storage')); + if (storageInterface) { + setInputValue('#intf-storage-start', storageInterface.start_intf || ''); + setInputValue('#intf-storage-end', storageInterface.end_intf || ''); + setInputValue('#intf-storage-native', storageInterface.native_vlan || ''); + setInputValue('#intf-storage-tagged', storageInterface.tagged_vlans || ''); + const storageQos = getElement('#intf-storage-qos'); + if (storageQos) storageQos.checked = storageInterface.qos || false; + } + } else if (deploymentPattern === 'switchless') { + const hostInterface = config.interfaces.find(i => i.type === 'Trunk' && i.start_intf); + if (hostInterface) { + setInputValue('#intf-switchless-start', hostInterface.start_intf || ''); + setInputValue('#intf-switchless-end', hostInterface.end_intf || ''); + setInputValue('#intf-switchless-native', hostInterface.native_vlan || ''); + setInputValue('#intf-switchless-tagged', hostInterface.tagged_vlans || ''); + } + } + + // Step 5: Uplinks and loopback (same interfaces array) + const loopback = config.interfaces.find(i => i.intf_type === 'loopback'); + if (loopback) { + setInputValue('#intf-loopback-ip', loopback.ipv4 || ''); + } + + const uplinks = config.interfaces.filter(i => i.type === 'L3' && i.intf_type === 'Ethernet'); + if (uplinks[0]) { + setInputValue('#intf-uplink1-port', uplinks[0].intf || ''); + setInputValue('#intf-uplink1-ip', uplinks[0].ipv4 || ''); + } + if (uplinks[1]) { + setInputValue('#intf-uplink2-port', uplinks[1].intf || ''); + setInputValue('#intf-uplink2-ip', uplinks[1].ipv4 || ''); + } + } + + // Step 4: Redundancy (peer-link editable fields) + if (Array.isArray(config.port_channels)) { + const peerLink = config.port_channels.find(pc => pc.vpc_peer_link); + if (peerLink) { + setInputValue('#mlag-peerlink-id', String(peerLink.id)); + if (peerLink.members && peerLink.members.length > 0) { + setInputValue('#mlag-peerlink-members', peerLink.members.join(',')); + } + } + } + + // Step 4: Redundancy (MLAG) + if (config.mlag) { + state.config.mlag = config.mlag; // Update state + if (config.mlag.peer_keepalive) { + setInputValue('#mlag-keepalive-src', config.mlag.peer_keepalive.source_ip || ''); + setInputValue('#mlag-keepalive-dst', config.mlag.peer_keepalive.destination_ip || ''); + } + setInputValue('#mlag-domain-id', String(config.mlag.domain_id || 1)); + } + + if (Array.isArray(config.port_channels)) { + state.config.port_channels = config.port_channels; // Update state + const ibgpPc = config.port_channels.find(pc => pc.type === 'L3' && !pc.vpc_peer_link); + if (ibgpPc) { + setInputValue('#pc-ibgp-id', String(ibgpPc.id || 50)); + setInputValue('#pc-ibgp-ip', ibgpPc.ipv4 || ''); + setInputValue('#pc-ibgp-members', (ibgpPc.members || []).join(',')); + } + } + + // Step 6: Routing (BGP) + if (config.bgp) { + state.config.bgp = config.bgp; // Update state + setInputValue('#bgp-asn', String(config.bgp.asn || '')); + setInputValue('#bgp-router-id', config.bgp.router_id || ''); + + // Load BGP neighbors + const neighborsContainer = getElement('#bgp-neighbors'); + if (neighborsContainer && config.bgp.neighbors) { + neighborsContainer.innerHTML = ''; + config.bgp.neighbors.forEach(neighbor => { + addBgpNeighbor(); + const entries = getElements('.neighbor-entry'); + const lastEntry = entries[entries.length - 1]; + if (lastEntry) { + const ipInput = lastEntry.querySelector('.bgp-neighbor-ip'); + const descInput = lastEntry.querySelector('.bgp-neighbor-desc'); + const asnInput = lastEntry.querySelector('.bgp-neighbor-asn'); + if (ipInput) ipInput.value = neighbor.ip || ''; + if (descInput) descInput.value = neighbor.description || ''; + if (asnInput) asnInput.value = String(neighbor.remote_as || ''); + } + }); + } + } + + // Update prefix lists if present + if (config.prefix_lists) { + state.config.prefix_lists = config.prefix_lists; + } + + // Mark all steps with populated data as completed + markCompletedSteps(); + updatePhaseCompletionFromConfig(); + + // Update config summary sidebar + updateConfigSummary(); + + // Update breadcrumb completion states after loading config + // Use setTimeout to ensure all DOM updates have settled + setTimeout(() => { + updateBreadcrumbCompletion(); + // Run twice to catch "Review" section which depends on others being complete + setTimeout(updateBreadcrumbCompletion, 100); + }, 100); + + showPhase(1); +} + +function updatePhaseCompletionFromConfig(): void { + const hasSwitch = Boolean( + state.config.switch.vendor && + state.config.switch.model && + state.config.switch.role && + state.config.switch.hostname + ); + const hasNetwork = Array.isArray(state.config.vlans) && state.config.vlans.length > 0; + const hasRouting = Boolean( + (state.config.bgp && Object.keys(state.config.bgp).length > 0) || + (state.config.static_routes && state.config.static_routes.length > 0) + ); + const hasReview = hasSwitch && hasNetwork; + + const phases = getElements('.nav-phase'); + phases.forEach(phase => { + const phaseId = phase.dataset.phase; + phase.classList.remove('completed'); + if (phaseId === '1' && hasSwitch) phase.classList.add('completed'); + if (phaseId === '2' && hasNetwork) phase.classList.add('completed'); + if (phaseId === '3' && hasRouting) phase.classList.add('completed'); + if (phaseId === 'review' && hasReview) phase.classList.add('completed'); + }); +} + +export function selectCard(selector: string, dataAttr: string, value: string): void { + const card = getElement(`${selector}[data-${dataAttr}="${value}"]`); + if (card) { + card.click(); + } +} + +function resetVlanContainers(): void { + const mgmtContainer = getElement('#mgmt-vlans-container'); + const computeContainer = getElement('#compute-vlans-container'); + + if (mgmtContainer) mgmtContainer.innerHTML = ''; + if (computeContainer) computeContainer.innerHTML = ''; + + VLAN_CONFIGS.management.counter = 0; + VLAN_CONFIGS.compute.counter = 0; +} + +function populateVlansFromConfig(vlans: VLAN[]): void { + resetVlanContainers(); + + const management = vlans.filter(v => v.purpose === 'management'); + const compute = vlans.filter(v => v.purpose === 'compute'); + const parking = vlans.find(v => v.shutdown === true || v.purpose === 'parking'); + + if (parking) { + setInputValue('#vlan-parking-id', String(parking.vlan_id)); + } + + if (management.length === 0) { + addDynamicVlan('management'); + } else { + management.forEach(vlan => addDynamicVlan('management', vlan)); + } + + if (compute.length === 0) { + addDynamicVlan('compute'); + } else { + compute.forEach(vlan => addDynamicVlan('compute', vlan)); + } +} diff --git a/frontend/src/app.ts.v1-reference b/frontend/src/app.ts.v1-reference new file mode 100644 index 0000000..b4087c3 --- /dev/null +++ b/frontend/src/app.ts.v1-reference @@ -0,0 +1,1694 @@ +/** + * Azure Local Switch Configuration Wizard - Main Application + * TypeScript conversion from app.js + */ + +import type { + StandardConfig, + VLAN, + Interface, + PortChannel, + BGPNeighbor, + Vendor, + Role, + VLANPurpose, + RedundancyType, + Firmware, + DeploymentPattern +} from './types'; +import { + DISPLAY_NAMES, + VENDOR_FIRMWARE_MAP, + VENDOR_REDUNDANCY_TYPE, + ROLE_DEFAULTS, + getElement, + getElements, + getInputValue, + setInputValue, + toggleElement, + downloadJSON, + copyToClipboard, + parseIntSafe, + formatJSON +} from './utils'; + +// ============================================================================ +// TYPES +// ============================================================================ + +interface WizardState { + currentStep: number; + totalSteps: number; + config: StandardConfig & { + routing_type?: 'bgp' | 'static'; + static_routes?: Array<{ + destination: string; + next_hop: string; + name?: string; + }>; + }; +} + +interface VLANConfig { + label: string; + purpose: VLANPurpose; + defaultVlanId: (idx: number) => number; + namePrefix: string; + switchIpPlaceholder: string | ((idx: number) => string); + gatewayPlaceholder: string | ((idx: number) => string); + cssClass: string; + counter: number; +} + +// ============================================================================ +// STATE MANAGEMENT +// ============================================================================ + +const state: WizardState = { + currentStep: 1, + totalSteps: 7, + config: { + switch: { + vendor: 'dellemc', + model: 's5248f-on', + firmware: 'os10', + hostname: '', + role: 'TOR1', + deployment_pattern: 'fully_converged' + }, + vlans: [], + interfaces: [], + port_channels: [], + mlag: undefined, + routing_type: 'bgp', + bgp: undefined, + prefix_lists: {}, + static_routes: [] + } +}; + +// MLAG constants +const MLAG_PEER_LINK_ID = 101; +const MLAG_NATIVE_VLAN = '99'; +const MLAG_PEER_LINK_MEMBERS = ['1/1/49', '1/1/50', '1/1/51', '1/1/52']; + +const VLAN_CONFIGS: Record<'management' | 'compute', VLANConfig> = { + management: { + label: 'Management', + purpose: 'management', + defaultVlanId: (idx) => 7 + idx, + namePrefix: 'Infra', + switchIpPlaceholder: (idx) => `100.69.176.${idx + 2}/24`, + gatewayPlaceholder: '100.69.176.1/24', + cssClass: 'mgmt', + counter: 1 + }, + compute: { + label: 'Compute', + purpose: 'compute', + defaultVlanId: (idx) => 201 + idx, + namePrefix: 'Compute', + switchIpPlaceholder: (idx) => `10.${idx}.0.2/24`, + gatewayPlaceholder: (idx) => `10.${idx}.0.1/24`, + cssClass: 'compute', + counter: 1 + } +}; + +// ============================================================================ +// INITIALIZATION +// ============================================================================ + +export function initializeWizard(): void { + updateNavigationUI(); + showStep(1); + initializeCardSelections(); +} + +function initializeCardSelections(): void { + initializeCardGroup('.vendor-card', 'vendor', (value) => { + state.config.switch.vendor = value as Vendor; + state.config.switch.firmware = VENDOR_FIRMWARE_MAP[value as Vendor] as Firmware; + updateModelCards(); + }, 'dellemc'); + + initializeCardGroup('.model-card', 'model', (value) => { + state.config.switch.model = value; + }); + updateModelCards(); + + initializeCardGroup('.role-card', 'role', (value) => { + state.config.switch.role = value as Role; + updateRoleBasedSections(); + }, 'TOR1'); + + initializeCardGroup('.pattern-card', 'pattern', (value) => { + state.config.switch.deployment_pattern = value as DeploymentPattern; + }, 'fully_converged'); + + initializeCardGroup('.routing-card', 'routing', (value) => { + state.config.routing_type = value as 'bgp' | 'static'; + updateRoutingSection(); + }, 'bgp'); +} + +function initializeCardGroup( + selector: string, + dataAttr: string, + onChange: ((value: string) => void) | null, + defaultValue: string | null = null +): void { + const cards = getElements(selector); + cards.forEach(card => { + card.addEventListener('click', () => { + cards.forEach(c => c.classList.remove('selected')); + card.classList.add('selected'); + const value = card.dataset[dataAttr]; + if (onChange && value) onChange(value); + }); + }); + + if (defaultValue) { + const defaultCard = getElement(`${selector}[data-${dataAttr}="${defaultValue}"]`); + if (defaultCard) { + defaultCard.classList.add('selected'); + if (onChange) onChange(defaultValue); + } + } +} + +export function setupEventListeners(): void { + const importInput = getElement('#import-json'); + if (importInput) { + importInput.addEventListener('change', handleFileImport); + } + + getElements('.btn-next').forEach(btn => { + btn.addEventListener('click', () => nextStep()); + }); + getElements('.btn-back').forEach(btn => { + btn.addEventListener('click', () => prevStep()); + }); + + getElements('.nav-step').forEach(step => { + step.addEventListener('click', () => { + const stepNum = parseInt(step.dataset.step || '1'); + // Allow clicking on previous steps, current step, or Review (step 7) + if (stepNum <= state.currentStep || stepNum === 7) { + showStep(stepNum); + } + }); + }); + + const addMgmtBtn = getElement('#btn-add-mgmt'); + if (addMgmtBtn) { + addMgmtBtn.addEventListener('click', () => addDynamicVlan('management')); + } + + const addComputeBtn = getElement('#btn-add-compute'); + if (addComputeBtn) { + addComputeBtn.addEventListener('click', () => addDynamicVlan('compute')); + } + + const addNeighborBtn = getElement('#btn-add-neighbor'); + if (addNeighborBtn) { + addNeighborBtn.addEventListener('click', addBgpNeighbor); + } + + const addRouteBtn = getElement('#btn-add-route'); + if (addRouteBtn) { + addRouteBtn.addEventListener('click', addStaticRoute); + } + + const exportBtn = getElement('#btn-export'); + if (exportBtn) exportBtn.addEventListener('click', exportJSONFile); + + const copyBtn = getElement('#btn-copy'); + if (copyBtn) copyBtn.addEventListener('click', copyJSON); + + const resetBtn = getElement('#btn-reset'); + if (resetBtn) resetBtn.addEventListener('click', startOver); + + const loopbackInput = getElement('#intf-loopback-ip'); + if (loopbackInput) { + loopbackInput.addEventListener('input', (e) => { + const routerIdField = getElement('#bgp-router-id'); + if (routerIdField && e.target instanceof HTMLInputElement) { + const ip = e.target.value.split('/')[0]; + routerIdField.value = ip || ''; + } + }); + } +} + +// ============================================================================ +// TEMPLATE LOADING +// ============================================================================ + +export function showTemplateModal(): void { + const modal = getElement('#template-modal'); + if (modal) { + modal.style.display = 'flex'; + } +} + +export function closeTemplateModal(): void { + const modal = getElement('#template-modal'); + if (modal) { + modal.style.display = 'none'; + } +} + +export async function loadTemplate(templateName: string): Promise { + closeTemplateModal(); // Close modal immediately + + try { + const response = await fetch(`/examples/${templateName}.json`); + if (!response.ok) { + throw new Error(`Failed to load template: ${response.statusText}`); + } + const config = await response.json() as Partial; + loadConfig(config); + showSuccessMessage(`Loaded template: ${templateName}`); + } catch (error) { + showValidationError(`Failed to load template: ${(error as Error).message}`); + } +} + +export function toggleCollapsible(header: HTMLElement): void { + const content = header.nextElementSibling as HTMLElement; + if (content && content.classList.contains('collapsible-content')) { + const isVisible = content.style.display !== 'none'; + content.style.display = isVisible ? 'none' : 'block'; + header.classList.toggle('expanded'); + } +} + +export function updateStorageVlanName(storageNum: number): void { + const vlanIdInput = getElement(`#vlan-storage${storageNum}-id`); + const nameInput = getElement(`#vlan-storage${storageNum}-name`); + + if (vlanIdInput && nameInput && vlanIdInput.value) { + nameInput.value = `Storage${storageNum}_${vlanIdInput.value}`; + nameInput.style.color = '#666'; + } +} + + +// ============================================================================ +// NAVIGATION +// ============================================================================ + +function showStep(stepNum: number): void { + getElements('.step').forEach(s => s.classList.remove('active')); + const targetStep = getElement(`#step${stepNum}`); + if (targetStep) { + targetStep.classList.add('active'); + state.currentStep = stepNum; + updateNavigationUI(); + + if (stepNum === 3) updateHostPortsSections(); + if (stepNum === 7) populateReviewStep(); + } +} + +function nextStep(): void { + if (validateCurrentStep()) { + collectStepData(); + // Mark current step as completed before moving + const steps = getElements('.nav-step'); + const currentStepElem = steps[state.currentStep - 1]; + if (currentStepElem) { + currentStepElem.classList.add('completed'); + } + if (state.currentStep < state.totalSteps) { + showStep(state.currentStep + 1); + window.scrollTo({ top: 0, behavior: 'smooth' }); + } + } +} + +function prevStep(): void { + if (state.currentStep > 1) { + showStep(state.currentStep - 1); + window.scrollTo({ top: 0, behavior: 'smooth' }); + } +} + +function updateNavigationUI(): void { + getElements('.nav-step').forEach(step => { + const stepNum = parseInt(step.dataset.step || '0'); + step.classList.remove('active'); + // Only add active to current step, but preserve completed class + if (stepNum === state.currentStep) { + step.classList.add('active'); + } else if (stepNum < state.currentStep) { + step.classList.add('completed'); + } + }); +} + +function updateHostPortsSections(): void { + const deploymentPattern = state.config.switch.deployment_pattern || 'fully_converged'; + const displayElem = getElement('#deployment-pattern-display'); + if (displayElem) { + displayElem.textContent = deploymentPattern.replace('_', ' ').toUpperCase(); + } + + // Hide all port sections first + const sections = [ + '#port-section-converged', + '#port-section-mgmt-compute', + '#port-section-storage', + '#port-section-switchless' + ]; + sections.forEach(id => { + const elem = getElement(id); + if (elem) (elem as HTMLElement).style.display = 'none'; + }); + + // Show appropriate sections and update VLAN lists + if (deploymentPattern === 'fully_converged') { + const section = getElement('#port-section-converged'); + if (section) (section as HTMLElement).style.display = ''; + updateVlanDisplay('#converged-vlans-display', ['management', 'compute', 'storage_1', 'storage_2']); + } else if (deploymentPattern === 'switched') { + const section = getElement('#port-section-mgmt-compute'); + if (section) (section as HTMLElement).style.display = ''; + updateVlanDisplay('#mgmt-compute-vlans-display', ['management', 'compute', 'storage_1', 'storage_2']); + + const storageSection = getElement('#port-section-storage'); + if (storageSection) (storageSection as HTMLElement).style.display = ''; + updateVlanDisplay('#storage-vlans-display', ['storage_1', 'storage_2']); + } else if (deploymentPattern === 'switchless') { + const section = getElement('#port-section-switchless'); + if (section) (section as HTMLElement).style.display = ''; + updateVlanDisplay('#switchless-vlans-display', ['management', 'compute']); + } +} + +function updateVlanDisplay(containerId: string, purposes: string[]): void { + // Determine which fields to populate based on the container + let nativeFieldId = ''; + let taggedFieldId = ''; + let hintId = ''; + + if (containerId === '#converged-vlans-display') { + nativeFieldId = '#intf-converged-native'; + taggedFieldId = '#intf-converged-tagged'; + hintId = '#converged-vlans-hint'; + } else if (containerId === '#mgmt-compute-vlans-display') { + nativeFieldId = '#intf-mgmt-compute-native'; + taggedFieldId = '#intf-mgmt-compute-tagged'; + hintId = '#mgmt-compute-vlans-hint'; + } else if (containerId === '#storage-vlans-display') { + nativeFieldId = '#intf-storage-native'; + taggedFieldId = '#intf-storage-tagged'; + hintId = '#storage-vlans-hint'; + } else if (containerId === '#switchless-vlans-display') { + nativeFieldId = '#intf-switchless-native'; + taggedFieldId = '#intf-switchless-tagged'; + hintId = '#switchless-vlans-hint'; + } + + // Get VLANs from state matching the specified purposes + const vlans = (state.config.vlans || []) + .filter(v => !v.shutdown && purposes.some(p => v.purpose?.includes(p))); + + const mgmtVlan = vlans.find(v => v.purpose === 'management'); + const vlanIds = vlans.map(v => v.vlan_id).join(','); + const vlanNames = vlans.map(v => `${v.vlan_id} (${v.name})`).join(', '); + + // Populate native VLAN (default to management VLAN) + const nativeField = getElement(nativeFieldId); + if (nativeField && mgmtVlan) { + nativeField.value = String(mgmtVlan.vlan_id); + nativeField.placeholder = String(mgmtVlan.vlan_id); + } + + // Populate tagged VLANs + const taggedField = getElement(taggedFieldId); + if (taggedField) { + taggedField.value = vlanIds; + taggedField.placeholder = vlanIds || 'No VLANs configured'; + } + + // Update hint with human-readable names + const hint = getElement(hintId); + if (hint) { + if (vlans.length === 0) { + hint.textContent = 'Complete Step 2 to configure VLANs'; + (hint as HTMLElement).style.color = '#999'; + } else { + hint.textContent = vlanNames; + (hint as HTMLElement).style.color = '#4CAF50'; + } + } +} + +function markCompletedSteps(): void { + // Mark steps as completed based on populated data in state + const steps = getElements('.nav-step'); + + // Step 1: Switch info (always completed if vendor/model set) + if (state.config.switch.vendor && state.config.switch.model) { + steps[0]?.classList.add('completed'); + } + + // Step 2: VLANs (completed if vlans exist) + if (state.config.vlans && state.config.vlans.length > 0) { + steps[1]?.classList.add('completed'); + } + + // Step 3: Host Ports (completed if trunk interfaces exist that aren't peer-links) + if (state.config.interfaces && state.config.interfaces.some(i => i.type === 'Trunk')) { + steps[2]?.classList.add('completed'); + } + + // Step 4: Redundancy (completed if mlag or port_channels exist) + if (state.config.mlag || (state.config.port_channels && state.config.port_channels.length > 0)) { + steps[3]?.classList.add('completed'); + } + + // Step 5: Uplinks (completed if loopback interface exists) + if (state.config.interfaces && state.config.interfaces.some(i => i.intf_type === 'loopback')) { + steps[4]?.classList.add('completed'); + } + + // Step 6: Routing (completed if bgp or static_routes exist) + if (state.config.bgp || (state.config.static_routes && state.config.static_routes.length > 0)) { + steps[5]?.classList.add('completed'); + } + + // Step 7: Review (mark as completed if we have enough data) + if (steps[0]?.classList.contains('completed') && steps[1]?.classList.contains('completed')) { + steps[6]?.classList.add('completed'); + } +} + +// ============================================================================ +// UI UPDATES +// ============================================================================ + +function updateModelCards(): void { + const vendor = state.config.switch.vendor; + getElements('.model-card').forEach(card => { + if (card.dataset.vendor === vendor) { + card.style.display = ''; + } else { + card.style.display = 'none'; + card.classList.remove('selected'); + } + }); + + const firstVisible = getElement(`.model-card[data-vendor="${vendor}"]`); + if (firstVisible && !getElement('.model-card.selected[style=""]')) { + firstVisible.classList.add('selected'); + state.config.switch.model = firstVisible.dataset.model || ''; + } +} + +function updateRoleBasedSections(): void { + const role = state.config.switch.role; + + toggleElement('#section-mlag', role !== 'BMC'); + toggleElement('#section-ibgp-pc', role !== 'BMC'); + toggleElement('#section-bmc-vlan', role === 'BMC'); +} + +function updateRoutingSection(): void { + const routingType = state.config.routing_type; + toggleElement('#section-bgp', routingType === 'bgp'); + toggleElement('#section-static-routes', routingType === 'static'); +} + +// ============================================================================ +// DATA COLLECTION +// ============================================================================ + +function collectStepData(): void { + switch (state.currentStep) { + case 1: + collectSwitchData(); + break; + case 2: + collectVlanData(); + break; + case 3: + collectHostPortsData(); + break; + case 4: + collectRedundancyData(); + break; + case 5: + collectUplinksData(); + break; + case 6: + collectRoutingData(); + break; + } +} + +function collectSwitchData(): void { + state.config.switch.hostname = getInputValue('#hostname'); + state.config.switch.firmware = VENDOR_FIRMWARE_MAP[state.config.switch.vendor] as Firmware; +} + +function collectVlanData(): void { + const vlans: VLAN[] = []; + const vendor = state.config.switch.vendor; + const role = state.config.switch.role; + const redundancyType = VENDOR_REDUNDANCY_TYPE[vendor]; + + const parkingId = parseIntSafe(getInputValue('#vlan-parking-id')); + if (parkingId) { + vlans.push({ + vlan_id: parkingId, + name: 'UNUSED_VLAN', + shutdown: true + }); + } + + collectVlansByType('management', vlans, redundancyType, role); + collectVlansByType('compute', vlans, redundancyType, role); + + const storage1Id = parseIntSafe(getInputValue('#vlan-storage1-id')); + if (storage1Id) { + const storage1Name = getInputValue('#vlan-storage1-name'); + vlans.push({ + vlan_id: storage1Id, + name: storage1Name || `Storage1_${storage1Id}`, + purpose: 'storage_1' + }); + } + + const storage2Id = parseIntSafe(getInputValue('#vlan-storage2-id')); + if (storage2Id) { + const storage2Name = getInputValue('#vlan-storage2-name'); + vlans.push({ + vlan_id: storage2Id, + name: storage2Name || `Storage2_${storage2Id}`, + purpose: 'storage_2' + }); + } + + if (role === 'BMC') { + const bmcId = parseIntSafe(getInputValue('#vlan-bmc-id')); + if (bmcId) { + const bmcName = getInputValue('#vlan-bmc-name'); + const bmcVlan: VLAN = { + vlan_id: bmcId, + name: bmcName || `BMC_${bmcId}`, + purpose: 'bmc' + }; + + const bmcIp = getInputValue('#vlan-bmc-ip'); + if (bmcIp) { + bmcVlan.interface = { + ip: bmcIp, + cidr: parseIntSafe(getInputValue('#vlan-bmc-cidr'), 26), + mtu: 9216 + }; + } + vlans.push(bmcVlan); + } + } + + state.config.vlans = vlans; +} + +function collectVlansByType( + type: 'management' | 'compute', + vlans: VLAN[], + redundancyType: RedundancyType, + role: Role +): void { + const config = VLAN_CONFIGS[type]; + if (!config) return; + + const cards = getElements(`[data-vlan-type="${type}"]`); + const cssClass = config.cssClass; + + cards.forEach((card) => { + const vlanIdInput = card.querySelector(`.vlan-${cssClass}-id`); + const vlanId = vlanIdInput ? parseInt(vlanIdInput.value) : 0; + if (!vlanId) return; + + const customNameInput = card.querySelector(`.vlan-${cssClass}-name`); + const customName = customNameInput?.value || ''; + + const vlan: VLAN = { + vlan_id: vlanId, + name: customName || `${config.namePrefix}_${vlanId}`, + purpose: config.purpose + }; + + const ipInput = card.querySelector(`.vlan-${cssClass}-ip`); + const ipValue = ipInput?.value || ''; + if (ipValue) { + const parts = ipValue.includes('/') ? ipValue.split('/') : [ipValue, '24']; + const ip = parts[0] || ''; + const cidr = parts[1] || '24'; + vlan.interface = { + ip: ip, + cidr: parseInt(cidr, 10) || 24, + mtu: 9216 + }; + + const gatewayInput = card.querySelector(`.vlan-${cssClass}-gateway`); + const gatewayValue = gatewayInput?.value || ''; + if (gatewayValue && role !== 'BMC' && vlan.interface) { + const gwIp = (gatewayValue.includes('/') ? gatewayValue.split('/')[0] : gatewayValue) || ''; + vlan.interface.redundancy = { + type: redundancyType, + virtual_ip: gwIp, + preempt: true, + group: vlanId, + priority: ROLE_DEFAULTS[role].hsrp_priority || 100 + }; + } + + const dhcpInput = card.querySelector(`.vlan-${cssClass}-dhcp`); + const dhcpRelay = dhcpInput?.value || ''; + if (dhcpRelay && vlan.interface) { + vlan.interface.dhcp_relay = dhcpRelay.split(',').map(s => s.trim()); + } + } + + vlans.push(vlan); + }); +} + +function collectHostPortsData(): void { + const interfaces: Interface[] = []; + const deploymentPattern = state.config.switch.deployment_pattern; + + const mgmtVlan = (state.config.vlans || []).find(v => v.purpose === 'management'); + const nativeVlan = mgmtVlan ? String(mgmtVlan.vlan_id) : '7'; + + const allVlans = (state.config.vlans || []) + .filter(v => !v.shutdown) + .map(v => v.vlan_id) + .join(','); + + const mgmtComputeVlans = (state.config.vlans || []) + .filter(v => !v.shutdown && (v.purpose === 'management' || v.purpose === 'compute')) + .map(v => v.vlan_id) + .join(','); + + const storageVlans = (state.config.vlans || []) + .filter(v => v.purpose === 'storage_1' || v.purpose === 'storage_2') + .map(v => v.vlan_id) + .join(','); + + if (deploymentPattern === 'fully_converged') { + // Fully converged: All VLANs on same ports + const start = getInputValue('#intf-converged-start'); + const end = getInputValue('#intf-converged-end'); + const qos = getElement('#intf-converged-qos'); + const nativeVlanInput = getInputValue('#intf-converged-native'); + const taggedVlansInput = getInputValue('#intf-converged-tagged'); + + if (start && end) { + interfaces.push({ + name: 'HyperConverged_To_Hosts', + type: 'Trunk', + intf_type: 'Ethernet', + start_intf: start, + end_intf: end, + native_vlan: nativeVlanInput || nativeVlan, + tagged_vlans: taggedVlansInput || allVlans, + qos: qos?.checked || false + } as Interface); + } + } else if (deploymentPattern === 'switched') { + // Storage switched: Separate mgmt/compute and storage ports + const mgmtStart = getInputValue('#intf-mgmt-compute-start'); + const mgmtEnd = getInputValue('#intf-mgmt-compute-end'); + const mgmtNativeInput = getInputValue('#intf-mgmt-compute-native'); + const mgmtTaggedInput = getInputValue('#intf-mgmt-compute-tagged'); + + if (mgmtStart && mgmtEnd) { + interfaces.push({ + name: 'Management_Compute_To_Hosts', + type: 'Trunk', + intf_type: 'Ethernet', + start_intf: mgmtStart, + end_intf: mgmtEnd, + native_vlan: mgmtNativeInput || nativeVlan, + tagged_vlans: mgmtTaggedInput || mgmtComputeVlans + } as Interface); + } + + const storageStart = getInputValue('#intf-storage-start'); + const storageEnd = getInputValue('#intf-storage-end'); + const storageNativeInput = getInputValue('#intf-storage-native'); + const storageTaggedInput = getInputValue('#intf-storage-tagged'); + + if (storageStart && storageEnd) { + const storageQos = getElement('#intf-storage-qos'); + interfaces.push({ + name: 'Storage_Ports', + type: 'Trunk', + intf_type: 'Ethernet', + start_intf: storageStart, + end_intf: storageEnd, + native_vlan: storageNativeInput || nativeVlan, + tagged_vlans: storageTaggedInput || storageVlans, + qos: storageQos?.checked || false + } as Interface); + } + } else if (deploymentPattern === 'switchless') { + // Switchless: Only mgmt/compute ports (no storage network) + const start = getInputValue('#intf-switchless-start'); + const end = getInputValue('#intf-switchless-end'); + const nativeVlanInput = getInputValue('#intf-switchless-native'); + const taggedVlansInput = getInputValue('#intf-switchless-tagged'); + + if (start && end) { + interfaces.push({ + name: 'Management_Compute_To_Hosts', + type: 'Trunk', + intf_type: 'Ethernet', + start_intf: start, + end_intf: end, + native_vlan: nativeVlanInput || nativeVlan, + tagged_vlans: taggedVlansInput || mgmtComputeVlans + } as Interface); + } + } + + state.config.interfaces = interfaces; +} + +function collectRedundancyData(): void { + const role = state.config.switch.role; + const portChannels: PortChannel[] = []; + + const taggedVlans = (state.config.vlans || []) + .filter(v => !v.shutdown) + .map(v => v.vlan_id) + .join(','); + + if (role !== 'BMC') { + const ibgpPcId = parseIntSafe(getInputValue('#pc-ibgp-id')); + const ibgpPcIp = getInputValue('#pc-ibgp-ip'); + const ibgpMembers = getInputValue('#pc-ibgp-members'); + if (ibgpPcId && ibgpPcIp) { + portChannels.push({ + id: ibgpPcId, + description: 'iBGP_Peer_Link_To_TOR2', + type: 'L3', + ipv4: ibgpPcIp, + members: ibgpMembers ? ibgpMembers.split(',').map(s => s.trim()) : [] + }); + } + + // MLAG Peer-Link Port-Channel (auto-configured) + portChannels.push({ + id: MLAG_PEER_LINK_ID, + description: 'MLAG_Peer_Link_To_TOR2', + type: 'Trunk', + native_vlan: MLAG_NATIVE_VLAN, + tagged_vlans: taggedVlans, + vpc_peer_link: true, + members: MLAG_PEER_LINK_MEMBERS + }); + + const keepaliveSrc = getInputValue('#mlag-keepalive-src'); + const keepaliveDst = getInputValue('#mlag-keepalive-dst'); + if (keepaliveSrc && keepaliveDst) { + state.config.mlag = { + domain_id: parseIntSafe(getInputValue('#mlag-domain-id'), 1), + peer_keepalive: { + source_ip: keepaliveSrc, + destination_ip: keepaliveDst + } + }; + } + } else { + state.config.mlag = undefined; + } + + state.config.port_channels = portChannels; +} + +function collectUplinksData(): void { + const interfaces = state.config.interfaces || []; + + const loopbackIp = getInputValue('#intf-loopback-ip'); + if (loopbackIp) { + interfaces.push({ + name: 'Loopback0', + type: 'L3', + intf_type: 'loopback', + intf: 'loopback0', + ipv4: loopbackIp + }); + } + + const uplink1Port = getInputValue('#intf-uplink1-port'); + const uplink1Ip = getInputValue('#intf-uplink1-ip'); + if (uplink1Port && uplink1Ip) { + interfaces.push({ + name: 'P2P_Border1', + type: 'L3', + intf_type: 'Ethernet', + intf: uplink1Port, + ipv4: uplink1Ip + }); + } + + const uplink2Port = getInputValue('#intf-uplink2-port'); + const uplink2Ip = getInputValue('#intf-uplink2-ip'); + if (uplink2Port && uplink2Ip) { + interfaces.push({ + name: 'P2P_Border2', + type: 'L3', + intf_type: 'Ethernet', + intf: uplink2Port, + ipv4: uplink2Ip + }); + } + + state.config.interfaces = interfaces; +} + +// Legacy function removed - now split into separate functions + +function collectRoutingData(): void { + if (state.config.routing_type === 'bgp') { + collectBgpData(); + state.config.static_routes = []; + } else { + collectStaticRoutesData(); + state.config.bgp = undefined; + state.config.prefix_lists = {}; + } +} + +function collectBgpData(): void { + const asn = parseIntSafe(getInputValue('#bgp-asn')); + const loopbackIp = getInputValue('#intf-loopback-ip'); + const routerId = loopbackIp ? loopbackIp.split('/')[0] || '' : ''; + + const networks: string[] = []; + if (loopbackIp) networks.push(loopbackIp); + + const uplink1Ip = getInputValue('#intf-uplink1-ip'); + if (uplink1Ip) networks.push(uplink1Ip); + + const neighbors: BGPNeighbor[] = []; + getElements('.neighbor-entry').forEach(entry => { + const ipInput = entry.querySelector('.bgp-neighbor-ip'); + const descInput = entry.querySelector('.bgp-neighbor-desc'); + const asnInput = entry.querySelector('.bgp-neighbor-asn'); + + const ip = ipInput?.value || ''; + const desc = descInput?.value || ''; + const remoteAsn = asnInput ? parseInt(asnInput.value) : 0; + + if (ip && remoteAsn) { + neighbors.push({ + ip: ip, + description: desc || `TO_${ip}`, + remote_as: remoteAsn, + af_ipv4_unicast: { + prefix_list_in: 'DefaultRoute' + } + }); + } + }); + + state.config.bgp = { + asn: asn, + router_id: routerId, + networks: networks, + neighbors: neighbors + }; + + state.config.prefix_lists = { + DefaultRoute: [ + { seq: 10, action: 'permit', prefix: '0.0.0.0/0' }, + { seq: 50, action: 'deny', prefix: '0.0.0.0/0', prefix_filter: 'le 32' } + ] + }; +} + +function collectStaticRoutesData(): void { + const routes: Array<{ destination: string; next_hop: string; name?: string }> = []; + + const defaultEnabled = getElement('#static-default-enabled'); + if (defaultEnabled?.checked) { + const nexthop = getInputValue('#static-default-nexthop'); + if (nexthop) { + routes.push({ + destination: '0.0.0.0/0', + next_hop: nexthop, + name: 'Default_Route' + }); + } + } + + getElements('.static-route-entry').forEach(entry => { + const destInput = entry.querySelector('.route-dest'); + const nexthopInput = entry.querySelector('.route-nexthop'); + const nameInput = entry.querySelector('.route-name'); + + const dest = destInput?.value; + const nexthop = nexthopInput?.value; + const name = nameInput?.value; + + if (dest && nexthop) { + routes.push({ + destination: dest, + next_hop: nexthop, + name: name || `Route_to_${dest}` + }); + } + }); + + state.config.static_routes = routes; +} + +// ============================================================================ +// DYNAMIC UI ELEMENTS +// ============================================================================ + +export function addDynamicVlan(type: 'management' | 'compute', data: VLAN | null = null): void { + const config = VLAN_CONFIGS[type]; + if (!config) return; + + const containerId = type === 'management' ? 'mgmt-vlans-container' : 'compute-vlans-container'; + const container = getElement(`#${containerId}`); + if (!container) return; + + const index = config.counter++; + const vlanId = config.defaultVlanId(index); + + const card = document.createElement('div'); + card.className = 'vlan-card dynamic-vlan'; + card.dataset.vlanType = type; + card.dataset.vlanIndex = String(index); + + card.innerHTML = createVlanCardHTML(config, index, vlanId); + container.appendChild(card); + + if (data) { + populateVlanCard(card, config, data); + } +} + +function createVlanCardHTML(config: VLANConfig, index: number, vlanId: number): string { + const switchIp = typeof config.switchIpPlaceholder === 'function' + ? config.switchIpPlaceholder(index) + : config.switchIpPlaceholder; + + const gateway = typeof config.gatewayPlaceholder === 'function' + ? config.gatewayPlaceholder(index) + : config.gatewayPlaceholder; + + return ` +
+

${config.label} VLAN #${index + 1}

+ +
+
+
+ + +
+
+ + + Optional - defaults to ${config.namePrefix}_{vlan_id} +
+
+
+
+ + +
+
+ + +
+
+
+ + +
+ `; +} + +function populateVlanCard(card: HTMLElement, config: VLANConfig, data: VLAN): void { + const cssClass = config.cssClass; + + const idInput = card.querySelector(`.vlan-${cssClass}-id`); + const nameInput = card.querySelector(`.vlan-${cssClass}-name`); + const ipInput = card.querySelector(`.vlan-${cssClass}-ip`); + const gatewayInput = card.querySelector(`.vlan-${cssClass}-gateway`); + const dhcpInput = card.querySelector(`.vlan-${cssClass}-dhcp`); + + if (idInput && data.vlan_id) { + idInput.value = String(data.vlan_id); + idInput.placeholder = String(data.vlan_id); + } + + if (nameInput) { + const defaultName = `${config.namePrefix}_${data.vlan_id || ''}`; + const customName = data.name || defaultName; + nameInput.value = customName; + nameInput.placeholder = defaultName; + nameInput.style.color = data.name ? '#333' : '#666'; + } + + if (ipInput && data.interface?.ip) { + const cidr = data.interface?.cidr || 24; + ipInput.value = `${data.interface.ip}/${cidr}`; + } + + if (gatewayInput && data.interface?.redundancy?.virtual_ip) { + const cidr = data.interface?.cidr || 24; + gatewayInput.value = `${data.interface.redundancy.virtual_ip}/${cidr}`; + } + + if (dhcpInput && data.interface?.dhcp_relay) { + dhcpInput.value = data.interface.dhcp_relay.join(','); + } +} + +export function setupVlanCardDelegation(): void { + document.addEventListener('click', (e) => { + const target = e.target as HTMLElement; + if (target.dataset.removeVlan === 'true') { + removeDynamicVlan(target); + } + }); + + document.addEventListener('change', (e) => { + const target = e.target as HTMLInputElement; + if (target.classList.contains('vlan-mgmt-id') || target.classList.contains('vlan-compute-id')) { + updateVlanName(target); + } + }); +} + +function removeDynamicVlan(btn: HTMLElement): void { + const card = btn.closest('.vlan-card'); + if (card && confirm('Remove this VLAN?')) { + card.remove(); + } +} + +export function updateVlanName(idInput: HTMLInputElement, type?: string, prefix?: string): void { + const vlanId = idInput.value; + if (!vlanId) return; + + const cssClass = type || idInput.dataset.cssClass; + const namePrefix = prefix || idInput.dataset.namePrefix; + if (!cssClass || !namePrefix) return; + + const card = idInput.closest('.vlan-card'); + const nameInput = card?.querySelector(`.vlan-${cssClass}-name`); + + if (nameInput) { + const newName = `${namePrefix}_${vlanId}`; + nameInput.placeholder = newName; + + const currentValue = nameInput.value.trim(); + const isAutoGenerated = !currentValue || /^(Infra|Compute)_\d+$/.test(currentValue); + + if (isAutoGenerated) { + nameInput.value = newName; + nameInput.style.color = '#666'; + } + } +} + +function addBgpNeighbor(): void { + const container = getElement('#bgp-neighbors-container'); + if (!container) return; + + const entry = document.createElement('div'); + entry.className = 'neighbor-entry'; + entry.innerHTML = ` +
+
+ + +
+
+ + +
+
+ + +
+ +
+ `; + container.appendChild(entry); +} + +function addStaticRoute(): void { + const container = getElement('#static-routes-container'); + if (!container) return; + + const entry = document.createElement('div'); + entry.className = 'static-route-entry'; + entry.innerHTML = ` +
+
+ + +
+
+ + +
+
+ + +
+ +
+ `; + container.appendChild(entry); +} + +export function setupRouteDelegation(): void { + document.addEventListener('click', (e) => { + const target = e.target as HTMLElement; + if (target.dataset.removeEntry) { + const entry = target.closest('.neighbor-entry, .static-route-entry'); + if (entry) { + entry.remove(); + } + } + }); +} + +// ============================================================================ +// VALIDATION +// ============================================================================ + +function validateCurrentStep(): boolean { + clearValidationErrors(); + + switch (state.currentStep) { + case 1: + return validateSwitchStep(); + case 2: + return validateVlanStep(); + case 3: + return validateHostPortsStep(); + case 4: + return validateRedundancyStep(); + case 5: + return validateUplinksStep(); + case 6: + return validateRoutingStep(); + default: + return true; + } +} + +function validateSwitchStep(): boolean { + const hostname = getInputValue('#hostname'); + if (!hostname) { + showValidationError('⚠️ Hostname is required'); + return false; + } + + if (!/^[a-zA-Z0-9][a-zA-Z0-9._-]*$/.test(hostname)) { + showValidationError('⚠️ Invalid hostname format. Use letters, numbers, dots, hyphens, and underscores only.'); + return false; + } + + return true; +} + +function validateVlanStep(): boolean { + if ((state.config.vlans || []).length === 0) { + showValidationError('⚠️ At least one VLAN is required. Please add a Management or Compute VLAN.'); + return false; + } + + const hasManagement = state.config.vlans?.some(v => v.purpose === 'management'); + if (!hasManagement) { + showValidationError('⚠️ At least one Management VLAN is required for switch management.'); + return false; + } + + return true; +} + +function validateHostPortsStep(): boolean { + const deploymentPattern = state.config.switch.deployment_pattern || 'fully_converged'; + let hostStart = ''; + let hostEnd = ''; + + // Check appropriate fields based on deployment pattern + if (deploymentPattern === 'fully_converged') { + hostStart = getInputValue('#intf-converged-start'); + hostEnd = getInputValue('#intf-converged-end'); + } else if (deploymentPattern === 'switched') { + hostStart = getInputValue('#intf-mgmt-compute-start'); + hostEnd = getInputValue('#intf-mgmt-compute-end'); + } else if (deploymentPattern === 'switchless') { + hostStart = getInputValue('#intf-switchless-start'); + hostEnd = getInputValue('#intf-switchless-end'); + } + + if (!hostStart || !hostEnd) { + showValidationError('⚠️ Host port range is required. Specify both start and end ports.'); + return false; + } + + return true; +} + +function validateRedundancyStep(): boolean { + const role = state.config.switch.role; + + // BMC switches skip MLAG validation + if (role === 'BMC') { + return true; + } + + const keepaliveSrc = getInputValue('#mlag-keepalive-src'); + const keepaliveDst = getInputValue('#mlag-keepalive-dst'); + + if (!keepaliveSrc || !keepaliveDst) { + showValidationError('⚠️ MLAG keepalive IPs are required for TOR switches. Specify both source and destination IPs.'); + return false; + } + + return true; +} + +function validateUplinksStep(): boolean { + const loopbackIp = getInputValue('#intf-loopback-ip'); + + if (!loopbackIp) { + showValidationError('⚠️ Loopback IP is required for BGP router-id.'); + return false; + } + + if (!loopbackIp.includes('/32')) { + showValidationError('⚠️ Loopback IP must be /32 (e.g., 203.0.113.1/32)'); + return false; + } + + return true; +} + +function validateRoutingStep(): boolean { + if (state.config.routing_type === 'bgp') { + const asn = getInputValue('#bgp-asn'); + if (!asn) { + showValidationError('⚠️ BGP ASN is required for BGP routing.'); + return false; + } + } + + return true; +} + +function showValidationError(message: string): void { + const errorDiv = getElement('#validation-error'); + if (errorDiv) { + errorDiv.textContent = message; + errorDiv.style.display = 'block'; + + // Scroll to error message + errorDiv.scrollIntoView({ behavior: 'smooth', block: 'center' }); + + setTimeout(() => { + errorDiv.style.display = 'none'; + }, 5000); + } +} + +function clearValidationErrors(): void { + const errorDiv = getElement('#validation-error'); + if (errorDiv) { + errorDiv.style.display = 'none'; + } +} + +function showSuccessMessage(message: string): void { + const successDiv = getElement('#success-message'); + if (successDiv) { + successDiv.textContent = message; + successDiv.style.display = 'block'; + setTimeout(() => { + successDiv.style.display = 'none'; + }, 3000); + } +} + +// ============================================================================ +// REVIEW & EXPORT +// ============================================================================ + +function populateReviewStep(): void { + const summary = getElement('#config-summary'); + if (summary) { + const vendorDisplay = DISPLAY_NAMES.vendors[state.config.switch.vendor]; + const modelDisplay = DISPLAY_NAMES.models[state.config.switch.model as keyof typeof DISPLAY_NAMES.models] || state.config.switch.model; + const patternDisplay = DISPLAY_NAMES.patterns[state.config.switch.deployment_pattern as keyof typeof DISPLAY_NAMES.patterns]; + + summary.innerHTML = ` +
+ Hostname + ${state.config.switch.hostname || 'Not set'} +
+
+ Vendor / Model + ${vendorDisplay} ${modelDisplay} +
+
+ Role + ${state.config.switch.role} +
+
+ Firmware + ${state.config.switch.firmware} +
+
+ Deployment Pattern + ${patternDisplay} +
+
+ VLANs + ${state.config.vlans?.length || 0} configured +
+
+ Interfaces + ${state.config.interfaces?.length || 0} configured +
+
+ Port Channels + ${state.config.port_channels?.length || 0} configured +
+
+ MLAG + ${state.config.mlag ? 'Enabled' : 'Disabled'} +
+
+ Routing + ${state.config.routing_type === 'bgp' ? `BGP ASN ${state.config.bgp?.asn || 'N/A'}` : 'Static Routes'} +
+ `; + } + + const jsonPreview = getElement('#json-preview'); + if (jsonPreview) { + const exportConfig = buildExportConfig(); + jsonPreview.textContent = formatJSON(exportConfig); + } +} + +function buildExportConfig(): Partial { + const config: any = { + switch: state.config.switch + }; + + if (state.config.vlans && state.config.vlans.length > 0) { + config.vlans = state.config.vlans; + } + + if (state.config.interfaces && state.config.interfaces.length > 0) { + config.interfaces = state.config.interfaces; + } + + if (state.config.port_channels && state.config.port_channels.length > 0) { + config.port_channels = state.config.port_channels; + } + + if (state.config.mlag) { + config.mlag = state.config.mlag; + } + + if (state.config.routing_type === 'bgp' && state.config.bgp) { + if (state.config.prefix_lists && Object.keys(state.config.prefix_lists).length > 0) { + config.prefix_lists = state.config.prefix_lists; + } + config.bgp = state.config.bgp; + } else if (state.config.static_routes && state.config.static_routes.length > 0) { + config.static_routes = state.config.static_routes; + } + + return config; +} + +function exportJSONFile(): void { + const config = buildExportConfig(); + const filename = `${state.config.switch.hostname || 'switch'}-config.json`; + downloadJSON(config, filename); + showSuccessMessage('Configuration exported successfully!'); +} + +async function copyJSON(): Promise { + const config = buildExportConfig(); + const success = await copyToClipboard(formatJSON(config)); + if (success) { + showSuccessMessage('Configuration copied to clipboard!'); + } else { + showValidationError('Failed to copy to clipboard'); + } +} + +function startOver(): void { + if (confirm('Are you sure you want to reset all configuration?')) { + location.reload(); + } +} + +// ============================================================================ +// IMPORT +// ============================================================================ + +function handleFileImport(event: Event): void { + const target = event.target as HTMLInputElement; + const file = target.files?.[0]; + if (!file) return; + + const reader = new FileReader(); + reader.onload = (e) => { + try { + const result = e.target?.result; + if (typeof result === 'string') { + const imported = JSON.parse(result); + loadConfig(imported); + showSuccessMessage('Configuration imported successfully!'); + } + } catch (err) { + showValidationError('Failed to parse JSON file: ' + (err as Error).message); + } + }; + reader.readAsText(file); +} + +function loadConfig(config: Partial): void { + // Step 1: Switch config + if (config.switch) { + state.config.switch = { ...state.config.switch, ...config.switch }; + setInputValue('#hostname', config.switch.hostname || ''); + + if (config.switch.vendor) { + selectCard('.vendor-card', 'vendor', config.switch.vendor); + updateModelCards(); + } + if (config.switch.model) { + selectCard('.model-card', 'model', config.switch.model); + } + if (config.switch.role) { + selectCard('.role-card', 'role', config.switch.role); + updateRoleBasedSections(); + } + if (config.switch.deployment_pattern) { + selectCard('.pattern-card', 'pattern', config.switch.deployment_pattern); + } + } + + // Step 2: VLANs + if (Array.isArray(config.vlans)) { + state.config.vlans = config.vlans; // Update state + populateVlansFromConfig(config.vlans); + + // Populate storage VLANs + const storage1 = config.vlans.find(v => v.purpose === 'storage_1'); + if (storage1) { + setInputValue('#vlan-storage1-id', String(storage1.vlan_id)); + setInputValue('#vlan-storage1-name', storage1.name || `Storage1_${storage1.vlan_id}`); + } + + const storage2 = config.vlans.find(v => v.purpose === 'storage_2'); + if (storage2) { + setInputValue('#vlan-storage2-id', String(storage2.vlan_id)); + setInputValue('#vlan-storage2-name', storage2.name || `Storage2_${storage2.vlan_id}`); + } + } + + // Step 3: Host ports - adapt to deployment pattern + if (Array.isArray(config.interfaces)) { + state.config.interfaces = config.interfaces; // Update state + const deploymentPattern = config.switch?.deployment_pattern || 'fully_converged'; + + if (deploymentPattern === 'fully_converged') { + const hostInterface = config.interfaces.find(i => i.type === 'Trunk' && i.start_intf); + if (hostInterface) { + setInputValue('#intf-converged-start', hostInterface.start_intf || ''); + setInputValue('#intf-converged-end', hostInterface.end_intf || ''); + setInputValue('#intf-converged-native', hostInterface.native_vlan || ''); + setInputValue('#intf-converged-tagged', hostInterface.tagged_vlans || ''); + const qosCheckbox = getElement('#intf-converged-qos'); + if (qosCheckbox) qosCheckbox.checked = hostInterface.qos || false; + } + } else if (deploymentPattern === 'switched') { + const mgmtInterface = config.interfaces.find(i => i.name?.includes('Management') || i.name?.includes('Compute')); + if (mgmtInterface) { + setInputValue('#intf-mgmt-compute-start', mgmtInterface.start_intf || ''); + setInputValue('#intf-mgmt-compute-end', mgmtInterface.end_intf || ''); + setInputValue('#intf-mgmt-compute-native', mgmtInterface.native_vlan || ''); + setInputValue('#intf-mgmt-compute-tagged', mgmtInterface.tagged_vlans || ''); + } + + const storageInterface = config.interfaces.find(i => i.name?.includes('Storage')); + if (storageInterface) { + setInputValue('#intf-storage-start', storageInterface.start_intf || ''); + setInputValue('#intf-storage-end', storageInterface.end_intf || ''); + setInputValue('#intf-storage-native', storageInterface.native_vlan || ''); + setInputValue('#intf-storage-tagged', storageInterface.tagged_vlans || ''); + const storageQos = getElement('#intf-storage-qos'); + if (storageQos) storageQos.checked = storageInterface.qos || false; + } + } else if (deploymentPattern === 'switchless') { + const hostInterface = config.interfaces.find(i => i.type === 'Trunk' && i.start_intf); + if (hostInterface) { + setInputValue('#intf-switchless-start', hostInterface.start_intf || ''); + setInputValue('#intf-switchless-end', hostInterface.end_intf || ''); + setInputValue('#intf-switchless-native', hostInterface.native_vlan || ''); + setInputValue('#intf-switchless-tagged', hostInterface.tagged_vlans || ''); + } + } + + // Step 5: Uplinks and loopback (same interfaces array) + const loopback = config.interfaces.find(i => i.intf_type === 'loopback'); + if (loopback) { + setInputValue('#intf-loopback-ip', loopback.ipv4 || ''); + } + + const uplinks = config.interfaces.filter(i => i.type === 'L3' && i.intf_type === 'Ethernet'); + if (uplinks[0]) { + setInputValue('#intf-uplink1-port', uplinks[0].intf || ''); + setInputValue('#intf-uplink1-ip', uplinks[0].ipv4 || ''); + } + if (uplinks[1]) { + setInputValue('#intf-uplink2-port', uplinks[1].intf || ''); + setInputValue('#intf-uplink2-ip', uplinks[1].ipv4 || ''); + } + } + + // Step 4: Redundancy (MLAG) + if (config.mlag) { + state.config.mlag = config.mlag; // Update state + if (config.mlag.peer_keepalive) { + setInputValue('#mlag-keepalive-src', config.mlag.peer_keepalive.source_ip || ''); + setInputValue('#mlag-keepalive-dst', config.mlag.peer_keepalive.destination_ip || ''); + } + setInputValue('#mlag-domain-id', String(config.mlag.domain_id || 1)); + } + + if (Array.isArray(config.port_channels)) { + state.config.port_channels = config.port_channels; // Update state + const ibgpPc = config.port_channels.find(pc => pc.type === 'L3' && !pc.vpc_peer_link); + if (ibgpPc) { + setInputValue('#pc-ibgp-id', String(ibgpPc.id || 50)); + setInputValue('#pc-ibgp-ip', ibgpPc.ipv4 || ''); + setInputValue('#pc-ibgp-members', (ibgpPc.members || []).join(',')); + } + } + + // Step 6: Routing (BGP) + if (config.bgp) { + state.config.bgp = config.bgp; // Update state + setInputValue('#bgp-asn', String(config.bgp.asn || '')); + setInputValue('#bgp-router-id', config.bgp.router_id || ''); + + // Load BGP neighbors + const neighborsContainer = getElement('#bgp-neighbors'); + if (neighborsContainer && config.bgp.neighbors) { + neighborsContainer.innerHTML = ''; + config.bgp.neighbors.forEach(neighbor => { + addBgpNeighbor(); + const entries = getElements('.neighbor-entry'); + const lastEntry = entries[entries.length - 1]; + if (lastEntry) { + const ipInput = lastEntry.querySelector('.bgp-neighbor-ip'); + const descInput = lastEntry.querySelector('.bgp-neighbor-desc'); + const asnInput = lastEntry.querySelector('.bgp-neighbor-asn'); + if (ipInput) ipInput.value = neighbor.ip || ''; + if (descInput) descInput.value = neighbor.description || ''; + if (asnInput) asnInput.value = String(neighbor.remote_as || ''); + } + }); + } + } + + // Update prefix lists if present + if (config.prefix_lists) { + state.config.prefix_lists = config.prefix_lists; + } + + // Mark all steps with populated data as completed + markCompletedSteps(); + + showStep(1); +} + +function selectCard(selector: string, dataAttr: string, value: string): void { + const card = getElement(`${selector}[data-${dataAttr}="${value}"]`); + if (card) { + card.click(); + } +} + +function resetVlanContainers(): void { + const mgmtContainer = getElement('#mgmt-vlans-container'); + const computeContainer = getElement('#compute-vlans-container'); + + if (mgmtContainer) mgmtContainer.innerHTML = ''; + if (computeContainer) computeContainer.innerHTML = ''; + + VLAN_CONFIGS.management.counter = 0; + VLAN_CONFIGS.compute.counter = 0; +} + +function populateVlansFromConfig(vlans: VLAN[]): void { + resetVlanContainers(); + + const management = vlans.filter(v => v.purpose === 'management'); + const compute = vlans.filter(v => v.purpose === 'compute'); + const parking = vlans.find(v => v.shutdown === true || v.purpose === 'parking'); + + if (parking) { + setInputValue('#vlan-parking-id', String(parking.vlan_id)); + } + + if (management.length === 0) { + addDynamicVlan('management'); + } else { + management.forEach(vlan => addDynamicVlan('management', vlan)); + } + + if (compute.length === 0) { + addDynamicVlan('compute'); + } else { + compute.forEach(vlan => addDynamicVlan('compute', vlan)); + } +} diff --git a/frontend/src/main.ts b/frontend/src/main.ts new file mode 100644 index 0000000..4faedb6 --- /dev/null +++ b/frontend/src/main.ts @@ -0,0 +1,344 @@ +/** + * Main entry point for the Azure Local Network Switch Configuration Wizard + */ + +import { + initializeWizard, + setupEventListeners, + setupVlanCardDelegation, + setupRouteDelegation, + showTemplateModal, + closeTemplateModal, + loadTemplate, + toggleCollapsible, + updateVlanName, + updateStorageVlanName, + selectPattern, + expandPatternImage, + expandPatternPreview, + closeLightbox, + changePattern, + onVendorChange, + onModelChange, + selectRole, + updateHostname, + startOver, + showPhase, + nextPhase, + nextSubstep, + previousSubstep, + previousPhase +} from './app'; + +// ============================================================================ +// THEME & FONT SIZE (Odin Style) +// ============================================================================ + +function toggleTheme(): void { + const body = document.body; + const isLight = body.classList.toggle('light-theme'); + const themeBtn = document.querySelector('.theme-toggle'); + if (themeBtn) { + themeBtn.textContent = isLight ? '☀️' : '🌙'; + } + localStorage.setItem('theme', isLight ? 'light' : 'dark'); +} + +function increaseFontSize(): void { + const body = document.body; + const currentSize = body.classList.contains('font-large') ? 'large' : + body.classList.contains('font-small') ? 'small' : 'normal'; + + body.classList.remove('font-small', 'font-large'); + + if (currentSize === 'small') { + // small -> normal (no class needed) + } else if (currentSize === 'normal') { + body.classList.add('font-large'); + } + // large stays large (max) + else { + body.classList.add('font-large'); + } + + const newSize = body.classList.contains('font-large') ? 'large' : + body.classList.contains('font-small') ? 'small' : 'normal'; + localStorage.setItem('fontSize', newSize); + showToast(`Font size: ${newSize}`, 'info'); +} + +function decreaseFontSize(): void { + const body = document.body; + const currentSize = body.classList.contains('font-large') ? 'large' : + body.classList.contains('font-small') ? 'small' : 'normal'; + + body.classList.remove('font-small', 'font-large'); + + if (currentSize === 'large') { + // large -> normal (no class needed) + } else if (currentSize === 'normal') { + body.classList.add('font-small'); + } + // small stays small (min) + else { + body.classList.add('font-small'); + } + + const newSize = body.classList.contains('font-large') ? 'large' : + body.classList.contains('font-small') ? 'small' : 'normal'; + localStorage.setItem('fontSize', newSize); + showToast(`Font size: ${newSize}`, 'info'); +} + +function scrollToSection(sectionId: string): void { + const section = document.getElementById(sectionId); + if (section) { + // Scroll with offset for sticky header + const headerHeight = 120; // header + breadcrumb height + const elementPosition = section.getBoundingClientRect().top; + const offsetPosition = elementPosition + window.pageYOffset - headerHeight; + + window.scrollTo({ + top: offsetPosition, + behavior: 'smooth' + }); + + // Update active breadcrumb + updateActiveBreadcrumb(sectionId); + } +} + +function updateActiveBreadcrumb(sectionId: string): void { + document.querySelectorAll('.breadcrumb-item').forEach(item => { + item.classList.remove('active'); + if (item.getAttribute('data-section') === sectionId || + item.getAttribute('href') === `#${sectionId}`) { + item.classList.add('active'); + } + }); +} + +/** + * Update breadcrumb completion states based on form data + * Only marks sections as complete when ALL required fields are filled + * Exported so it can be called after template/config loading + */ +export function updateBreadcrumbCompletion(): void { + const breadcrumbs = document.querySelectorAll('.breadcrumb-item'); + + breadcrumbs.forEach(item => { + const section = item.getAttribute('data-section') || item.getAttribute('href')?.replace('#', ''); + let isComplete = false; + + switch (section) { + case 'phase1': + // Pattern & Switch complete ONLY if ALL required fields filled: + // - Pattern selected + // - Vendor selected (not empty) + // - Model selected (not empty) + // - Role selected + // - Hostname has value + const patternSelected = document.querySelector('.pattern-card.selected'); + const vendorSelect = document.getElementById('vendor-select') as HTMLSelectElement; + const modelSelect = document.getElementById('model-select') as HTMLSelectElement; + const roleSelected = document.querySelector('.role-btn.selected'); + const hostnameInput = document.getElementById('hostname') as HTMLInputElement; + + isComplete = !!( + patternSelected && + vendorSelect?.value && vendorSelect.value !== '' && + modelSelect?.value && modelSelect.value !== '' && + roleSelected && + hostnameInput?.value && hostnameInput.value.trim() !== '' + ); + break; + + case 'phase2': + // VLANs complete if management VLAN has a valid ID (number > 0) + // Note: VLAN inputs use class selectors, not IDs + const mgmtVlanInput = document.querySelector('.vlan-mgmt-id') as HTMLInputElement; + const mgmtVlanValue = mgmtVlanInput?.value?.trim(); + isComplete = !!(mgmtVlanValue && parseInt(mgmtVlanValue, 10) > 0); + break; + + case 'phase2-ports': + // Ports complete if converged/host port range is defined with BOTH start and end + const convergedStart = document.getElementById('intf-converged-start') as HTMLInputElement; + const convergedEnd = document.getElementById('intf-converged-end') as HTMLInputElement; + isComplete = !!( + convergedStart?.value && convergedStart.value.trim() !== '' && + convergedEnd?.value && convergedEnd.value.trim() !== '' + ); + break; + + case 'phase2-redundancy': + // Redundancy complete if MLAG peer-link members and keepalive IPs are set + // Note: HTML uses id="mlag-peerlink-members" (no hyphen in peerlink) + const peerLinkMembers = document.getElementById('mlag-peerlink-members') as HTMLInputElement; + const keepaliveSrc = document.getElementById('mlag-keepalive-src') as HTMLInputElement; + const keepaliveDst = document.getElementById('mlag-keepalive-dst') as HTMLInputElement; + isComplete = !!( + peerLinkMembers?.value && peerLinkMembers.value.trim() !== '' && + keepaliveSrc?.value && keepaliveSrc.value.trim() !== '' && + keepaliveDst?.value && keepaliveDst.value.trim() !== '' + ); + break; + + case 'phase3': + // Routing complete based on routing type + const routingType = (document.querySelector('input[name="routing-type"]:checked') as HTMLInputElement)?.value || 'bgp'; + if (routingType === 'bgp') { + // BGP requires loopback IP, ASN, and router-id + const loopback = document.getElementById('intf-loopback-ip') as HTMLInputElement; + const asn = document.getElementById('bgp-asn') as HTMLInputElement; + const routerId = document.getElementById('bgp-router-id') as HTMLInputElement; + isComplete = !!( + loopback?.value && loopback.value.trim() !== '' && + asn?.value && parseInt(asn.value, 10) > 0 && + routerId?.value && routerId.value.trim() !== '' + ); + } else { + // Static routing - just needs loopback IP + const loopback = document.getElementById('intf-loopback-ip') as HTMLInputElement; + isComplete = !!(loopback?.value && loopback.value.trim() !== ''); + } + break; + + case 'review': + // Review is complete when all other sections are complete + // Check if phase1, phase2, phase2-ports, phase2-redundancy, and phase3 are all done + const allOtherSectionsComplete = [ + 'phase1', 'phase2', 'phase2-ports', 'phase2-redundancy', 'phase3' + ].every(sectionId => { + const breadcrumb = document.querySelector(`.breadcrumb-item[data-section="${sectionId}"]`); + return breadcrumb?.classList.contains('completed'); + }); + isComplete = allOtherSectionsComplete; + break; + } + + if (isComplete) { + item.classList.add('completed'); + } else { + item.classList.remove('completed'); + } + }); +} + +// Call updateBreadcrumbCompletion periodically and on input changes +function setupBreadcrumbTracking(): void { + // Update on any input change + document.addEventListener('input', () => { + setTimeout(updateBreadcrumbCompletion, 100); + }); + + // Update on click (for card selections) + document.addEventListener('click', () => { + setTimeout(updateBreadcrumbCompletion, 100); + }); + + // Initial update + setTimeout(updateBreadcrumbCompletion, 500); +} + +function showToast(message: string, type: 'success' | 'error' | 'info' = 'info'): void { + const container = document.getElementById('toast-container'); + if (!container) return; + + const toast = document.createElement('div'); + toast.className = `toast ${type}`; + toast.textContent = message; + container.appendChild(toast); + + setTimeout(() => { + toast.classList.add('dismissing'); + setTimeout(() => toast.remove(), 300); + }, 3000); +} + +function loadSavedPreferences(): void { + // Load theme + const savedTheme = localStorage.getItem('theme'); + if (savedTheme === 'light') { + document.body.classList.add('light-theme'); + const themeBtn = document.querySelector('.theme-toggle'); + if (themeBtn) themeBtn.textContent = '☀️'; + } + + // Load font size + const savedFontSize = localStorage.getItem('fontSize'); + if (savedFontSize === 'large') { + document.body.classList.add('font-large'); + } else if (savedFontSize === 'small') { + document.body.classList.add('font-small'); + } +} + +// Expose functions globally for onclick handlers in HTML +declare global { + interface Window { + showTemplateModal: () => void; + closeTemplateModal: () => void; + loadTemplate: (templateName: string) => Promise; + toggleCollapsible: (header: HTMLElement) => void; + updateVlanName: (input: HTMLInputElement, type: string, prefix: string) => void; + updateStorageVlanName: (storageNum: number) => void; + selectPattern: (pattern: any) => void; + expandPatternImage: () => void; + expandPatternPreview: (src: string, alt: string) => void; + closeLightbox: () => void; + changePattern: () => void; + onVendorChange: () => void; + onModelChange: () => void; + selectRole: (role: any) => void; + updateHostname: () => void; + startOver: () => void; + showPhase: (phase: number | string, substep?: string) => void; + nextPhase: () => void; + nextSubstep: () => void; + previousSubstep: () => void; + previousPhase: () => void; + toggleTheme: () => void; + increaseFontSize: () => void; + decreaseFontSize: () => void; + scrollToSection: (sectionId: string) => void; + showToast: (message: string, type?: 'success' | 'error' | 'info') => void; + } +} + +window.showTemplateModal = showTemplateModal; +window.closeTemplateModal = closeTemplateModal; +window.loadTemplate = loadTemplate; +window.toggleCollapsible = toggleCollapsible; +window.updateVlanName = updateVlanName; +window.updateStorageVlanName = updateStorageVlanName; +window.selectPattern = selectPattern; +window.expandPatternImage = expandPatternImage; +window.expandPatternPreview = expandPatternPreview; +window.closeLightbox = closeLightbox; +window.changePattern = changePattern; +window.onVendorChange = onVendorChange; +window.onModelChange = onModelChange; +window.selectRole = selectRole; +window.updateHostname = updateHostname; +window.startOver = startOver; +window.showPhase = showPhase; +window.nextPhase = nextPhase; +window.nextSubstep = nextSubstep; +window.previousSubstep = previousSubstep; +window.previousPhase = previousPhase; +window.toggleTheme = toggleTheme; +window.increaseFontSize = increaseFontSize; +window.decreaseFontSize = decreaseFontSize; +window.scrollToSection = scrollToSection; +window.showToast = showToast; + +// Initialize the application when DOM is ready +document.addEventListener('DOMContentLoaded', () => { + loadSavedPreferences(); + initializeWizard(); + setupEventListeners(); + setupVlanCardDelegation(); + setupRouteDelegation(); + setupBreadcrumbTracking(); +}); diff --git a/frontend/src/state.ts b/frontend/src/state.ts new file mode 100644 index 0000000..ade3488 --- /dev/null +++ b/frontend/src/state.ts @@ -0,0 +1,256 @@ +/** + * Wizard state management + * Manages the multi-step wizard state and provides typed getters/setters + */ + +import type { + SwitchConfig, + VLAN, + Interface, + PortChannel, + MLAG, + BGP, + PrefixLists, + StandardConfig, + DeploymentPattern +} from './types'; + +export type WizardPhase = 1 | 2 | 3 | 'review'; +export type Phase2Substep = '2.1' | '2.2' | '2.3' | '2.4'; + +export interface WizardState { + currentPhase: WizardPhase; + currentSubstep: Phase2Substep | null; + selectedPattern: DeploymentPattern | null; + switch: Partial; + vlans: VLAN[]; + interfaces: Interface[]; + portChannels: PortChannel[]; + mlag: Partial; + bgp: Partial; + prefix_lists: PrefixLists; + qos: boolean; +} + +// Initialize default state +const initialState: WizardState = { + currentPhase: 1, + currentSubstep: null, + selectedPattern: null, + switch: {}, + vlans: [], + interfaces: [], + portChannels: [], + mlag: {}, + bgp: {}, + prefix_lists: {}, + qos: false +}; + +// Global state instance +let state: WizardState = { ...initialState }; + +/** + * Get current wizard state + */ +export function getState(): WizardState { + return state; +} + +/** + * Set entire wizard state + */ +export function setState(newState: WizardState): void { + state = newState; +} + +/** + * Reset state to initial values + */ +export function resetState(): void { + state = { ...initialState }; +} + +/** + * Get current step + */ +export function getCurrentPhase(): WizardPhase { + return state.currentPhase; +} + +/** + * Set current step + */ +export function setCurrentPhase(phase: WizardPhase): void { + state.currentPhase = phase; +} + +/** + * Get switch config + */ +export function getSwitchConfig(): Partial { + return state.switch; +} + +/** + * Update switch config (merge) + */ +export function updateSwitchConfig(config: Partial): void { + state.switch = { ...state.switch, ...config }; +} + +/** + * Get VLANs + */ +export function getVlans(): VLAN[] { + return state.vlans; +} + +/** + * Set VLANs + */ +export function setVlans(vlans: VLAN[]): void { + state.vlans = vlans; +} + +/** + * Add VLAN + */ +export function addVlan(vlan: VLAN): void { + state.vlans.push(vlan); +} + +/** + * Remove VLAN by ID + */ +export function removeVlan(vlanId: number): void { + state.vlans = state.vlans.filter(v => v.vlan_id !== vlanId); +} + +/** + * Get interfaces + */ +export function getInterfaces(): Interface[] { + return state.interfaces; +} + +/** + * Set interfaces + */ +export function setInterfaces(interfaces: Interface[]): void { + state.interfaces = interfaces; +} + +/** + * Add interface + */ +export function addInterface(iface: Interface): void { + state.interfaces.push(iface); +} + +/** + * Get port channels + */ +export function getPortChannels(): PortChannel[] { + return state.portChannels; +} + +/** + * Set port channels + */ +export function setPortChannels(channels: PortChannel[]): void { + state.portChannels = channels; +} + +/** + * Add port channel + */ +export function addPortChannel(channel: PortChannel): void { + state.portChannels.push(channel); +} + +/** + * Get MLAG config + */ +export function getMlagConfig(): Partial { + return state.mlag; +} + +/** + * Update MLAG config (merge) + */ +export function updateMlagConfig(config: Partial): void { + state.mlag = { ...state.mlag, ...config }; +} + +/** + * Get BGP config + */ +export function getBgpConfig(): Partial { + return state.bgp; +} + +/** + * Update BGP config (merge) + */ +export function updateBgpConfig(config: Partial): void { + state.bgp = { ...state.bgp, ...config }; +} + +/** + * Get prefix lists + */ +export function getPrefixLists(): PrefixLists { + return state.prefix_lists; +} + +/** + * Set prefix lists + */ +export function setPrefixLists(lists: PrefixLists): void { + state.prefix_lists = lists; +} + +/** + * Get QoS enabled state + */ +export function getQosEnabled(): boolean { + return state.qos; +} + +/** + * Set QoS enabled state + */ +export function setQosEnabled(enabled: boolean): void { + state.qos = enabled; +} + +/** + * Export state as StandardConfig + */ +export function exportToStandardConfig(): StandardConfig { + return { + switch: state.switch as SwitchConfig, + vlans: state.vlans, + interfaces: state.interfaces, + port_channels: state.portChannels, + mlag: Object.keys(state.mlag).length > 0 ? state.mlag as MLAG : undefined, + bgp: Object.keys(state.bgp).length > 0 ? state.bgp as BGP : undefined, + prefix_lists: Object.keys(state.prefix_lists).length > 0 ? state.prefix_lists : undefined, + qos: state.qos + }; +} + +/** + * Import StandardConfig into state + */ +export function importFromStandardConfig(config: StandardConfig): void { + state.switch = config.switch; + state.vlans = config.vlans || []; + state.interfaces = config.interfaces || []; + state.portChannels = config.port_channels || []; + state.mlag = config.mlag || {}; + state.bgp = config.bgp || {}; + state.prefix_lists = config.prefix_lists || {}; + state.qos = config.qos || false; +} diff --git a/frontend/src/types.ts b/frontend/src/types.ts new file mode 100644 index 0000000..a5a37aa --- /dev/null +++ b/frontend/src/types.ts @@ -0,0 +1,159 @@ +/** + * TypeScript type definitions for Azure Local Network Switch Configuration + * Generated from backend/schema/standard.json + */ + +// Vendor and firmware types +export type Vendor = 'cisco' | 'dellemc'; +export type Firmware = 'nxos' | 'os10'; +export type Role = 'TOR1' | 'TOR2' | 'BMC'; +export type DeploymentPattern = 'fully_converged' | 'switched' | 'switchless'; + +// VLAN types +export type VLANPurpose = 'parking' | 'management' | 'compute' | 'storage_1' | 'storage_2' | 'bmc'; +export type RedundancyType = 'vrrp' | 'hsrp'; + +export interface VLANRedundancy { + type: RedundancyType; + group: number; + priority: number; + virtual_ip: string; + preempt?: boolean; +} + +export interface VLANInterface { + ip: string; + cidr: number; + mtu?: number; + redundancy?: VLANRedundancy; + dhcp_relay?: string[]; +} + +export interface VLAN { + vlan_id: number; + name: string; + purpose?: VLANPurpose; + shutdown?: boolean; + interface?: VLANInterface; +} + +// Interface types +export type InterfaceType = 'Access' | 'Trunk' | 'L3'; +export type PhysicalInterfaceType = 'Ethernet' | 'loopback' | 'port-channel'; + +export interface ServicePolicy { + qos_input?: string; +} + +export interface Interface { + name: string; + type: InterfaceType; + description?: string; + intf_type?: PhysicalInterfaceType; + intf?: string; + start_intf?: string; + end_intf?: string; + access_vlan?: string; + native_vlan?: string; + tagged_vlans?: string; + ipv4?: string; + shutdown?: boolean; + qos?: boolean; + service_policy?: ServicePolicy; +} + +// Port Channel types +export interface PortChannel { + id: number; + description: string; + type: 'Trunk' | 'L3'; + ipv4?: string; + native_vlan?: string; + tagged_vlans?: string; + members: string[]; + vpc_peer_link?: boolean; +} + +// MLAG types +export interface MLAGPeerKeepalive { + source_ip: string; + destination_ip: string; + vrf?: string; +} + +export interface MLAG { + domain_id: number; + peer_keepalive: MLAGPeerKeepalive; + delay_restore?: number; + peer_gateway?: boolean; + auto_recovery?: boolean; +} + +// BGP types +export interface PrefixListEntry { + seq: number; + action: 'permit' | 'deny'; + prefix: string; + prefix_filter?: string; +} + +export type PrefixLists = Record; + +export interface BGPNeighborAF { + prefix_list_in?: string; + prefix_list_out?: string; +} + +export interface BGPNeighbor { + ip: string; + description: string; + remote_as: number; + af_ipv4_unicast?: BGPNeighborAF; +} + +export interface BGP { + asn: number; + router_id: string; + networks?: string[]; + neighbors: BGPNeighbor[]; +} + +// Switch configuration +export interface SwitchConfig { + vendor: Vendor; + model: string; + firmware: Firmware; + hostname: string; + role: Role; + version?: string; + deployment_pattern?: DeploymentPattern; +} + +// Main configuration type +export interface StandardConfig { + switch: SwitchConfig; + vlans?: VLAN[]; + interfaces?: Interface[]; + port_channels?: PortChannel[]; + mlag?: MLAG; + prefix_lists?: PrefixLists; + bgp?: BGP; + qos?: boolean; +} + +// Validation result type +export interface ValidationError { + path: string; + message: string; +} + +export interface ValidationResult { + valid: boolean; + errors: ValidationError[]; +} + +// Wizard state type +export interface WizardState { + currentStep: number; + config: Partial; +} diff --git a/frontend/src/utils.ts b/frontend/src/utils.ts new file mode 100644 index 0000000..f3028f8 --- /dev/null +++ b/frontend/src/utils.ts @@ -0,0 +1,146 @@ +/** + * Utility functions for the wizard + */ + +import type { Vendor, Role, RedundancyType } from './types'; + +// Display name mappings +export const DISPLAY_NAMES = { + vendors: { + 'dellemc': 'Dell EMC', + 'cisco': 'Cisco' + }, + models: { + 's5248f-on': 'S5248F-ON', + 's5232f-on': 'S5232F-ON', + '93180yc-fx': '93180YC-FX', + '9336c-fx2': '9336C-FX2' + }, + roles: { + 'TOR1': 'TOR1 (Primary)', + 'TOR2': 'TOR2 (Secondary)', + 'BMC': 'BMC (Management)' + }, + patterns: { + 'fully_converged': 'Fully Converged', + 'switched': 'Storage Switched', + 'switchless': 'Switchless' + }, + routingTypes: { + 'bgp': 'BGP (Dynamic)', + 'static': 'Static Routes' + }, + vlanPurposes: { + 'parking': 'Parking (Unused)', + 'management': 'Management', + 'compute': 'Compute', + 'storage_1': 'Storage 1', + 'storage_2': 'Storage 2', + 'bmc': 'BMC' + } +} as const; + +// Vendor firmware mappings +export const VENDOR_FIRMWARE_MAP: Record = { + 'dellemc': 'os10', + 'cisco': 'nxos' +}; + +// Vendor redundancy type mappings +export const VENDOR_REDUNDANCY_TYPE: Record = { + 'dellemc': 'vrrp', + 'cisco': 'hsrp' +}; + +// Role-based defaults +export const ROLE_DEFAULTS: Record = { + 'TOR1': { hsrp_priority: 150, mlag_role_priority: 1, mst_priority: 8192 }, + 'TOR2': { hsrp_priority: 100, mlag_role_priority: 32667, mst_priority: 16384 }, + 'BMC': { hsrp_priority: null, mlag_role_priority: null, mst_priority: 32768 } +}; + +/** + * Get elements by selector + */ +export function getElement(selector: string): T | null { + return document.querySelector(selector); +} + +/** + * Get all elements by selector + */ +export function getElements(selector: string): NodeListOf { + return document.querySelectorAll(selector); +} + +/** + * Get input value safely + */ +export function getInputValue(selector: string): string { + const element = getElement(selector); + return element?.value?.trim() || ''; +} + +/** + * Set input value safely + */ +export function setInputValue(selector: string, value: string): void { + const element = getElement(selector); + if (element) { + element.value = value; + } +} + +/** + * Show/hide element + */ +export function toggleElement(selector: string, show: boolean): void { + const element = getElement(selector); + if (element) { + element.style.display = show ? '' : 'none'; + } +} + +/** + * Download data as JSON file + */ +export function downloadJSON(data: unknown, filename: string): void { + const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = filename; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); +} + +/** + * Copy text to clipboard + */ +export async function copyToClipboard(text: string): Promise { + try { + await navigator.clipboard.writeText(text); + return true; + } catch (err) { + console.error('Failed to copy:', err); + return false; + } +} + +/** + * Parse integer safely + */ +export function parseIntSafe(value: string | undefined, defaultValue = 0): number { + if (!value) return defaultValue; + const parsed = parseInt(value, 10); + return isNaN(parsed) ? defaultValue : parsed; +} + +/** + * Format JSON for display + */ +export function formatJSON(data: unknown): string { + return JSON.stringify(data, null, 2); +} diff --git a/frontend/src/validator.ts b/frontend/src/validator.ts new file mode 100644 index 0000000..48e52cd --- /dev/null +++ b/frontend/src/validator.ts @@ -0,0 +1,55 @@ +/** + * Configuration validation using Ajv and JSON Schema + */ + +import Ajv from 'ajv'; +import type { StandardConfig, ValidationResult, ValidationError } from './types'; +import standardSchema from '../../backend/schema/standard.json'; + +const ajv = new Ajv({ allErrors: true, strict: false }); +const validate = ajv.compile(standardSchema); + +/** + * Validate configuration against JSON schema + */ +export function validateConfig(config: StandardConfig): ValidationResult { + const valid = validate(config); + + if (valid) { + return { valid: true, errors: [] }; + } + + const errors: ValidationError[] = (validate.errors || []).map((err: any) => ({ + path: err.instancePath || err.schemaPath || '', + message: err.message || 'Unknown validation error' + })); + + return { valid: false, errors }; +} + +/** + * Validate IPv4 address format + */ +export function isValidIPv4(ip?: string): boolean { + if (!ip) return false; + const parts = ip.split('.'); + if (parts.length !== 4) return false; + return parts.every(part => { + const num = parseInt(part, 10); + return !isNaN(num) && num >= 0 && num <= 255 && String(num) === part; + }); +} + +/** + * Validate CIDR notation + */ +export function isValidCIDR(cidr?: string): boolean { + if (!cidr) return false; + const parts = cidr.split('/'); + if (parts.length !== 2) return false; + if (!isValidIPv4(parts[0])) return false; + const prefixStr = parts[1]; + if (!prefixStr) return false; + const prefix = parseInt(prefixStr, 10); + return !isNaN(prefix) && prefix >= 0 && prefix <= 32; +} diff --git a/frontend/style-odin.css b/frontend/style-odin.css new file mode 100644 index 0000000..d79e493 --- /dev/null +++ b/frontend/style-odin.css @@ -0,0 +1,1209 @@ +/* Azure Local Switch Configuration Wizard - Odin-Style Dark Theme */ + +* { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +:root { + /* Odin Dark Theme Colors */ + --bg-primary: #0a0e17; + --bg-secondary: #111827; + --bg-card: #1a1f2e; + --bg-card-hover: #232936; + --bg-input: #0d1117; + --border-color: #2d3748; + --border-light: #374151; + --text-primary: #e2e8f0; + --text-secondary: #94a3b8; + --text-muted: #64748b; + --accent-blue: #3b82f6; + --accent-purple: #8b5cf6; + --accent-green: #10b981; + --accent-cyan: #06b6d4; + --success: #22c55e; + --warning: #f59e0b; + --error: #ef4444; +} + +body { + font-family: 'Inter', 'Segoe UI', system-ui, -apple-system, sans-serif; + background: var(--bg-primary); + min-height: 100vh; + color: var(--text-primary); + line-height: 1.6; + -webkit-font-smoothing: antialiased; +} + +/* ============================================ + LAYOUT + ============================================ */ + +.container { + max-width: 1600px; + margin: 0 auto; + padding: 0 20px; +} + +.main-layout { + display: flex; + gap: 24px; + align-items: flex-start; + padding: 20px 0; +} + +.wizard-container { + flex: 1; + min-width: 0; +} + +/* ============================================ + HEADER + ============================================ */ + +header { + background: var(--bg-secondary); + border-bottom: 1px solid var(--border-color); + padding: 20px 0; + margin-bottom: 0; +} + +header .container { + display: flex; + align-items: center; + justify-content: space-between; +} + +header h1 { + color: var(--text-primary); + font-size: 24px; + font-weight: 600; + display: flex; + align-items: center; + gap: 12px; +} + +header h1::before { + content: ''; + width: 40px; + height: 40px; + background: linear-gradient(135deg, var(--accent-blue), var(--accent-purple)); + border-radius: 10px; +} + +.subtitle { + color: var(--text-secondary); + font-size: 14px; + max-width: 600px; + margin-top: 8px; +} + +.import-section { + display: flex; + gap: 12px; +} + +.import-btn { + background: var(--bg-card); + color: var(--text-primary); + border: 1px solid var(--border-color); + padding: 10px 16px; + border-radius: 8px; + font-size: 13px; + font-weight: 500; + cursor: pointer; + transition: all 0.2s; + display: inline-flex; + align-items: center; + gap: 8px; +} + +.import-btn:hover { + background: var(--bg-card-hover); + border-color: var(--accent-blue); +} + +.import-btn.primary { + background: var(--accent-blue); + border-color: var(--accent-blue); +} + +.import-btn.primary:hover { + background: #2563eb; +} + +/* ============================================ + TOP NAVIGATION (Quick Jump) + ============================================ */ + +.top-nav { + background: var(--bg-secondary); + border-bottom: 1px solid var(--border-color); + padding: 0; + display: flex; + align-items: stretch; + overflow-x: auto; + position: sticky; + top: 0; + z-index: 100; +} + +.nav-phase { + display: flex; + align-items: center; + gap: 10px; + padding: 16px 24px; + color: var(--text-muted); + cursor: pointer; + border-bottom: 3px solid transparent; + transition: all 0.2s; + white-space: nowrap; +} + +.nav-phase:hover { + color: var(--text-secondary); + background: var(--bg-card); +} + +.nav-phase.active { + color: var(--accent-cyan); + border-bottom-color: var(--accent-cyan); +} + +.nav-phase.completed { + color: var(--success); +} + +.nav-phase.completed .phase-number { + background: var(--success); +} + +.phase-number { + width: 28px; + height: 28px; + background: var(--border-color); + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + font-size: 12px; + font-weight: 600; + color: var(--text-primary); +} + +.nav-phase.active .phase-number { + background: var(--accent-cyan); + color: var(--bg-primary); +} + +.phase-label { + font-size: 14px; + font-weight: 500; +} + +.sub-steps { + display: none; + gap: 12px; + margin-left: 10px; + padding-left: 10px; + border-left: 1px solid var(--border-color); +} + +.nav-phase.active .sub-steps { + display: flex; +} + +.sub-step { + font-size: 12px; + color: var(--text-muted); + cursor: pointer; + padding: 4px 8px; + border-radius: 4px; +} + +.sub-step:hover { + color: var(--text-secondary); + background: var(--bg-card); +} + +.sub-step.active { + color: var(--accent-cyan); + background: rgba(6, 182, 212, 0.1); +} + +/* ============================================ + MODULE CARDS (Odin Style) + ============================================ */ + +.phase { + display: none; +} + +.phase.active { + display: block; +} + +.phase > h2 { + font-size: 20px; + color: var(--text-primary); + margin-bottom: 8px; + display: flex; + align-items: center; + gap: 12px; +} + +.phase > h2::before { + content: attr(data-number); + width: 32px; + height: 32px; + background: var(--accent-blue); + border-radius: 8px; + display: flex; + align-items: center; + justify-content: center; + font-size: 14px; + font-weight: 600; +} + +.phase > .description { + color: var(--text-secondary); + font-size: 14px; + margin-bottom: 24px; +} + +/* Module Section */ +.form-section { + background: var(--bg-card); + border: 1px solid var(--border-color); + border-radius: 12px; + padding: 24px; + margin-bottom: 20px; +} + +.form-section h3 { + font-size: 16px; + color: var(--text-primary); + margin-bottom: 16px; + display: flex; + align-items: center; + gap: 10px; +} + +.form-section h3 .required { + color: var(--error); +} + +/* ============================================ + PATTERN CARDS (Odin Style Selection) + ============================================ */ + +.pattern-cards { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 16px; +} + +.pattern-card { + background: var(--bg-secondary); + border: 2px solid var(--border-color); + border-radius: 12px; + padding: 20px; + cursor: pointer; + transition: all 0.2s; + position: relative; +} + +.pattern-card:hover { + border-color: var(--accent-blue); + background: var(--bg-card-hover); +} + +.pattern-card.selected { + border-color: var(--accent-cyan); + background: rgba(6, 182, 212, 0.1); +} + +.pattern-card.selected::after { + content: '✓'; + position: absolute; + top: 12px; + right: 12px; + width: 24px; + height: 24px; + background: var(--accent-cyan); + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + font-size: 14px; + color: var(--bg-primary); +} + +.pattern-card.recommended::before { + content: 'RECOMMENDED'; + position: absolute; + top: -10px; + left: 20px; + background: var(--accent-purple); + color: white; + font-size: 10px; + font-weight: 600; + padding: 4px 8px; + border-radius: 4px; + letter-spacing: 0.5px; +} + +.pattern-card img { + width: 100%; + height: 120px; + object-fit: contain; + border-radius: 8px; + background: var(--bg-primary); + margin-bottom: 12px; +} + +.pattern-card h4 { + font-size: 16px; + color: var(--text-primary); + margin-bottom: 8px; +} + +.pattern-card p { + font-size: 13px; + color: var(--text-secondary); + margin-bottom: 12px; +} + +.pattern-tag { + font-size: 11px; + color: var(--text-muted); + background: var(--bg-primary); + padding: 4px 8px; + border-radius: 4px; +} + +/* ============================================ + ROLE CARDS + ============================================ */ + +.role-cards { + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: 16px; +} + +.role-card { + background: var(--bg-secondary); + border: 2px solid var(--border-color); + border-radius: 12px; + padding: 20px; + cursor: pointer; + transition: all 0.2s; + text-align: center; +} + +.role-card:hover { + border-color: var(--accent-blue); +} + +.role-card.selected { + border-color: var(--accent-cyan); + background: rgba(6, 182, 212, 0.1); +} + +.role-card h4 { + font-size: 18px; + color: var(--text-primary); + margin-bottom: 4px; +} + +.role-card p { + font-size: 13px; + color: var(--text-secondary); +} + +/* ============================================ + FORM ELEMENTS (Odin Style) + ============================================ */ + +.form-container { + display: flex; + flex-direction: column; + gap: 16px; +} + +.form-row { + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: 16px; +} + +.form-group { + display: flex; + flex-direction: column; + gap: 6px; +} + +.form-group label { + font-size: 13px; + font-weight: 500; + color: var(--text-secondary); +} + +.form-group label .required { + color: var(--error); +} + +.form-group input, +.form-group select, +.form-group textarea { + background: var(--bg-input); + border: 1px solid var(--border-color); + border-radius: 8px; + padding: 12px 14px; + font-size: 14px; + color: var(--text-primary); + transition: all 0.2s; +} + +.form-group input:focus, +.form-group select:focus, +.form-group textarea:focus { + outline: none; + border-color: var(--accent-blue); + box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.2); +} + +.form-group input::placeholder { + color: var(--text-muted); +} + +.form-group small { + font-size: 12px; + color: var(--text-muted); +} + +.form-group select { + cursor: pointer; +} + +.form-group select option { + background: var(--bg-secondary); + color: var(--text-primary); +} + +/* Checkbox */ +.checkbox-label { + display: flex; + align-items: center; + gap: 10px; + cursor: pointer; + font-size: 14px; + color: var(--text-primary); +} + +.checkbox-label input[type="checkbox"] { + width: 18px; + height: 18px; + accent-color: var(--accent-cyan); +} + +/* ============================================ + PORT CARDS / VLAN CARDS + ============================================ */ + +.port-card, +.vlan-card, +.bgp-card { + background: var(--bg-secondary); + border: 1px solid var(--border-color); + border-radius: 12px; + padding: 20px; + margin-bottom: 16px; +} + +.port-card h3, +.vlan-card h3, +.bgp-card h3 { + font-size: 15px; + color: var(--text-primary); + margin-bottom: 8px; +} + +.section-note { + font-size: 13px; + color: var(--text-muted); + margin-bottom: 16px; +} + +/* ============================================ + BGP NEIGHBORS (Odin Style) + ============================================ */ + +.neighbor-entry { + background: var(--bg-primary); + border: 1px solid var(--border-color); + border-radius: 8px; + margin-bottom: 12px; + overflow: hidden; +} + +.neighbor-header { + display: flex; + justify-content: space-between; + align-items: center; + background: var(--bg-card); + padding: 10px 15px; + border-bottom: 1px solid var(--border-color); +} + +.neighbor-title { + font-weight: 600; + font-size: 13px; + color: var(--text-secondary); +} + +.btn-remove-neighbor { + background: transparent; + border: none; + color: var(--text-muted); + width: 24px; + height: 24px; + border-radius: 4px; + cursor: pointer; + font-size: 14px; + transition: all 0.2s; +} + +.btn-remove-neighbor:hover { + background: rgba(239, 68, 68, 0.2); + color: var(--error); +} + +.neighbor-fields { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 12px; + padding: 15px; +} + +.neighbor-fields .form-group label { + font-size: 11px; + text-transform: uppercase; + letter-spacing: 0.5px; +} + +/* ============================================ + BUTTONS (Odin Style) + ============================================ */ + +.btn-group { + display: flex; + justify-content: space-between; + margin-top: 24px; + padding-top: 24px; + border-top: 1px solid var(--border-color); +} + +.btn-next, +.btn-back { + padding: 12px 24px; + border: none; + border-radius: 8px; + font-size: 14px; + font-weight: 500; + cursor: pointer; + transition: all 0.2s; + display: flex; + align-items: center; + gap: 8px; +} + +.btn-next { + background: var(--accent-blue); + color: white; +} + +.btn-next:hover { + background: #2563eb; +} + +.btn-back { + background: var(--bg-card); + color: var(--text-primary); + border: 1px solid var(--border-color); +} + +.btn-back:hover { + background: var(--bg-card-hover); +} + +.btn-add { + background: transparent; + border: 1px dashed var(--border-color); + color: var(--accent-blue); + padding: 10px 16px; + border-radius: 8px; + font-size: 13px; + cursor: pointer; + transition: all 0.2s; + width: 100%; + margin-top: 12px; +} + +.btn-add:hover { + border-color: var(--accent-blue); + background: rgba(59, 130, 246, 0.1); +} + +/* ============================================ + CONFIGURATION SUMMARY SIDEBAR + ============================================ */ + +.config-summary-sidebar { + width: 320px; + flex-shrink: 0; + background: var(--bg-secondary); + border: 1px solid var(--border-color); + border-radius: 12px; + position: sticky; + top: 80px; + max-height: calc(100vh - 100px); + overflow-y: auto; +} + +.sidebar-header { + padding: 20px; + border-bottom: 1px solid var(--border-color); + background: var(--bg-card); + border-radius: 12px 12px 0 0; +} + +.sidebar-header h3 { + font-size: 14px; + font-weight: 600; + color: var(--text-primary); + margin-bottom: 12px; +} + +.progress-indicator { + display: flex; + align-items: center; + gap: 12px; +} + +.progress-bar { + flex: 1; + height: 6px; + background: var(--border-color); + border-radius: 3px; + overflow: hidden; +} + +.progress-fill { + height: 100%; + background: linear-gradient(90deg, var(--accent-blue), var(--accent-cyan)); + transition: width 0.3s ease; + border-radius: 3px; +} + +.progress-text { + font-size: 13px; + font-weight: 600; + color: var(--accent-cyan); + min-width: 50px; + text-align: right; +} + +.sidebar-content { + padding: 16px; +} + +.summary-section { + margin-bottom: 20px; + padding-bottom: 16px; + border-bottom: 1px solid var(--border-color); +} + +.summary-section:last-child { + margin-bottom: 0; + padding-bottom: 0; + border-bottom: none; +} + +.summary-section h4 { + font-size: 11px; + font-weight: 700; + color: var(--accent-cyan); + text-transform: uppercase; + letter-spacing: 1px; + margin-bottom: 12px; +} + +.summary-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 8px; + font-size: 13px; +} + +.summary-label { + color: var(--text-muted); +} + +.summary-value { + color: var(--text-primary); + font-weight: 500; + text-align: right; +} + +/* ============================================ + PATTERN SIDEBAR (LEFT) + ============================================ */ + +.pattern-sidebar { + background: var(--bg-card); + border: 1px solid var(--border-color); + border-radius: 12px; + padding: 16px; + margin-bottom: 20px; +} + +.sidebar-thumbnail img { + width: 100%; + border-radius: 8px; + cursor: zoom-in; + margin-bottom: 12px; +} + +.sidebar-info strong { + color: var(--text-primary); + font-size: 14px; + display: block; + margin-bottom: 8px; +} + +.change-pattern-btn { + width: 100%; + background: var(--bg-secondary); + border: 1px solid var(--border-color); + color: var(--text-secondary); + padding: 8px 12px; + border-radius: 6px; + font-size: 12px; + cursor: pointer; + transition: all 0.2s; + margin-top: 12px; +} + +.change-pattern-btn:hover { + background: var(--bg-card-hover); + color: var(--text-primary); +} + +/* ============================================ + LIGHTBOX + ============================================ */ + +.lightbox { + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + background: rgba(0, 0, 0, 0.9); + display: flex; + align-items: center; + justify-content: center; + z-index: 1000; + cursor: zoom-out; +} + +.lightbox img { + max-width: 90%; + max-height: 90%; + border-radius: 8px; +} + +.lightbox-close { + position: absolute; + top: 20px; + right: 30px; + color: white; + font-size: 40px; + cursor: pointer; +} + +/* ============================================ + MODAL (Odin Style) + ============================================ */ + +.modal { + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + background: rgba(0, 0, 0, 0.8); + display: none; + align-items: center; + justify-content: center; + z-index: 1000; +} + +.modal-content { + background: var(--bg-secondary); + border: 1px solid var(--border-color); + border-radius: 16px; + max-width: 800px; + width: 90%; + max-height: 85vh; + overflow-y: auto; +} + +.modal-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 20px 24px; + border-bottom: 1px solid var(--border-color); +} + +.modal-header h2 { + font-size: 18px; + color: var(--text-primary); +} + +.modal-close { + background: none; + border: none; + color: var(--text-muted); + font-size: 28px; + cursor: pointer; + padding: 0; + line-height: 1; +} + +.modal-close:hover { + color: var(--text-primary); +} + +.modal-description { + padding: 16px 24px; + color: var(--text-secondary); + font-size: 14px; + border-bottom: 1px solid var(--border-color); +} + +/* Template List in Modal */ +#template-list { + padding: 24px; +} + +.template-category { + color: var(--text-secondary); + font-size: 14px; + font-weight: 600; + margin-bottom: 12px; + margin-top: 20px; +} + +.template-category:first-child { + margin-top: 0; +} + +.template-grid { + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: 12px; +} + +.template-card { + background: var(--bg-card); + border: 1px solid var(--border-color); + border-radius: 8px; + padding: 16px; + cursor: pointer; + transition: all 0.2s; +} + +.template-card:hover { + border-color: var(--accent-blue); + background: var(--bg-card-hover); +} + +.template-card h4 { + font-size: 14px; + color: var(--text-primary); + margin-bottom: 4px; +} + +.template-card p { + font-size: 12px; + color: var(--text-muted); +} + +/* ============================================ + MESSAGES + ============================================ */ + +.message { + padding: 16px 20px; + border-radius: 8px; + margin: 20px 0; + font-size: 14px; +} + +.error-message { + background: rgba(239, 68, 68, 0.1); + border: 1px solid var(--error); + color: var(--error); +} + +.success-message { + background: rgba(34, 197, 94, 0.1); + border: 1px solid var(--success); + color: var(--success); +} + +/* ============================================ + REVIEW / EXPORT SECTION + ============================================ */ + +.summary-container { + background: var(--bg-card); + border: 1px solid var(--border-color); + border-radius: 12px; + padding: 24px; + margin-bottom: 20px; +} + +.preview-container { + background: var(--bg-card); + border: 1px solid var(--border-color); + border-radius: 12px; + padding: 24px; + margin-bottom: 20px; +} + +.preview-container h3 { + font-size: 16px; + color: var(--text-primary); + margin-bottom: 16px; +} + +#json-preview { + background: var(--bg-primary); + border: 1px solid var(--border-color); + border-radius: 8px; + padding: 16px; + font-family: 'Monaco', 'Menlo', 'Courier New', monospace; + font-size: 12px; + color: var(--accent-cyan); + overflow-x: auto; + max-height: 400px; + white-space: pre-wrap; +} + +.export-buttons { + display: flex; + gap: 12px; + flex-wrap: wrap; +} + +.btn-export { + background: var(--accent-blue); + color: white; + border: none; + padding: 12px 24px; + border-radius: 8px; + font-size: 14px; + font-weight: 500; + cursor: pointer; + transition: all 0.2s; + display: flex; + align-items: center; + gap: 8px; +} + +.btn-export:hover { + background: #2563eb; +} + +.btn-export.secondary { + background: var(--bg-card); + border: 1px solid var(--border-color); + color: var(--text-primary); +} + +.btn-export.secondary:hover { + background: var(--bg-card-hover); +} + +.btn-reset { + background: transparent; + border: 1px solid var(--border-color); + color: var(--text-secondary); + padding: 12px 24px; + border-radius: 8px; + font-size: 14px; + cursor: pointer; +} + +.btn-reset:hover { + background: var(--bg-card); + color: var(--text-primary); +} + +/* ============================================ + SUB-NAV BUTTONS + ============================================ */ + +.sub-nav { + display: flex; + gap: 8px; + margin-bottom: 24px; + padding: 12px; + background: var(--bg-card); + border-radius: 8px; +} + +.sub-nav-btn { + background: transparent; + border: 1px solid var(--border-color); + color: var(--text-secondary); + padding: 10px 16px; + border-radius: 6px; + font-size: 13px; + cursor: pointer; + transition: all 0.2s; +} + +.sub-nav-btn:hover { + background: var(--bg-card-hover); + color: var(--text-primary); +} + +.sub-nav-btn.active { + background: var(--accent-blue); + border-color: var(--accent-blue); + color: white; +} + +/* ============================================ + SUBSTEPS + ============================================ */ + +.substep { + display: none; +} + +.substep.active { + display: block; +} + +.substep h3 { + font-size: 18px; + color: var(--text-primary); + margin-bottom: 8px; +} + +.substep-description { + color: var(--text-secondary); + font-size: 14px; + margin-bottom: 20px; +} + +/* ============================================ + INFO BOX + ============================================ */ + +.info-box { + background: rgba(59, 130, 246, 0.1); + border: 1px solid var(--accent-blue); + border-radius: 8px; + padding: 12px 16px; + font-size: 13px; + color: var(--text-primary); + margin-bottom: 16px; +} + +.info-box strong { + color: var(--accent-blue); +} + +/* ============================================ + ROUTING CARDS + ============================================ */ + +.cards.inline { + display: flex; + gap: 12px; +} + +.card.small.routing-card { + background: var(--bg-secondary); + border: 2px solid var(--border-color); + border-radius: 8px; + padding: 16px 24px; + cursor: pointer; + transition: all 0.2s; +} + +.card.small.routing-card:hover { + border-color: var(--accent-blue); +} + +.card.small.routing-card.selected { + border-color: var(--accent-cyan); + background: rgba(6, 182, 212, 0.1); +} + +.card.small.routing-card h4 { + font-size: 14px; + color: var(--text-primary); + margin: 0; +} + +/* ============================================ + RESPONSIVE + ============================================ */ + +@media (max-width: 1400px) { + .config-summary-sidebar { + width: 280px; + } +} + +@media (max-width: 1200px) { + .main-layout { + flex-direction: column; + } + + .config-summary-sidebar { + display: none; + } + + .pattern-cards { + grid-template-columns: repeat(2, 1fr); + } +} + +@media (max-width: 768px) { + .pattern-cards { + grid-template-columns: 1fr; + } + + .form-row { + grid-template-columns: 1fr; + } + + .neighbor-fields { + grid-template-columns: 1fr; + } + + .template-grid { + grid-template-columns: 1fr; + } +} diff --git a/frontend/style.css b/frontend/style.css index 175acc9..4cc2529 100644 --- a/frontend/style.css +++ b/frontend/style.css @@ -6,18 +6,162 @@ box-sizing: border-box; } +:root { + color-scheme: light; + --bg-gradient-start: #6f86f6; + --bg-gradient-end: #7a5bd6; + --text-primary: #1f2937; + --text-secondary: #6b7280; + --brand: #667eea; + --brand-strong: #5b6fe0; + --surface: #ffffff; + --surface-muted: #f8fafc; + --border: #e5e7eb; + --focus: #4f46e5; +} + body { - font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; - background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); + font-family: 'Inter', 'Segoe UI', system-ui, -apple-system, 'Helvetica Neue', Arial, sans-serif; + background: linear-gradient(135deg, var(--bg-gradient-start) 0%, var(--bg-gradient-end) 100%); min-height: 100vh; padding: 20px; + color: var(--text-primary); + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; } .container { - max-width: 1000px; + max-width: 1400px; margin: 0 auto; } +/* Main Layout with Sidebar */ +.main-layout { + display: flex; + gap: 20px; + align-items: flex-start; +} + +.wizard-container { + flex: 1; + min-width: 0; +} + +/* Configuration Summary Sidebar */ +.config-summary-sidebar { + width: 300px; + flex-shrink: 0; + background: #1e293b; + border-radius: 12px; + color: #e2e8f0; + position: sticky; + top: 20px; + max-height: calc(100vh - 40px); + overflow-y: auto; + box-shadow: 0 8px 16px rgba(0,0,0,0.2); +} + +.sidebar-header { + padding: 20px; + border-bottom: 1px solid #334155; + background: #0f172a; + border-radius: 12px 12px 0 0; +} + +.sidebar-header h3 { + font-size: 14px; + font-weight: 600; + color: #f1f5f9; + margin-bottom: 12px; +} + +.progress-indicator { + display: flex; + align-items: center; + gap: 10px; +} + +.progress-bar { + flex: 1; + height: 6px; + background: #334155; + border-radius: 3px; + overflow: hidden; +} + +.progress-fill { + height: 100%; + background: linear-gradient(90deg, #667eea 0%, #4ade80 100%); + transition: width 0.3s ease; + border-radius: 3px; +} + +.progress-text { + font-size: 12px; + font-weight: 600; + color: #4ade80; + min-width: 40px; + text-align: right; +} + +.sidebar-content { + padding: 15px; +} + +.summary-section { + margin-bottom: 16px; + padding-bottom: 16px; + border-bottom: 1px solid #334155; +} + +.summary-section:last-child { + margin-bottom: 0; + padding-bottom: 0; + border-bottom: none; +} + +.summary-section h4 { + font-size: 11px; + font-weight: 700; + color: #94a3b8; + text-transform: uppercase; + letter-spacing: 0.5px; + margin-bottom: 10px; +} + +.summary-grid { + display: grid; + grid-template-columns: auto 1fr; + gap: 6px 12px; + font-size: 12px; +} + +.summary-label { + color: #64748b; +} + +.summary-value { + color: #e2e8f0; + font-weight: 500; + text-align: right; + word-break: break-word; +} + +.summary-value.highlight { + color: #4ade80; +} + +/* Responsive: Hide sidebar on smaller screens */ +@media (max-width: 1200px) { + .config-summary-sidebar { + display: none; + } + + .container { + max-width: 1000px; + } +} + /* Header */ header { background: white; @@ -73,123 +217,93 @@ header h1 { background: #5568d3; } -/* Modal Styles */ -.modal { - display: none; - position: fixed; - z-index: 1000; - left: 0; - top: 0; - width: 100%; - height: 100%; - background-color: rgba(0,0,0,0.7); - animation: fadeIn 0.3s; +/* Validation & Success Messages */ +.message { + max-width: 1000px; + margin: 0 auto 20px; + padding: 16px 20px; + border-radius: 8px; + font-size: 14px; + font-weight: 500; + text-align: center; + animation: slideDown 0.3s ease-out; } -.modal.active { - display: flex; - align-items: center; - justify-content: center; +.error-message { + background: #fff3f3; + border: 2px solid #ff4444; + color: #cc0000; } -.modal-content { - background: #1a1a2e; - color: white; - padding: 40px; - border-radius: 16px; - max-width: 900px; - width: 90%; - max-height: 85vh; - overflow-y: auto; - box-shadow: 0 20px 60px rgba(0,0,0,0.5); +.success-message { + background: #f0fff4; + border: 2px solid #22c55e; + color: #15803d; } -.modal-header { - display: flex; - justify-content: space-between; - align-items: center; - margin-bottom: 10px; +@keyframes slideDown { + from { + opacity: 0; + transform: translateY(-20px); + } + to { + opacity: 1; + transform: translateY(0); + } } -.modal-header h2 { - color: #4da6ff; - font-size: 24px; - margin: 0; +/* Collapsible Sections */ +.collapsible { + border: 2px solid #e5e7eb; + border-radius: 8px; + overflow: hidden; } -.modal-close { - background: none; - border: none; - color: #999; - font-size: 32px; +.collapsible-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 16px; cursor: pointer; - padding: 0; - width: 40px; - height: 40px; - transition: color 0.3s; + background: #f9fafb; + transition: background 0.2s; } -.modal-close:hover { - color: white; +.collapsible-header:hover { + background: #f3f4f6; } -.modal-description { - color: #b0b0b0; - font-size: 14px; - margin-bottom: 30px; - line-height: 1.6; -} - -.template-grid { - display: grid; - grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); - gap: 20px; -} - -.template-card { - background: #252541; - border: 2px solid #3a3a5c; - border-radius: 12px; - padding: 25px; - cursor: pointer; - transition: all 0.3s; -} - -.template-card:hover { - border-color: #4da6ff; - transform: translateY(-5px); - box-shadow: 0 8px 20px rgba(77, 166, 255, 0.3); +.collapsible-header h3 { + margin: 0; + flex: 1; } -.template-card h3 { - color: white; - font-size: 18px; - margin-bottom: 12px; +.collapse-icon { + font-size: 16px; + transition: transform 0.3s; + color: #666; } -.template-card p { - color: #b0b0b0; - font-size: 13px; - line-height: 1.5; - margin-bottom: 15px; +.collapsible-header.expanded .collapse-icon { + transform: rotate(180deg); } -.template-tags { - display: flex; - flex-wrap: wrap; - gap: 8px; +.collapsible-content { + padding: 20px; + background: white; } -.tag { - background: #3a3a5c; - color: #4da6ff; - padding: 4px 12px; - border-radius: 12px; - font-size: 11px; - font-weight: 600; +.optional-badge { + font-size: 12px; + font-weight: normal; + color: #666; + background: #fef3c7; + padding: 4px 8px; + border-radius: 4px; + margin-left: 8px; } -/* Template Modal (Odin-style) */ +/* Modal Styles */ .modal { display: none; position: fixed; @@ -198,7 +312,8 @@ header h1 { top: 0; width: 100%; height: 100%; - background-color: rgba(0, 0, 0, 0.7); + background-color: rgba(17, 24, 39, 0.5); + animation: fadeIn 0.3s; } .modal.active { @@ -208,15 +323,16 @@ header h1 { } .modal-content { - background: #1a1a2e; - color: white; - padding: 40px; - border-radius: 16px; - max-width: 900px; - width: 90%; + background: var(--surface); + color: var(--text-primary); + padding: 32px; + border-radius: 18px; + max-width: 980px; + width: 92%; max-height: 85vh; overflow-y: auto; - box-shadow: 0 20px 60px rgba(0, 0, 0, 0.5); + box-shadow: 0 24px 60px rgba(17, 24, 39, 0.18); + border: 1px solid var(--border); } .modal-header { @@ -227,7 +343,7 @@ header h1 { } .modal-header h2 { - color: #4da6ff; + color: var(--brand); font-size: 24px; margin: 0; } @@ -235,23 +351,23 @@ header h1 { .modal-close { background: none; border: none; - color: #999; + color: #9aa3b2; font-size: 32px; cursor: pointer; padding: 0; width: 40px; height: 40px; - transition: color 0.3s; + transition: color 0.2s; } .modal-close:hover { - color: white; + color: var(--brand); } .modal-description { - color: #b0b0b0; + color: var(--text-secondary); font-size: 14px; - margin-bottom: 30px; + margin-bottom: 24px; line-height: 1.6; } @@ -262,31 +378,32 @@ header h1 { } .template-card { - background: #252541; - border: 2px solid #3a3a5c; - border-radius: 12px; - padding: 25px; + background: #ffffff; + border: 2px solid #e7e9f2; + border-radius: 14px; + padding: 22px; cursor: pointer; - transition: all 0.3s; + transition: all 0.25s ease; } .template-card:hover { - border-color: #4da6ff; - transform: translateY(-5px); - box-shadow: 0 8px 20px rgba(77, 166, 255, 0.3); + border-color: #667eea; + transform: translateY(-3px); + box-shadow: 0 10px 24px rgba(102, 126, 234, 0.18); } -.template-card h3 { - color: white; - font-size: 18px; - margin-bottom: 12px; +.template-card h3, +.template-card h4 { + color: #1f2937; + font-size: 16px; + margin-bottom: 10px; } .template-card p { - color: #b0b0b0; + color: #6b7280; font-size: 13px; line-height: 1.5; - margin-bottom: 15px; + margin-bottom: 14px; } .template-tags { @@ -295,6 +412,60 @@ header h1 { gap: 8px; } +.template-category { + margin: 24px 0 12px; + font-size: 14px; + font-weight: 700; + color: #4b5563; + display: flex; + align-items: center; + gap: 8px; +} + +.template-category::after { + content: ''; + flex: 1; + height: 1px; + background: #e5e7eb; +} + +button:focus-visible, +input:focus-visible, +select:focus-visible, +textarea:focus-visible, +.template-card:focus-visible { + outline: 3px solid var(--focus); + outline-offset: 2px; +} + +@media (prefers-reduced-motion: reduce) { + * { + animation: none !important; + transition: none !important; + scroll-behavior: auto !important; + } +} + +@media (prefers-contrast: more) { + :root { + --border: #c1c7d0; + --text-secondary: #374151; + } + .template-card { + border-width: 2px; + } +} + +.tag { + background: #eef2ff; + color: #4f46e5; + padding: 4px 10px; + border-radius: 999px; + font-size: 11px; + font-weight: 600; +} + + .quick-load-btn { background: #e8f5e9; color: #2e7d32; @@ -314,7 +485,7 @@ header h1 { box-shadow: 0 4px 12px rgba(76, 175, 80, 0.4); } -/* Top Navigation Bar */ +/* Top Navigation Bar (Phase-based) */ .top-nav { background: white; padding: 20px; @@ -326,7 +497,7 @@ header h1 { gap: 10px; } -.nav-step { +.nav-phase { flex: 1; display: flex; flex-direction: column; @@ -338,7 +509,7 @@ header h1 { position: relative; } -.nav-step::after { +.nav-phase::after { content: ''; position: absolute; right: -10px; @@ -351,11 +522,11 @@ header h1 { border-bottom: 8px solid transparent; } -.nav-step:last-child::after { +.nav-phase:last-child::after { display: none; } -.nav-number { +.phase-number { width: 36px; height: 36px; border-radius: 50%; @@ -370,34 +541,57 @@ header h1 { transition: all 0.3s ease; } -.nav-label { +.phase-label { font-size: 12px; color: #999; text-align: center; transition: all 0.3s ease; } -.nav-step.active .nav-number { +.sub-steps { + display: none; + margin-top: 5px; + font-size: 10px; + color: #bbb; +} + +.nav-phase.active .sub-steps { + display: flex; + gap: 5px; +} + +.sub-step { + padding: 2px 4px; + border-radius: 3px; + background: #f0f0f0; +} + +.sub-step.active { + background: #667eea; + color: white; +} + +.nav-phase.active .phase-number { background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); color: white; transform: scale(1.1); } -.nav-step.active .nav-label { +.nav-phase.active .phase-label { color: #667eea; font-weight: 600; } -.nav-step.completed .nav-number { +.nav-phase.completed .phase-number { background: #4caf50; color: white; } -.nav-step.completed .nav-label { +.nav-phase.completed .phase-label { color: #4caf50; } -.nav-step:not(.disabled):hover { +.nav-phase:not(.disabled):hover { background: #f5f5f5; } @@ -746,16 +940,77 @@ header h1 { margin-top: 15px; } -/* Neighbor Entry */ +/* BGP Neighbor Entry - Improved Layout */ .neighbor-entry { - background: white; - border: 1px solid #e0e0e0; + background: #f8fafc; + border: 1px solid #e2e8f0; border-radius: 8px; + padding: 0; + margin-bottom: 12px; + overflow: hidden; +} + +.neighbor-header { + display: flex; + justify-content: space-between; + align-items: center; + background: #f1f5f9; + padding: 10px 15px; + border-bottom: 1px solid #e2e8f0; +} + +.neighbor-title { + font-weight: 600; + font-size: 13px; + color: #475569; +} + +.btn-remove-neighbor { + background: transparent; + border: none; + color: #94a3b8; + width: 24px; + height: 24px; + border-radius: 4px; + cursor: pointer; + font-size: 14px; + transition: all 0.2s; + display: flex; + align-items: center; + justify-content: center; +} + +.btn-remove-neighbor:hover { + background: #fee2e2; + color: #dc2626; +} + +.neighbor-fields { + display: grid; + grid-template-columns: 1fr 1fr 1fr; + gap: 12px; padding: 15px; - margin-bottom: 15px; - position: relative; } +.neighbor-fields .form-group { + margin-bottom: 0; +} + +.neighbor-fields .form-group label { + font-size: 11px; + font-weight: 600; + color: #64748b; + margin-bottom: 4px; + text-transform: uppercase; + letter-spacing: 0.5px; +} + +.neighbor-fields .form-group input { + padding: 8px 10px; + font-size: 13px; +} + +/* Legacy neighbor entry remove button (kept for backward compat) */ .neighbor-entry .remove-btn { position: absolute; top: 10px; @@ -889,31 +1144,55 @@ header h1 { } .config-summary { - background: #f8f9fa; + background: var(--surface-muted); + border-radius: 16px; + padding: 24px; + border: 1px solid var(--border); +} + +.summary-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); + gap: 16px; +} + +.summary-card { + background: var(--surface); + border: 1px solid var(--border); border-radius: 12px; - padding: 25px; + padding: 16px; + box-shadow: 0 6px 16px rgba(15, 23, 42, 0.06); } -.summary-item { +.summary-card h4 { + font-size: 13px; + text-transform: uppercase; + letter-spacing: 0.08em; + color: var(--text-secondary); + margin-bottom: 12px; +} + +.summary-row { display: flex; justify-content: space-between; - padding: 12px 0; - border-bottom: 1px solid #e0e0e0; + gap: 12px; + padding: 8px 0; + border-bottom: 1px solid #eef0f6; } -.summary-item:last-child { +.summary-row:last-child { border-bottom: none; } .summary-label { - color: #666; - font-size: 14px; + color: var(--text-secondary); + font-size: 13px; } .summary-value { - color: #333; + color: var(--text-primary); font-weight: 600; - font-size: 14px; + font-size: 13px; } .preview-container { @@ -927,11 +1206,12 @@ header h1 { } #json-preview { - background: #1e1e1e; - color: #d4d4d4; + background: #f3f4f6; + color: #111827; padding: 20px; - border-radius: 8px; - font-family: 'Consolas', 'Monaco', monospace; + border-radius: 10px; + border: 1px solid #e5e7eb; + font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace; font-size: 12px; overflow-x: auto; max-height: 400px; @@ -988,3 +1268,282 @@ header h1 { width: 100%; } } + +/* ================================================================ + PATTERN-FIRST UI STYLES + ================================================================ */ + +/* Pattern Selection Cards with Images */ +.pattern-selection { + margin-bottom: 30px; +} + +.pattern-cards { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 20px; + margin-top: 15px; +} + +.pattern-card { + border: 3px solid #e0e0e0; + border-radius: 12px; + padding: 20px; + cursor: pointer; + transition: all 0.3s ease; + background: white; + text-align: center; +} + +.pattern-card img { + width: 100%; + height: 220px; + object-fit: contain; + border-radius: 8px; + margin-bottom: 15px; + cursor: zoom-in; + transition: transform 0.2s; +} + +.pattern-card img:hover { + transform: scale(1.05); +} + +.pattern-card h4 { + margin: 0 0 10px; + color: #333; + font-size: 16px; +} + +.pattern-card p { + font-size: 13px; + color: #666; + margin-bottom: 10px; + line-height: 1.4; +} + +.pattern-tag { + display: inline-block; + background: #f0f0f0; + padding: 4px 10px; + border-radius: 12px; + font-size: 11px; + color: #666; + margin-top: 5px; +} + +.pattern-card:hover { + transform: translateY(-3px); + box-shadow: 0 6px 20px rgba(0,0,0,0.15); + border-color: #667eea; +} + +.pattern-card.selected { + border-color: #667eea; + background: #f8f9ff; +} + +/* Persistent Pattern Sidebar */ +.pattern-sidebar { + position: fixed; + right: 20px; + top: 120px; + width: 280px; + background: white; + border-radius: 12px; + padding: 20px; + box-shadow: 0 4px 12px rgba(0,0,0,0.1); + z-index: 100; +} + +.sidebar-thumbnail img { + width: 100%; + height: 180px; + object-fit: contain; + border-radius: 8px; + margin-bottom: 15px; + transition: transform 0.2s; +} + +.sidebar-thumbnail img:hover { + transform: scale(1.05); +} + +.sidebar-info { + margin: 10px 0; +} + +.sidebar-info strong { + display: block; + margin-bottom: 10px; + color: #333; + font-size: 16px; +} + +.change-pattern-btn { + width: 100%; + padding: 8px; + background: #f0f0f0; + border: 1px solid #ddd; + border-radius: 6px; + cursor: pointer; + margin-top: 10px; +} + +.change-pattern-btn:hover { + background: #e0e0e0; +} + +/* Lightbox for Pattern Image */ +.lightbox { + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + background: rgba(0, 0, 0, 0.9); + display: flex; + align-items: center; + justify-content: center; + z-index: 1000; + cursor: pointer; +} + +.lightbox img { + max-width: 90%; + max-height: 90%; + border-radius: 8px; +} + +.lightbox-close { + position: absolute; + top: 20px; + right: 40px; + font-size: 40px; + color: white; + cursor: pointer; +} + +/* Hardware Selection (dropdowns) */ +.hardware-selection select { + width: 100%; + padding: 12px; + border: 2px solid #e0e0e0; + border-radius: 8px; + font-size: 14px; + background: white; +} + +.hardware-selection select:focus { + outline: none; + border-color: #667eea; +} + +/* Role Selection Cards */ +.role-selection { + margin-top: 20px; +} + +.role-cards { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); + gap: 15px; + margin-top: 15px; +} + +.role-card { + border: 3px solid #e0e0e0; + border-radius: 12px; + padding: 20px; + cursor: pointer; + transition: all 0.3s ease; + text-align: center; +} + +.role-card:hover { + transform: translateY(-3px); + box-shadow: 0 6px 20px rgba(0,0,0,0.15); + border-color: #667eea; +} + +.role-card.selected { + border-color: #667eea; + background: #f8f9ff; +} + +/* Sub-navigation for Phase 2 */ +.sub-nav { + display: flex; + gap: 10px; + margin-bottom: 20px; + background: #f5f5f5; + padding: 10px; + border-radius: 8px; +} + +.sub-nav-btn { + flex: 1; + padding: 10px; + background: white; + border: 2px solid #e0e0e0; + border-radius: 6px; + cursor: pointer; + transition: all 0.3s ease; +} + +.sub-nav-btn.active { + background: #667eea; + color: white; + border-color: #667eea; +} + +.sub-nav-btn:hover:not(.active) { + border-color: #667eea; +} + +/* Substeps */ +.substep { + display: none; +} + +.substep.active { + display: block; +} + +.substep-description { + color: #666; + margin-bottom: 20px; +} + +/* Form sections */ +.form-section { + margin-bottom: 30px; +} + +.section-help { + color: #666; + font-size: 14px; + margin-bottom: 15px; +} + +/* Template Modal Categories */ +.template-category { + color: #333; + margin: 20px 0 10px; + padding-bottom: 5px; + border-bottom: 2px solid #e0e0e0; +} + +.template-category:first-of-type { + margin-top: 0; +} + +/* Phase/Step visibility */ +.phase { + display: none; +} + +.phase.active { + display: block; +} + diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json new file mode 100644 index 0000000..91a7f8e --- /dev/null +++ b/frontend/tsconfig.json @@ -0,0 +1,27 @@ +{ + "compilerOptions": { + "target": "ES2020", + "useDefineForClassFields": true, + "module": "ESNext", + "lib": ["ES2020", "DOM", "DOM.Iterable"], + "skipLibCheck": true, + "types": ["vite/client"], + + /* Bundler mode */ + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + + /* Linting */ + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedIndexedAccess": true, + "forceConsistentCasingInFileNames": true + }, + "include": ["src/**/*", "../backend/schema/standard.json"], + "exclude": ["node_modules", "dist"] +} diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts new file mode 100644 index 0000000..6213de3 --- /dev/null +++ b/frontend/vite.config.ts @@ -0,0 +1,13 @@ +import { defineConfig } from 'vite' + +export default defineConfig({ + root: '.', + build: { + outDir: 'dist', + emptyOutDir: true, + }, + server: { + port: 3000, + open: true + } +}) diff --git a/package-lock.json b/package-lock.json index 4059aad..1c40cc3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,13 +1,13 @@ { - "name": "workspace", - "version": "1.0.0", + "name": "azure-local-network-config-tool", + "version": "2.0.0", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "workspace", - "version": "1.0.0", - "license": "ISC", + "name": "azure-local-network-config-tool", + "version": "2.0.0", + "license": "MIT", "devDependencies": { "@playwright/test": "^1.58.0", "@types/node": "^25.1.0" diff --git a/package.json b/package.json index 3736116..65912c4 100644 --- a/package.json +++ b/package.json @@ -1,12 +1,22 @@ { - "name": "workspace", - "version": "1.0.0", - "description": "- [Network Configuration Generation Tool](#network-configuration-generation-tool)\r - [Overview](#overview)\r - [Quick Navigation](#quick-navigation)\r - [Goals](#goals)\r - [Design Architecture](#design-architecture)\r - [Overall Flow](#overall-flow)\r - [Other User-Defined Input Support](#other-user-defined-input-support)\r - [Workflow Detail](#workflow-detail)\r - [Directory Structure](#directory-structure)\r - [Input \\& Output Examples](#input--output-examples)\r - [Sample Input JSON](#sample-input-json)\r - [Sample Template (Jinja2)](#sample-template-jinja2)\r - [Quick Start](#quick-start)\r - [Choose Your Path](#choose-your-path)\r - [Basic Usage](#basic-usage)\r - [Quick Examples](#quick-examples)\r - [What's Improved in This Version](#whats-improved-in-this-version)\r - [Key Enhancements](#key-enhancements)", - "main": "index.js", - "scripts": {}, - "keywords": [], + "name": "azure-local-network-config-tool", + "version": "2.0.0", + "description": "Generate vendor-specific switch configurations for Azure Local deployments", + "private": true, + "scripts": { + "dev": "npm run dev --prefix frontend", + "build": "npm run build --prefix frontend", + "preview": "npm run preview --prefix frontend", + "typecheck": "npm run typecheck --prefix frontend", + "test": "playwright test", + "test:ui": "playwright test --ui", + "test:report": "playwright show-report", + "backend:test": "cd backend && python -m pytest tests/ -v", + "backend:generate": "cd backend && python -m src.cli generate" + }, + "keywords": ["azure-local", "network", "switch", "configuration", "cisco", "dell"], "author": "", - "license": "ISC", + "license": "MIT", "devDependencies": { "@playwright/test": "^1.58.0", "@types/node": "^25.1.0" diff --git a/playwright.config.ts b/playwright.config.ts index 2bf3143..6703aeb 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -10,26 +10,41 @@ import { defineConfig, devices } from '@playwright/test'; /** * See https://playwright.dev/docs/test-configuration. + * Updated for container environment with strict timeouts to prevent hangs. */ export default defineConfig({ testDir: './tests', /* Run tests in files in parallel */ - fullyParallel: true, + fullyParallel: false, // Sequential to avoid resource contention in container /* Fail the build on CI if you accidentally left test.only in the source code. */ forbidOnly: !!process.env.CI, /* Retry on CI only */ - retries: process.env.CI ? 2 : 0, - /* Opt out of parallel tests on CI. */ - workers: process.env.CI ? 1 : undefined, + retries: 0, // No retries - fail fast + /* Single worker for container stability */ + workers: 1, /* Reporter to use. See https://playwright.dev/docs/test-reporters */ - reporter: 'html', + reporter: [['line'], ['html', { open: 'never' }]], + /* Global timeout for entire test run: 3 minutes */ + globalTimeout: 180000, + /* Per-test timeout: 30 seconds */ + timeout: 30000, + /* Expect timeout: 5 seconds */ + expect: { + timeout: 5000, + }, /* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */ use: { /* Base URL to use in actions like `await page.goto('')`. */ - // baseURL: 'http://localhost:3000', + baseURL: 'http://localhost:3002', /* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */ - trace: 'on-first-retry', + trace: 'off', // Disable trace for faster execution + + /* Action timeout: 10 seconds */ + actionTimeout: 10000, + + /* Navigation timeout: 15 seconds */ + navigationTimeout: 15000, }, /* Configure projects for major browsers */ @@ -71,9 +86,10 @@ export default defineConfig({ ], /* Run your local dev server before starting the tests */ - // webServer: { - // command: 'npm run start', - // url: 'http://localhost:3000', - // reuseExistingServer: !process.env.CI, - // }, + webServer: { + command: 'cd frontend && npm run dev -- --port 3002', + url: 'http://localhost:3002', + reuseExistingServer: true, // Always reuse to avoid port conflicts + timeout: 60000, // 60 seconds to start server + }, }); diff --git a/tests/example.spec.ts b/tests/example.spec.ts deleted file mode 100644 index 54a906a..0000000 --- a/tests/example.spec.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { test, expect } from '@playwright/test'; - -test('has title', async ({ page }) => { - await page.goto('https://playwright.dev/'); - - // Expect a title "to contain" a substring. - await expect(page).toHaveTitle(/Playwright/); -}); - -test('get started link', async ({ page }) => { - await page.goto('https://playwright.dev/'); - - // Click the get started link. - await page.getByRole('link', { name: 'Get started' }).click(); - - // Expects page to have a heading with the name of Installation. - await expect(page.getByRole('heading', { name: 'Installation' })).toBeVisible(); -}); diff --git a/frontend/examples/dell-bmc.json b/tests/fixtures/rr1-n25-r20-3248bmc-23-1/std_rr1-n25-r20-3248bmc-23-1.json similarity index 78% rename from frontend/examples/dell-bmc.json rename to tests/fixtures/rr1-n25-r20-3248bmc-23-1/std_rr1-n25-r20-3248bmc-23-1.json index 755634c..76eca7a 100644 --- a/frontend/examples/dell-bmc.json +++ b/tests/fixtures/rr1-n25-r20-3248bmc-23-1/std_rr1-n25-r20-3248bmc-23-1.json @@ -1,80 +1,75 @@ -{ - "switch": { - "vendor": "dellemc", - "model": "n3248te-on", - "firmware": "os10", - "hostname": "rr1-n25-r20-3248bmc-23-1", - "role": "BMC", - "deployment_pattern": "fully_converged" - }, - "vlans": [ - { - "vlan_id": 2, - "name": "UNUSED_VLAN", - "purpose": "parking", - "shutdown": true - }, - { - "vlan_id": 7, - "name": "Infra_7", - "purpose": "management" - }, - { - "vlan_id": 99, - "name": "NativeVlan", - "purpose": "native" - }, - { - "vlan_id": 125, - "name": "BMC_Mgmt_125", - "interface": { - "ip": "100.71.39.254", - "cidr": 26, - "mtu": 9216 - } - } - ], - "interfaces": [ - { - "name": "Unused", - "type": "Access", - "description": "Unused ports assigned to parking VLAN", - "intf_type": "Ethernet", - "start_intf": "1/1/1", - "end_intf": "1/1/54", - "access_vlan": "2", - "shutdown": true - }, - { - "name": "Host_BMC", - "type": "Access", - "description": "Host BMC Connection", - "intf_type": "Ethernet", - "start_intf": "1/1/1", - "end_intf": "1/1/40", - "access_vlan": "125", - "shutdown": false - }, - { - "name": "HLH_OS", - "type": "Access", - "description": "HLH OS Connection", - "intf_type": "Ethernet", - "start_intf": "1/1/49", - "end_intf": "1/1/50", - "access_vlan": "7", - "shutdown": false - }, - { - "name": "To_TORs", - "type": "Trunk", - "intf_type": "Ethernet", - "start_intf": "1/1/51", - "end_intf": "1/1/52", - "native_vlan": "99", - "tagged_vlans": "7,125" - } - ], - "port_channels": [], - "qos": false -} +{ + "switch": { + "make": "dellemc", + "model": "n3248te-on", + "type": "BMC", + "hostname": "rr1-n25-r20-3248bmc-23-1", + "version": "10.5.5.5", + "firmware": "os10" + }, + "vlans": [ + { + "vlan_id": 2, + "name": "UNUSED_VLAN", + "shutdown": true + }, + { + "vlan_id": 7, + "name": "Infra_7" + }, + { + "vlan_id": 99, + "name": "NativeVlan" + }, + { + "vlan_id": 125, + "name": "BMC_Mgmt_125", + "interface": { + "ip": "100.71.39.254", + "cidr": 26, + "mtu": 9216 + } + } + ], + "interfaces": [ + { + "name": "Unused", + "type": "Access", + "description": "initial unused for all interfaces then config as defined", + "intf_type": "Ethernet", + "start_intf": "1/1/1", + "end_intf": "1/1/54", + "access_vlan": "2", + "shutdown": true + }, + { + "name": "Host_BMC", + "type": "Access", + "description": "Host BMC Connection", + "intf_type": "Ethernet", + "start_intf": "1/1/1", + "end_intf": "1/1/40", + "access_vlan": "125", + "shutdown": false + }, + { + "name": "HLH_OS", + "type": "Access", + "description": "HLH OS Connection", + "intf_type": "Ethernet", + "start_intf": "1/1/49", + "end_intf": "1/1/50", + "access_vlan": "7", + "shutdown": false + }, + { + "name": "To_TORs", + "type": "Trunk", + "intf_type": "Ethernet", + "start_intf": "1/1/51", + "end_intf": "1/1/52", + "native_vlan": "99", + "tagged_vlans": "7,125" + } + ] +} \ No newline at end of file diff --git a/frontend/examples/dell-tor1.json b/tests/fixtures/rr1-n25-r20-5248hl-23-1a/std_rr1-n25-r20-5248hl-23-1a.json similarity index 84% rename from frontend/examples/dell-tor1.json rename to tests/fixtures/rr1-n25-r20-5248hl-23-1a/std_rr1-n25-r20-5248hl-23-1a.json index fa45472..16e9065 100644 --- a/frontend/examples/dell-tor1.json +++ b/tests/fixtures/rr1-n25-r20-5248hl-23-1a/std_rr1-n25-r20-5248hl-23-1a.json @@ -1,180 +1,178 @@ -{ - "switch": { - "vendor": "dellemc", - "model": "s5248f-on", - "firmware": "os10", - "hostname": "rr1-n25-r20-5248hl-23-1a", - "role": "TOR1", - "deployment_pattern": "fully_converged" - }, - "vlans": [ - { - "vlan_id": 2, - "name": "UNUSED_VLAN", - "purpose": "parking", - "shutdown": true - }, - { - "vlan_id": 7, - "name": "Infra_7", - "purpose": "management", - "interface": { - "ip": "100.68.11.2", - "cidr": 24, - "mtu": 9216, - "redundancy": { - "type": "vrrp", - "group": 7, - "priority": 150, - "virtual_ip": "100.68.11.1" - } - } - }, - { - "vlan_id": 125, - "name": "BMC_Mgmt_125", - "interface": { - "ip": "100.71.39.251", - "cidr": 26, - "mtu": 9216, - "redundancy": { - "type": "vrrp", - "group": 125, - "priority": 150, - "virtual_ip": "100.71.39.193" - } - } - } - ], - "interfaces": [ - { - "name": "Unused", - "type": "Access", - "description": "Unused ports assigned to parking VLAN", - "intf_type": "Ethernet", - "start_intf": "1/1/1", - "end_intf": "1/1/56", - "access_vlan": "2", - "shutdown": true - }, - { - "name": "Loopback0", - "type": "L3", - "intf_type": "loopback", - "intf": "loopback0", - "ipv4": "100.71.39.149/32" - }, - { - "name": "P2P_Border1", - "type": "L3", - "intf_type": "Ethernet", - "intf": "1/1/48", - "ipv4": "100.71.39.130/30" - }, - { - "name": "P2P_Border2", - "type": "L3", - "intf_type": "Ethernet", - "intf": "1/1/47", - "ipv4": "100.71.39.138/30" - }, - { - "name": "Trunk_TO_BMC_SWITCH", - "type": "Trunk", - "intf_type": "Ethernet", - "intf": "1/1/44", - "native_vlan": "99", - "tagged_vlans": "125" - }, - { - "name": "HyperConverged_To_Host", - "type": "Trunk", - "intf_type": "Ethernet", - "start_intf": "1/1/1", - "end_intf": "1/1/18", - "native_vlan": "7", - "tagged_vlans": "7", - "service_policy": { - "qos_input": "AZLOCAL-QOS-MAP" - } - } - ], - "port_channels": [ - { - "id": 50, - "description": "P2P_IBGP", - "type": "L3", - "ipv4": "100.71.39.145", - "members": ["1/1/39", "1/1/40"] - }, - { - "id": 101, - "description": "ToR_Peer_Link", - "type": "Trunk", - "native_vlan": "99", - "tagged_vlans": "", - "members": ["1/1/49", "1/1/50", "1/1/51", "1/1/52"], - "vpc_peer_link": true - } - ], - "mlag": { - "domain_id": 1, - "peer_keepalive": { - "source_ip": "100.71.85.17", - "destination_ip": "100.71.85.18", - "vrf": "default" - }, - "delay_restore": 150, - "peer_gateway": true, - "auto_recovery": true - }, - "prefix_lists": { - "DefaultRoute": [ - { - "seq": 10, - "action": "permit", - "prefix": "0.0.0.0/0" - }, - { - "seq": 50, - "action": "deny", - "prefix": "0.0.0.0/0", - "prefix_filter": "le 32" - } - ] - }, - "bgp": { - "asn": 4200003501, - "router_id": "100.71.39.149", - "networks": [ - "100.71.39.130/30", - "100.71.39.138/30", - "100.71.39.149/32" - ], - "neighbors": [ - { - "ip": "100.71.39.129", - "description": "TO_Border1", - "remote_as": 64811, - "af_ipv4_unicast": { - "prefix_list_in": "DefaultRoute" - } - }, - { - "ip": "100.71.39.137", - "description": "TO_Border2", - "remote_as": 64811, - "af_ipv4_unicast": { - "prefix_list_in": "DefaultRoute" - } - }, - { - "ip": "100.71.39.146", - "description": "iBGP_PEER", - "remote_as": 4200003501, - "af_ipv4_unicast": {} - } - ] - }, - "qos": true -} +{ + "switch": { + "make": "dellemc", + "model": "s5248f-on", + "type": "TOR1", + "hostname": "rr1-n25-r20-5248hl-23-1a", + "version": "10.5.5.5", + "firmware": "os10" + }, + "vlans": [ + { + "vlan_id": 2, + "name": "UNUSED_VLAN", + "shutdown": true + }, + { + "vlan_id": 7, + "name": "Infra_7", + "interface": { + "ip": "100.68.11.2", + "cidr": 24, + "mtu": 9216, + "redundancy": { + "type": "vrrp", + "group": 7, + "priority": 150, + "virtual_ip": "100.68.11.1" + } + } + }, + { + "vlan_id": 99, + "name": "NativeVlan" + }, + { + "vlan_id": 125, + "name": "BMC_Mgmt_125", + "interface": { + "ip": "100.71.39.251", + "cidr": 26, + "mtu": 9216, + "redundancy": { + "type": "vrrp", + "group": 125, + "priority": 150, + "virtual_ip": "100.71.39.193" + } + } + } + ], + "interfaces": [ + { + "name": "Unused", + "type": "Access", + "description": "initial unused for all interfaces then config as defined", + "intf_type": "Ethernet", + "start_intf": "1/1/1", + "end_intf": "1/1/56", + "access_vlan": "2", + "shutdown": true + }, + { + "name": "Loopback0", + "type": "L3", + "intf_type": "loopback", + "intf": "loopback0", + "ipv4": "100.71.39.149/32" + }, + { + "name": "P2P_Border1", + "type": "L3", + "intf_type": "Ethernet", + "intf": "1/1/48", + "ipv4": "100.71.39.130/30" + }, + { + "name": "P2P_Border2", + "type": "L3", + "intf_type": "Ethernet", + "intf": "1/1/47", + "ipv4": "100.71.39.138/30" + }, + { + "name": "Trunk_TO_BMC_SWITCH", + "type": "Trunk", + "intf_type": "Ethernet", + "intf": "1/1/44", + "native_vlan": "99", + "tagged_vlans": "125" + }, + { + "name": "HyperConverged_To_Host", + "type": "Trunk", + "intf_type": "Ethernet", + "start_intf": "1/1/1", + "end_intf": "1/1/18", + "native_vlan": "7", + "tagged_vlans": "7", + "service_policy": { + "qos_input": "AZLOCAL-QOS-MAP" + } + } + ], + "port_channels": [ + { + "id": 50, + "description": "P2P_IBGP", + "type": "L3", + "ipv4": "100.71.39.145", + "members": [ + "1/1/39", + "1/1/40" + ] + }, + { + "id": 101, + "description": "ToR_Peer_Link", + "type": "Trunk", + "native_vlan": "99", + "tagged_vlans": "", + "members": [ + "1/1/49", + "1/1/50", + "1/1/51", + "1/1/52" + ] + } + ], + "bgp": { + "asn": 4200003501, + "router_id": "100.71.39.149", + "networks": [ + "100.71.39.130/30", + "100.71.39.138/30", + "100.71.39.149/32" + ], + "neighbors": [ + { + "ip": "100.71.39.129", + "description": "TO_Border1", + "remote_as": 64811, + "af_ipv4_unicast": { + "prefix_list_in": "DefaultRoute" + } + }, + { + "ip": "100.71.39.137", + "description": "TO_Border2", + "remote_as": 64811, + "af_ipv4_unicast": { + "prefix_list_in": "DefaultRoute" + } + }, + { + "ip": "100.71.39.146", + "description": "iBGP_PEER", + "remote_as": 4200003501, + "af_ipv4_unicast": {} + } + ] + }, + "prefix_lists": { + "DefaultRoute": [ + { + "seq": 10, + "action": "permit", + "prefix": "0.0.0.0/0" + }, + { + "seq": 50, + "action": "deny", + "prefix": "0.0.0.0/0", + "prefix_filter": "le 32" + } + ] + }, + "qos": true +} \ No newline at end of file diff --git a/frontend/examples/dell-tor2.json b/tests/fixtures/rr1-n25-r20-5248hl-23-1b/std_rr1-n25-r20-5248hl-23-1b.json similarity index 83% rename from frontend/examples/dell-tor2.json rename to tests/fixtures/rr1-n25-r20-5248hl-23-1b/std_rr1-n25-r20-5248hl-23-1b.json index 37fe57e..2fa52d8 100644 --- a/frontend/examples/dell-tor2.json +++ b/tests/fixtures/rr1-n25-r20-5248hl-23-1b/std_rr1-n25-r20-5248hl-23-1b.json @@ -1,180 +1,178 @@ -{ - "switch": { - "vendor": "dellemc", - "model": "s5248f-on", - "firmware": "os10", - "hostname": "rr1-n25-r20-5248hl-23-1b", - "role": "TOR2", - "deployment_pattern": "fully_converged" - }, - "vlans": [ - { - "vlan_id": 2, - "name": "UNUSED_VLAN", - "purpose": "parking", - "shutdown": true - }, - { - "vlan_id": 7, - "name": "Infra_7", - "purpose": "management", - "interface": { - "ip": "100.68.11.3", - "cidr": 24, - "mtu": 9216, - "redundancy": { - "type": "vrrp", - "group": 7, - "priority": 100, - "virtual_ip": "100.68.11.1" - } - } - }, - { - "vlan_id": 125, - "name": "BMC_Mgmt_125", - "interface": { - "ip": "100.71.39.252", - "cidr": 26, - "mtu": 9216, - "redundancy": { - "type": "vrrp", - "group": 125, - "priority": 100, - "virtual_ip": "100.71.39.193" - } - } - } - ], - "interfaces": [ - { - "name": "Unused", - "type": "Access", - "description": "Unused ports assigned to parking VLAN", - "intf_type": "Ethernet", - "start_intf": "1/1/1", - "end_intf": "1/1/56", - "access_vlan": "2", - "shutdown": true - }, - { - "name": "Loopback0", - "type": "L3", - "intf_type": "loopback", - "intf": "loopback0", - "ipv4": "100.71.39.150/32" - }, - { - "name": "P2P_Border1", - "type": "L3", - "intf_type": "Ethernet", - "intf": "1/1/48", - "ipv4": "100.71.39.134/30" - }, - { - "name": "P2P_Border2", - "type": "L3", - "intf_type": "Ethernet", - "intf": "1/1/47", - "ipv4": "100.71.39.142/30" - }, - { - "name": "Trunk_TO_BMC_SWITCH", - "type": "Trunk", - "intf_type": "Ethernet", - "intf": "1/1/44", - "native_vlan": "99", - "tagged_vlans": "125" - }, - { - "name": "HyperConverged_To_Host", - "type": "Trunk", - "intf_type": "Ethernet", - "start_intf": "1/1/1", - "end_intf": "1/1/18", - "native_vlan": "7", - "tagged_vlans": "7", - "service_policy": { - "qos_input": "AZLOCAL-QOS-MAP" - } - } - ], - "port_channels": [ - { - "id": 50, - "description": "P2P_IBGP", - "type": "L3", - "ipv4": "100.71.39.146", - "members": ["1/1/39", "1/1/40"] - }, - { - "id": 101, - "description": "ToR_Peer_Link", - "type": "Trunk", - "native_vlan": "99", - "tagged_vlans": "", - "members": ["1/1/49", "1/1/50", "1/1/51", "1/1/52"], - "vpc_peer_link": true - } - ], - "mlag": { - "domain_id": 1, - "peer_keepalive": { - "source_ip": "100.71.85.18", - "destination_ip": "100.71.85.17", - "vrf": "default" - }, - "delay_restore": 150, - "peer_gateway": true, - "auto_recovery": true - }, - "prefix_lists": { - "DefaultRoute": [ - { - "seq": 10, - "action": "permit", - "prefix": "0.0.0.0/0" - }, - { - "seq": 50, - "action": "deny", - "prefix": "0.0.0.0/0", - "prefix_filter": "le 32" - } - ] - }, - "bgp": { - "asn": 4200003501, - "router_id": "100.71.39.150", - "networks": [ - "100.71.39.134/30", - "100.71.39.142/30", - "100.71.39.150/32" - ], - "neighbors": [ - { - "ip": "100.71.39.133", - "description": "TO_Border1", - "remote_as": 64811, - "af_ipv4_unicast": { - "prefix_list_in": "DefaultRoute" - } - }, - { - "ip": "100.71.39.141", - "description": "TO_Border2", - "remote_as": 64811, - "af_ipv4_unicast": { - "prefix_list_in": "DefaultRoute" - } - }, - { - "ip": "100.71.39.145", - "description": "iBGP_PEER", - "remote_as": 4200003501, - "af_ipv4_unicast": {} - } - ] - }, - "qos": true -} +{ + "switch": { + "make": "dellemc", + "model": "s5248f-on", + "type": "TOR2", + "hostname": "rr1-n25-r20-5248hl-23-1b", + "version": "10.5.5.5", + "firmware": "os10" + }, + "vlans": [ + { + "vlan_id": 2, + "name": "UNUSED_VLAN", + "shutdown": true + }, + { + "vlan_id": 7, + "name": "Infra_7", + "interface": { + "ip": "100.68.11.3", + "cidr": 24, + "mtu": 9216, + "redundancy": { + "type": "vrrp", + "group": 7, + "priority": 140, + "virtual_ip": "100.68.11.1" + } + } + }, + { + "vlan_id": 99, + "name": "NativeVlan" + }, + { + "vlan_id": 125, + "name": "BMC_Mgmt_125", + "interface": { + "ip": "100.71.39.252", + "cidr": 26, + "mtu": 9216, + "redundancy": { + "type": "vrrp", + "group": 125, + "priority": 140, + "virtual_ip": "100.71.39.193" + } + } + } + ], + "interfaces": [ + { + "name": "Unused", + "type": "Access", + "description": "initial unused for all interfaces then config as defined", + "intf_type": "Ethernet", + "start_intf": "1/1/1", + "end_intf": "1/1/56", + "access_vlan": "2", + "shutdown": true + }, + { + "name": "Loopback0", + "type": "L3", + "intf_type": "loopback", + "intf": "loopback0", + "ipv4": "100.71.39.150/32" + }, + { + "name": "P2P_Border1", + "type": "L3", + "intf_type": "Ethernet", + "intf": "1/1/48", + "ipv4": "100.71.39.134/30" + }, + { + "name": "P2P_Border2", + "type": "L3", + "intf_type": "Ethernet", + "intf": "1/1/47", + "ipv4": "100.71.39.142/30" + }, + { + "name": "Trunk_TO_BMC_SWITCH", + "type": "Trunk", + "intf_type": "Ethernet", + "intf": "1/1/44", + "native_vlan": "99", + "tagged_vlans": "125" + }, + { + "name": "HyperConverged_To_Host", + "type": "Trunk", + "intf_type": "Ethernet", + "start_intf": "1/1/1", + "end_intf": "1/1/18", + "native_vlan": "7", + "tagged_vlans": "7", + "service_policy": { + "qos_input": "AZLOCAL-QOS-MAP" + } + } + ], + "port_channels": [ + { + "id": 50, + "description": "P2P_IBGP", + "type": "L3", + "ipv4": "100.71.39.146", + "members": [ + "1/1/39", + "1/1/40" + ] + }, + { + "id": 101, + "description": "ToR_Peer_Link", + "type": "Trunk", + "native_vlan": "99", + "tagged_vlans": "", + "members": [ + "1/1/49", + "1/1/50", + "1/1/51", + "1/1/52" + ] + } + ], + "bgp": { + "asn": 4200003501, + "router_id": "100.71.39.150", + "networks": [ + "100.71.39.134/30", + "100.71.39.142/30", + "100.71.39.150/32" + ], + "neighbors": [ + { + "ip": "100.71.39.133", + "description": "TO_Border1", + "remote_as": 64811, + "af_ipv4_unicast": { + "prefix_list_in": "DefaultRoute" + } + }, + { + "ip": "100.71.39.141", + "description": "TO_Border2", + "remote_as": 64811, + "af_ipv4_unicast": { + "prefix_list_in": "DefaultRoute" + } + }, + { + "ip": "100.71.39.145", + "description": "iBGP_PEER", + "remote_as": 4200003501, + "af_ipv4_unicast": {} + } + ] + }, + "prefix_lists": { + "DefaultRoute": [ + { + "seq": 10, + "action": "permit", + "prefix": "0.0.0.0/0" + }, + { + "seq": 50, + "action": "deny", + "prefix": "0.0.0.0/0", + "prefix_filter": "le 32" + } + ] + }, + "qos": true +} \ No newline at end of file diff --git a/tests/fixtures/rr1n25r20-hc40-definition.json b/tests/fixtures/rr1n25r20-hc40-definition.json new file mode 100644 index 0000000..e3ddf7c --- /dev/null +++ b/tests/fixtures/rr1n25r20-hc40-definition.json @@ -0,0 +1,1558 @@ +{ + "Version": "1.0.0", + "Description": "Template for ASZ Rack", + "InputData": { + "MainEnvData": [ + { + "Id": "Env01", + "Site": "rr1", + "RackName": "n25r20", + "NodeCount": 40, + "ConnectType": "connected", + "CiEnabled": "yes", + "ClusterUnits": [ + { + "Name": "Cl01", + "NodeCount": 1, + "RackId": "Rack01", + "NamingPrefix": "n25r2001", + "PhysicalNamingPrefix": "n25r2001", + "Topology": "HyperConverged", + "CompanyName": "Microsoft", + "RegionName": "Redmond", + "DomainFQDN": "n25r2001.masd.stbtest.microsoft.com", + "ExternalDomainFQDN": "ext-n25r2001.masd.stbtest.microsoft.com", + "InfraAzureEnvironment": "AzureCloud", + "InfraAzureDirectoryTenantName": "msazurestack.microsoft.com" + }, + { + "Name": "Cl02", + "NodeCount": 1, + "RackId": "Rack01", + "NamingPrefix": "n25r2002", + "PhysicalNamingPrefix": "n25r2002", + "Topology": "HyperConverged", + "CompanyName": "Microsoft", + "RegionName": "Redmond", + "DomainFQDN": "n25r2002.masd.stbtest.microsoft.com", + "ExternalDomainFQDN": "ext-n25r2002.masd.stbtest.microsoft.com", + "InfraAzureEnvironment": "AzureCloud", + "InfraAzureDirectoryTenantName": "msazurestack.microsoft.com" + }, + { + "Name": "Cl03", + "NodeCount": 1, + "RackId": "Rack01", + "NamingPrefix": "n25r2003", + "PhysicalNamingPrefix": "n25r2003", + "Topology": "HyperConverged", + "CompanyName": "Microsoft", + "RegionName": "Redmond", + "DomainFQDN": "n25r2003.masd.stbtest.microsoft.com", + "ExternalDomainFQDN": "ext-n25r2003.masd.stbtest.microsoft.com", + "InfraAzureEnvironment": "AzureCloud", + "InfraAzureDirectoryTenantName": "msazurestack.microsoft.com" + }, + { + "Name": "Cl04", + "NodeCount": 1, + "RackId": "Rack01", + "NamingPrefix": "n25r2004", + "PhysicalNamingPrefix": "n25r2004", + "Topology": "HyperConverged", + "CompanyName": "Microsoft", + "RegionName": "Redmond", + "DomainFQDN": "n25r2004.masd.stbtest.microsoft.com", + "ExternalDomainFQDN": "ext-n25r2004.masd.stbtest.microsoft.com", + "InfraAzureEnvironment": "AzureCloud", + "InfraAzureDirectoryTenantName": "msazurestack.microsoft.com" + }, + { + "Name": "Cl05", + "NodeCount": 1, + "RackId": "Rack01", + "NamingPrefix": "n25r2005", + "PhysicalNamingPrefix": "n25r2005", + "Topology": "HyperConverged", + "CompanyName": "Microsoft", + "RegionName": "Redmond", + "DomainFQDN": "n25r2005.masd.stbtest.microsoft.com", + "ExternalDomainFQDN": "ext-n25r2005.masd.stbtest.microsoft.com", + "InfraAzureEnvironment": "AzureCloud", + "InfraAzureDirectoryTenantName": "msazurestack.microsoft.com" + }, + { + "Name": "Cl06", + "NodeCount": 1, + "RackId": "Rack01", + "NamingPrefix": "n25r2006", + "PhysicalNamingPrefix": "n25r2006", + "Topology": "HyperConverged", + "CompanyName": "Microsoft", + "RegionName": "Redmond", + "DomainFQDN": "n25r2006.masd.stbtest.microsoft.com", + "ExternalDomainFQDN": "ext-n25r2006.masd.stbtest.microsoft.com", + "InfraAzureEnvironment": "AzureCloud", + "InfraAzureDirectoryTenantName": "msazurestack.microsoft.com" + }, + { + "Name": "Cl07", + "NodeCount": 1, + "RackId": "Rack01", + "NamingPrefix": "n25r2007", + "PhysicalNamingPrefix": "n25r2007", + "Topology": "HyperConverged", + "CompanyName": "Microsoft", + "RegionName": "Redmond", + "DomainFQDN": "n25r2007.masd.stbtest.microsoft.com", + "ExternalDomainFQDN": "ext-n25r2007.masd.stbtest.microsoft.com", + "InfraAzureEnvironment": "AzureCloud", + "InfraAzureDirectoryTenantName": "msazurestack.microsoft.com" + }, + { + "Name": "Cl08", + "NodeCount": 1, + "RackId": "Rack01", + "NamingPrefix": "n25r2008", + "PhysicalNamingPrefix": "n25r2008", + "Topology": "HyperConverged", + "CompanyName": "Microsoft", + "RegionName": "Redmond", + "DomainFQDN": "n25r2008.masd.stbtest.microsoft.com", + "ExternalDomainFQDN": "ext-n25r2008.masd.stbtest.microsoft.com", + "InfraAzureEnvironment": "AzureCloud", + "InfraAzureDirectoryTenantName": "msazurestack.microsoft.com" + }, + { + "Name": "Cl09", + "NodeCount": 1, + "RackId": "Rack01", + "NamingPrefix": "n25r2009", + "PhysicalNamingPrefix": "n25r2009", + "Topology": "HyperConverged", + "CompanyName": "Microsoft", + "RegionName": "Redmond", + "DomainFQDN": "n25r2009.masd.stbtest.microsoft.com", + "ExternalDomainFQDN": "ext-n25r2009.masd.stbtest.microsoft.com", + "InfraAzureEnvironment": "AzureCloud", + "InfraAzureDirectoryTenantName": "msazurestack.microsoft.com" + }, + { + "Name": "Cl10", + "NodeCount": 1, + "RackId": "Rack01", + "NamingPrefix": "n25r2010", + "PhysicalNamingPrefix": "n25r2010", + "Topology": "HyperConverged", + "CompanyName": "Microsoft", + "RegionName": "Redmond", + "DomainFQDN": "n25r2010.masd.stbtest.microsoft.com", + "ExternalDomainFQDN": "ext-n25r2010.masd.stbtest.microsoft.com", + "InfraAzureEnvironment": "AzureCloud", + "InfraAzureDirectoryTenantName": "msazurestack.microsoft.com" + }, + { + "Name": "Cl11", + "NodeCount": 1, + "RackId": "Rack01", + "NamingPrefix": "n25r2011", + "PhysicalNamingPrefix": "n25r2011", + "Topology": "HyperConverged", + "CompanyName": "Microsoft", + "RegionName": "Redmond", + "DomainFQDN": "n25r2011.masd.stbtest.microsoft.com", + "ExternalDomainFQDN": "ext-n25r2011.masd.stbtest.microsoft.com", + "InfraAzureEnvironment": "AzureCloud", + "InfraAzureDirectoryTenantName": "msazurestack.microsoft.com" + }, + { + "Name": "Cl12", + "NodeCount": 1, + "RackId": "Rack01", + "NamingPrefix": "n25r2012", + "PhysicalNamingPrefix": "n25r2012", + "Topology": "HyperConverged", + "CompanyName": "Microsoft", + "RegionName": "Redmond", + "DomainFQDN": "n25r2012.masd.stbtest.microsoft.com", + "ExternalDomainFQDN": "ext-n25r2012.masd.stbtest.microsoft.com", + "InfraAzureEnvironment": "AzureCloud", + "InfraAzureDirectoryTenantName": "msazurestack.microsoft.com" + }, + { + "Name": "Cl13", + "NodeCount": 1, + "RackId": "Rack01", + "NamingPrefix": "n25r2013", + "PhysicalNamingPrefix": "n25r2013", + "Topology": "HyperConverged", + "CompanyName": "Microsoft", + "RegionName": "Redmond", + "DomainFQDN": "n25r2013.masd.stbtest.microsoft.com", + "ExternalDomainFQDN": "ext-n25r2013.masd.stbtest.microsoft.com", + "InfraAzureEnvironment": "AzureCloud", + "InfraAzureDirectoryTenantName": "msazurestack.microsoft.com" + }, + { + "Name": "Cl14", + "NodeCount": 1, + "RackId": "Rack01", + "NamingPrefix": "n25r2014", + "PhysicalNamingPrefix": "n25r2014", + "Topology": "HyperConverged", + "CompanyName": "Microsoft", + "RegionName": "Redmond", + "DomainFQDN": "n25r2014.masd.stbtest.microsoft.com", + "ExternalDomainFQDN": "ext-n25r2014.masd.stbtest.microsoft.com", + "InfraAzureEnvironment": "AzureCloud", + "InfraAzureDirectoryTenantName": "msazurestack.microsoft.com" + }, + { + "Name": "Cl15", + "NodeCount": 1, + "RackId": "Rack01", + "NamingPrefix": "n25r2015", + "PhysicalNamingPrefix": "n25r2015", + "Topology": "HyperConverged", + "CompanyName": "Microsoft", + "RegionName": "Redmond", + "DomainFQDN": "n25r2015.masd.stbtest.microsoft.com", + "ExternalDomainFQDN": "ext-n25r2015.masd.stbtest.microsoft.com", + "InfraAzureEnvironment": "AzureCloud", + "InfraAzureDirectoryTenantName": "msazurestack.microsoft.com" + }, + { + "Name": "Cl16", + "NodeCount": 1, + "RackId": "Rack01", + "NamingPrefix": "n25r2016", + "PhysicalNamingPrefix": "n25r2016", + "Topology": "HyperConverged", + "CompanyName": "Microsoft", + "RegionName": "Redmond", + "DomainFQDN": "n25r2016.masd.stbtest.microsoft.com", + "ExternalDomainFQDN": "ext-n25r2016.masd.stbtest.microsoft.com", + "InfraAzureEnvironment": "AzureCloud", + "InfraAzureDirectoryTenantName": "msazurestack.microsoft.com" + }, + { + "Name": "Cl17", + "NodeCount": 1, + "RackId": "Rack01", + "NamingPrefix": "n25r2017", + "PhysicalNamingPrefix": "n25r2017", + "Topology": "HyperConverged", + "CompanyName": "Microsoft", + "RegionName": "Redmond", + "DomainFQDN": "n25r2017.masd.stbtest.microsoft.com", + "ExternalDomainFQDN": "ext-n25r2017.masd.stbtest.microsoft.com", + "InfraAzureEnvironment": "AzureCloud", + "InfraAzureDirectoryTenantName": "msazurestack.microsoft.com" + }, + { + "Name": "Cl18", + "NodeCount": 1, + "RackId": "Rack01", + "NamingPrefix": "n25r2018", + "PhysicalNamingPrefix": "n25r2018", + "Topology": "HyperConverged", + "CompanyName": "Microsoft", + "RegionName": "Redmond", + "DomainFQDN": "n25r2018.masd.stbtest.microsoft.com", + "ExternalDomainFQDN": "ext-n25r2018.masd.stbtest.microsoft.com", + "InfraAzureEnvironment": "AzureCloud", + "InfraAzureDirectoryTenantName": "msazurestack.microsoft.com" + }, + { + "Name": "Cl19", + "NodeCount": 1, + "RackId": "Rack01", + "NamingPrefix": "n25r2019", + "PhysicalNamingPrefix": "n25r2019", + "Topology": "HyperConverged", + "CompanyName": "Microsoft", + "RegionName": "Redmond", + "DomainFQDN": "n25r2019.masd.stbtest.microsoft.com", + "ExternalDomainFQDN": "ext-n25r2019.masd.stbtest.microsoft.com", + "InfraAzureEnvironment": "AzureCloud", + "InfraAzureDirectoryTenantName": "msazurestack.microsoft.com" + }, + { + "Name": "Cl20", + "NodeCount": 1, + "RackId": "Rack01", + "NamingPrefix": "n25r2020", + "PhysicalNamingPrefix": "n25r2020", + "Topology": "HyperConverged", + "CompanyName": "Microsoft", + "RegionName": "Redmond", + "DomainFQDN": "n25r2020.masd.stbtest.microsoft.com", + "ExternalDomainFQDN": "ext-n25r2020.masd.stbtest.microsoft.com", + "InfraAzureEnvironment": "AzureCloud", + "InfraAzureDirectoryTenantName": "msazurestack.microsoft.com" + }, + { + "Name": "Cl21", + "NodeCount": 1, + "RackId": "Rack01", + "NamingPrefix": "n25r2021", + "PhysicalNamingPrefix": "n25r2021", + "Topology": "HyperConverged", + "CompanyName": "Microsoft", + "RegionName": "Redmond", + "DomainFQDN": "n25r2021.masd.stbtest.microsoft.com", + "ExternalDomainFQDN": "ext-n25r2021.masd.stbtest.microsoft.com", + "InfraAzureEnvironment": "AzureCloud", + "InfraAzureDirectoryTenantName": "msazurestack.microsoft.com" + }, + { + "Name": "Cl22", + "NodeCount": 1, + "RackId": "Rack01", + "NamingPrefix": "n25r2022", + "PhysicalNamingPrefix": "n25r2022", + "Topology": "HyperConverged", + "CompanyName": "Microsoft", + "RegionName": "Redmond", + "DomainFQDN": "n25r2022.masd.stbtest.microsoft.com", + "ExternalDomainFQDN": "ext-n25r2022.masd.stbtest.microsoft.com", + "InfraAzureEnvironment": "AzureCloud", + "InfraAzureDirectoryTenantName": "msazurestack.microsoft.com" + }, + { + "Name": "Cl23", + "NodeCount": 1, + "RackId": "Rack01", + "NamingPrefix": "n25r2023", + "PhysicalNamingPrefix": "n25r2023", + "Topology": "HyperConverged", + "CompanyName": "Microsoft", + "RegionName": "Redmond", + "DomainFQDN": "n25r2023.masd.stbtest.microsoft.com", + "ExternalDomainFQDN": "ext-n25r2023.masd.stbtest.microsoft.com", + "InfraAzureEnvironment": "AzureCloud", + "InfraAzureDirectoryTenantName": "msazurestack.microsoft.com" + }, + { + "Name": "Cl24", + "NodeCount": 1, + "RackId": "Rack01", + "NamingPrefix": "n25r2024", + "PhysicalNamingPrefix": "n25r2024", + "Topology": "HyperConverged", + "CompanyName": "Microsoft", + "RegionName": "Redmond", + "DomainFQDN": "n25r2024.masd.stbtest.microsoft.com", + "ExternalDomainFQDN": "ext-n25r2024.masd.stbtest.microsoft.com", + "InfraAzureEnvironment": "AzureCloud", + "InfraAzureDirectoryTenantName": "msazurestack.microsoft.com" + }, + { + "Name": "Cl25", + "NodeCount": 1, + "RackId": "Rack01", + "NamingPrefix": "n25r2025", + "PhysicalNamingPrefix": "n25r2025", + "Topology": "HyperConverged", + "CompanyName": "Microsoft", + "RegionName": "Redmond", + "DomainFQDN": "n25r2025.masd.stbtest.microsoft.com", + "ExternalDomainFQDN": "ext-n25r2025.masd.stbtest.microsoft.com", + "InfraAzureEnvironment": "AzureCloud", + "InfraAzureDirectoryTenantName": "msazurestack.microsoft.com" + }, + { + "Name": "Cl26", + "NodeCount": 1, + "RackId": "Rack01", + "NamingPrefix": "n25r2026", + "PhysicalNamingPrefix": "n25r2026", + "Topology": "HyperConverged", + "CompanyName": "Microsoft", + "RegionName": "Redmond", + "DomainFQDN": "n25r2026.masd.stbtest.microsoft.com", + "ExternalDomainFQDN": "ext-n25r2026.masd.stbtest.microsoft.com", + "InfraAzureEnvironment": "AzureCloud", + "InfraAzureDirectoryTenantName": "msazurestack.microsoft.com" + }, + { + "Name": "Cl27", + "NodeCount": 1, + "RackId": "Rack01", + "NamingPrefix": "n25r2027", + "PhysicalNamingPrefix": "n25r2027", + "Topology": "HyperConverged", + "CompanyName": "Microsoft", + "RegionName": "Redmond", + "DomainFQDN": "n25r2027.masd.stbtest.microsoft.com", + "ExternalDomainFQDN": "ext-n25r2027.masd.stbtest.microsoft.com", + "InfraAzureEnvironment": "AzureCloud", + "InfraAzureDirectoryTenantName": "msazurestack.microsoft.com" + }, + { + "Name": "Cl28", + "NodeCount": 1, + "RackId": "Rack01", + "NamingPrefix": "n25r2028", + "PhysicalNamingPrefix": "n25r2028", + "Topology": "HyperConverged", + "CompanyName": "Microsoft", + "RegionName": "Redmond", + "DomainFQDN": "n25r2028.masd.stbtest.microsoft.com", + "ExternalDomainFQDN": "ext-n25r2028.masd.stbtest.microsoft.com", + "InfraAzureEnvironment": "AzureCloud", + "InfraAzureDirectoryTenantName": "msazurestack.microsoft.com" + }, + { + "Name": "Cl29", + "NodeCount": 1, + "RackId": "Rack01", + "NamingPrefix": "n25r2029", + "PhysicalNamingPrefix": "n25r2029", + "Topology": "HyperConverged", + "CompanyName": "Microsoft", + "RegionName": "Redmond", + "DomainFQDN": "n25r2029.masd.stbtest.microsoft.com", + "ExternalDomainFQDN": "ext-n25r2029.masd.stbtest.microsoft.com", + "InfraAzureEnvironment": "AzureCloud", + "InfraAzureDirectoryTenantName": "msazurestack.microsoft.com" + }, + { + "Name": "Cl30", + "NodeCount": 1, + "RackId": "Rack01", + "NamingPrefix": "n25r2030", + "PhysicalNamingPrefix": "n25r2030", + "Topology": "HyperConverged", + "CompanyName": "Microsoft", + "RegionName": "Redmond", + "DomainFQDN": "n25r2030.masd.stbtest.microsoft.com", + "ExternalDomainFQDN": "ext-n25r2030.masd.stbtest.microsoft.com", + "InfraAzureEnvironment": "AzureCloud", + "InfraAzureDirectoryTenantName": "msazurestack.microsoft.com" + }, + { + "Name": "Cl31", + "NodeCount": 1, + "RackId": "Rack01", + "NamingPrefix": "n25r2031", + "PhysicalNamingPrefix": "n25r2031", + "Topology": "HyperConverged", + "CompanyName": "Microsoft", + "RegionName": "Redmond", + "DomainFQDN": "n25r2031.masd.stbtest.microsoft.com", + "ExternalDomainFQDN": "ext-n25r2031.masd.stbtest.microsoft.com", + "InfraAzureEnvironment": "AzureCloud", + "InfraAzureDirectoryTenantName": "msazurestack.microsoft.com" + }, + { + "Name": "Cl32", + "NodeCount": 1, + "RackId": "Rack01", + "NamingPrefix": "n25r2032", + "PhysicalNamingPrefix": "n25r2032", + "Topology": "HyperConverged", + "CompanyName": "Microsoft", + "RegionName": "Redmond", + "DomainFQDN": "n25r2032.masd.stbtest.microsoft.com", + "ExternalDomainFQDN": "ext-n25r2032.masd.stbtest.microsoft.com", + "InfraAzureEnvironment": "AzureCloud", + "InfraAzureDirectoryTenantName": "msazurestack.microsoft.com" + }, + { + "Name": "Cl33", + "NodeCount": 1, + "RackId": "Rack01", + "NamingPrefix": "n25r2033", + "PhysicalNamingPrefix": "n25r2033", + "Topology": "HyperConverged", + "CompanyName": "Microsoft", + "RegionName": "Redmond", + "DomainFQDN": "n25r2033.masd.stbtest.microsoft.com", + "ExternalDomainFQDN": "ext-n25r2033.masd.stbtest.microsoft.com", + "InfraAzureEnvironment": "AzureCloud", + "InfraAzureDirectoryTenantName": "msazurestack.microsoft.com" + }, + { + "Name": "Cl34", + "NodeCount": 1, + "RackId": "Rack01", + "NamingPrefix": "n25r2034", + "PhysicalNamingPrefix": "n25r2034", + "Topology": "HyperConverged", + "CompanyName": "Microsoft", + "RegionName": "Redmond", + "DomainFQDN": "n25r2034.masd.stbtest.microsoft.com", + "ExternalDomainFQDN": "ext-n25r2034.masd.stbtest.microsoft.com", + "InfraAzureEnvironment": "AzureCloud", + "InfraAzureDirectoryTenantName": "msazurestack.microsoft.com" + }, + { + "Name": "Cl35", + "NodeCount": 1, + "RackId": "Rack01", + "NamingPrefix": "n25r2035", + "PhysicalNamingPrefix": "n25r2035", + "Topology": "HyperConverged", + "CompanyName": "Microsoft", + "RegionName": "Redmond", + "DomainFQDN": "n25r2035.masd.stbtest.microsoft.com", + "ExternalDomainFQDN": "ext-n25r2035.masd.stbtest.microsoft.com", + "InfraAzureEnvironment": "AzureCloud", + "InfraAzureDirectoryTenantName": "msazurestack.microsoft.com" + }, + { + "Name": "Cl36", + "NodeCount": 1, + "RackId": "Rack01", + "NamingPrefix": "n25r2036", + "PhysicalNamingPrefix": "n25r2036", + "Topology": "HyperConverged", + "CompanyName": "Microsoft", + "RegionName": "Redmond", + "DomainFQDN": "n25r2036.masd.stbtest.microsoft.com", + "ExternalDomainFQDN": "ext-n25r2036.masd.stbtest.microsoft.com", + "InfraAzureEnvironment": "AzureCloud", + "InfraAzureDirectoryTenantName": "msazurestack.microsoft.com" + }, + { + "Name": "Cl37", + "NodeCount": 1, + "RackId": "Rack01", + "NamingPrefix": "n25r2037", + "PhysicalNamingPrefix": "n25r2037", + "Topology": "HyperConverged", + "CompanyName": "Microsoft", + "RegionName": "Redmond", + "DomainFQDN": "n25r2037.masd.stbtest.microsoft.com", + "ExternalDomainFQDN": "ext-n25r2037.masd.stbtest.microsoft.com", + "InfraAzureEnvironment": "AzureCloud", + "InfraAzureDirectoryTenantName": "msazurestack.microsoft.com" + }, + { + "Name": "Cl38", + "NodeCount": 1, + "RackId": "Rack01", + "NamingPrefix": "n25r2038", + "PhysicalNamingPrefix": "n25r2038", + "Topology": "HyperConverged", + "CompanyName": "Microsoft", + "RegionName": "Redmond", + "DomainFQDN": "n25r2038.masd.stbtest.microsoft.com", + "ExternalDomainFQDN": "ext-n25r2038.masd.stbtest.microsoft.com", + "InfraAzureEnvironment": "AzureCloud", + "InfraAzureDirectoryTenantName": "msazurestack.microsoft.com" + }, + { + "Name": "Cl39", + "NodeCount": 1, + "RackId": "Rack01", + "NamingPrefix": "n25r2039", + "PhysicalNamingPrefix": "n25r2039", + "Topology": "HyperConverged", + "CompanyName": "Microsoft", + "RegionName": "Redmond", + "DomainFQDN": "n25r2039.masd.stbtest.microsoft.com", + "ExternalDomainFQDN": "ext-n25r2039.masd.stbtest.microsoft.com", + "InfraAzureEnvironment": "AzureCloud", + "InfraAzureDirectoryTenantName": "msazurestack.microsoft.com" + }, + { + "Name": "Cl40", + "NodeCount": 1, + "RackId": "Rack01", + "NamingPrefix": "n25r2040", + "PhysicalNamingPrefix": "n25r2040", + "Topology": "HyperConverged", + "CompanyName": "Microsoft", + "RegionName": "Redmond", + "DomainFQDN": "n25r2040.masd.stbtest.microsoft.com", + "ExternalDomainFQDN": "ext-n25r2040.masd.stbtest.microsoft.com", + "InfraAzureEnvironment": "AzureCloud", + "InfraAzureDirectoryTenantName": "msazurestack.microsoft.com" + } + ] + } + ], + "Switches": [ + { + "Make": "Cisco", + "Model": "C9336C-FX2", + "Hostname": "rr1-n25-r12-9336ssp-1a", + "Type": "Border1", + "ASN": 64811 + }, + { + "Make": "Cisco", + "Model": "C9336C-FX2", + "Hostname": "rr1-n25-r12-9336ssp-1b", + "Type": "Border2", + "ASN": 64811 + }, + { + "Make": "DellEMC", + "Model": "S5248F-ON", + "Type": "TOR1", + "Hostname": "rr1-n25-r20-5248hl-23-1a", + "ASN": 4200003501, + "Firmware": "10.5.5.5" + }, + { + "Make": "DellEMC", + "Model": "S5248F-ON", + "Type": "TOR2", + "Hostname": "rr1-n25-r20-5248hl-23-1b", + "ASN": 4200003501, + "Firmware": "10.5.5.5" + }, + { + "Make": "DellEMC", + "Model": "N3248TE-ON", + "Type": "BMC", + "Hostname": "rr1-n25-r20-3248bmc-23-1", + "ASN": null, + "Firmware": "10.5.5.5" + } + ], + "DeploymentPattern": "HyperConverged", + "SwitchUplink": "BGP", + "HostConnectivity": "BGP", + "Supernets": [ + { + "GroupName": "P2P_Border", + "Name": "P2P_Border1_Tor1", + "VlanId": 0, + "Description": "Uplink to Border1, P2P_{$Type}_{$Type}", + "IPv4": { + "Name": "P2P_Border1_Tor1", + "VlanId": 0, + "Cidr": 30, + "TORGroup": "", + "NetworkType": "P2P Link", + "Subnet": "100.71.39.128/30", + "Network": "100.71.39.128", + "Netmask": "255.255.255.252", + "Gateway": "", + "BroadcastAddress": "100.71.39.131", + "FirstAddress": "100.71.39.129", + "LastAddress": "100.71.39.130", + "Assignment": [ + { + "Name": "Network", + "IP": "100.71.39.128" + }, + { + "Name": "Border1", + "IP": "100.71.39.129" + }, + { + "Name": "TOR1", + "IP": "100.71.39.130" + } + ] + }, + "IPv6": {} + }, + { + "GroupName": "P2P_Border", + "Name": "P2P_Border1_Tor2", + "VlanId": 0, + "Description": "Uplink to Border2, P2P_{$Type}_{$Type}", + "IPv4": { + "Name": "P2P_Border1_Tor2", + "VlanId": 0, + "Cidr": 30, + "TORGroup": "", + "NetworkType": "P2P Link", + "Subnet": "100.71.39.132/30", + "Network": "100.71.39.132", + "Netmask": "255.255.255.252", + "Gateway": "", + "BroadcastAddress": "100.71.39.135", + "FirstAddress": "100.71.39.133", + "LastAddress": "100.71.39.134", + "Assignment": [ + { + "Name": "Network", + "IP": "100.71.39.132" + }, + { + "Name": "Border1", + "IP": "100.71.39.133" + }, + { + "Name": "TOR2", + "IP": "100.71.39.134" + } + ] + }, + "IPv6": {} + }, + { + "GroupName": "P2P_Border", + "Name": "P2P_Border2_Tor1", + "VlanId": 0, + "Description": "Uplink to Border2, naming convention P2P_{$Type}_{$Type}", + "IPv4": { + "Name": "P2P_Border2_Tor1", + "VlanId": 0, + "Cidr": 30, + "TORGroup": "", + "NetworkType": "P2P Link", + "Subnet": "100.71.39.136/30", + "Network": "100.71.39.136", + "Netmask": "255.255.255.252", + "Gateway": "", + "BroadcastAddress": "100.71.39.139", + "FirstAddress": "100.71.39.137", + "LastAddress": "100.71.39.138", + "Assignment": [ + { + "Name": "Network", + "IP": "100.71.39.136" + }, + { + "Name": "Border2", + "IP": "100.71.39.137" + }, + { + "Name": "TOR1", + "IP": "100.71.39.138" + } + ] + }, + "IPv6": {} + }, + { + "GroupName": "P2P_Border", + "Name": "P2P_Border2_Tor2", + "VlanId": 0, + "Description": "Uplink to Border2, naming convention P2P_{$Type}_{$Type}", + "IPv4": { + "Name": "P2P_Border2_Tor2", + "VlanId": 0, + "Cidr": 30, + "TORGroup": "", + "NetworkType": "P2P Link", + "Subnet": "100.71.39.140/30", + "Network": "100.71.39.140", + "Netmask": "255.255.255.252", + "Gateway": "", + "BroadcastAddress": "100.71.39.143", + "FirstAddress": "100.71.39.141", + "LastAddress": "100.71.39.142", + "Assignment": [ + { + "Name": "Network", + "IP": "100.71.39.140" + }, + { + "Name": "Border2", + "IP": "100.71.39.141" + }, + { + "Name": "TOR2", + "IP": "100.71.39.142" + } + ] + }, + "IPv6": {} + }, + { + "GroupName": "P2P_IBGP", + "Name": "P2P_iBGP", + "VlanId": 0, + "Description": "IBGP Peer Link", + "IPv4": { + "Name": "P2P_iBGP", + "VlanId": 0, + "Cidr": 30, + "TORGroup": "", + "NetworkType": "P2P Link", + "Subnet": "100.71.39.144/30", + "Network": "100.71.39.144", + "Netmask": "255.255.255.252", + "Gateway": "", + "BroadcastAddress": "100.71.39.147", + "FirstAddress": "100.71.39.145", + "LastAddress": "100.71.39.146", + "Assignment": [ + { + "Name": "Network", + "IP": "100.71.39.144" + }, + { + "Name": "TOR1", + "IP": "100.71.39.145" + }, + { + "Name": "TOR2", + "IP": "100.71.39.146" + } + ] + }, + "IPv6": {} + }, + { + "GroupName": "Loopback0", + "Name": "Loopback0_Tor1", + "VlanId": 0, + "Description": "Switch Loopback assignment", + "IPv4": { + "Name": "Loopback0_Tor1", + "VlanId": 0, + "Cidr": 32, + "TORGroup": "", + "NetworkType": "Loopback", + "Subnet": "100.71.39.149/32", + "Network": "100.71.39.149", + "Netmask": "255.255.255.255", + "Gateway": "", + "BroadcastAddress": "", + "FirstAddress": "", + "LastAddress": "", + "Assignment": [ + { + "Name": "TOR1", + "IP": "100.71.39.149" + } + ] + }, + "IPv6": {} + }, + { + "GroupName": "Loopback0", + "Name": "Loopback0_Tor2", + "VlanId": 0, + "Description": "Switch Loopback assignment", + "IPv4": { + "Name": "Loopback0_Tor2", + "VlanId": 0, + "Cidr": 32, + "TORGroup": "", + "NetworkType": "Loopback", + "Subnet": "100.71.39.150/32", + "Network": "100.71.39.150", + "Netmask": "255.255.255.255", + "Gateway": "", + "BroadcastAddress": "", + "FirstAddress": "", + "LastAddress": "", + "Assignment": [ + { + "Name": "TOR2", + "IP": "100.71.39.150" + } + ] + }, + "IPv6": {} + }, + { + "GroupName": "BMC", + "Name": "BMC_Mgmt_125", + "VLANID": 125, + "Description": "BMC Network. GroupName is unique key.", + "IPv4": { + "SwitchSVI": true, + "Name": "BMC_Mgmt_125", + "VLANID": 125, + "Cidr": 26, + "TORGroup": "", + "NetworkType": "Infrastructure", + "Subnet": "100.71.39.192/26", + "Network": "100.71.39.192", + "Netmask": "255.255.255.192", + "Gateway": "100.71.39.193", + "BroadcastAddress": "100.71.39.255", + "FirstAddress": "100.71.39.193", + "LastAddress": "100.71.39.254", + "Assignment": [ + { + "Name": "Network", + "IP": "100.71.39.192", + "ClusterID": null + }, + { + "Name": "Gateway", + "IP": "100.71.39.193", + "ClusterID": null + }, + { + "Name": "HLH-BMC", + "IP": "100.71.39.194", + "ClusterID": null + }, + { + "Name": "CL01-N01", + "IP": "100.71.39.195", + "ClusterID": "CL01" + }, + { + "Name": "CL02-N01", + "IP": "100.71.39.196", + "ClusterID": "CL02" + }, + { + "Name": "CL03-N01", + "IP": "100.71.39.197", + "ClusterID": "CL03" + }, + { + "Name": "CL04-N01", + "IP": "100.71.39.198", + "ClusterID": "CL04" + }, + { + "Name": "CL05-N01", + "IP": "100.71.39.199", + "ClusterID": "CL05" + }, + { + "Name": "CL06-N01", + "IP": "100.71.39.200", + "ClusterID": "CL06" + }, + { + "Name": "CL07-N01", + "IP": "100.71.39.201", + "ClusterID": "CL07" + }, + { + "Name": "CL08-N01", + "IP": "100.71.39.202", + "ClusterID": "CL08" + }, + { + "Name": "CL09-N01", + "IP": "100.71.39.203", + "ClusterID": "CL09" + }, + { + "Name": "CL10-N01", + "IP": "100.71.39.204", + "ClusterID": "CL10" + }, + { + "Name": "CL11-N01", + "IP": "100.71.39.205", + "ClusterID": "CL11" + }, + { + "Name": "CL12-N01", + "IP": "100.71.39.206", + "ClusterID": "CL12" + }, + { + "Name": "CL13-N01", + "IP": "100.71.39.207", + "ClusterID": "CL13" + }, + { + "Name": "CL14-N01", + "IP": "100.71.39.208", + "ClusterID": "CL14" + }, + { + "Name": "CL15-N01", + "IP": "100.71.39.209", + "ClusterID": "CL15" + }, + { + "Name": "CL16-N01", + "IP": "100.71.39.210", + "ClusterID": "CL16" + }, + { + "Name": "CL17-N01", + "IP": "100.71.39.211", + "ClusterID": "CL17" + }, + { + "Name": "CL18-N01", + "IP": "100.71.39.212", + "ClusterID": "CL18" + }, + { + "Name": "CL19-N01", + "IP": "100.71.39.213", + "ClusterID": "CL19" + }, + { + "Name": "CL20-N01", + "IP": "100.71.39.214", + "ClusterID": "CL20" + }, + { + "Name": "CL21-N01", + "IP": "100.71.39.215", + "ClusterID": "CL21" + }, + { + "Name": "CL22-N01", + "IP": "100.71.39.216", + "ClusterID": "CL22" + }, + { + "Name": "CL23-N01", + "IP": "100.71.39.217", + "ClusterID": "CL23" + }, + { + "Name": "CL24-N01", + "IP": "100.71.39.218", + "ClusterID": "CL24" + }, + { + "Name": "CL25-N01", + "IP": "100.71.39.219", + "ClusterID": "CL25" + }, + { + "Name": "CL26-N01", + "IP": "100.71.39.220", + "ClusterID": "CL26" + }, + { + "Name": "CL27-N01", + "IP": "100.71.39.221", + "ClusterID": "CL27" + }, + { + "Name": "CL28-N01", + "IP": "100.71.39.222", + "ClusterID": "CL28" + }, + { + "Name": "CL29-N01", + "IP": "100.71.39.223", + "ClusterID": "CL29" + }, + { + "Name": "CL30-N01", + "IP": "100.71.39.224", + "ClusterID": "CL30" + }, + { + "Name": "CL31-N01", + "IP": "100.71.39.225", + "ClusterID": "CL31" + }, + { + "Name": "CL32-N01", + "IP": "100.71.39.226", + "ClusterID": "CL32" + }, + { + "Name": "CL33-N01", + "IP": "100.71.39.227", + "ClusterID": "CL33" + }, + { + "Name": "CL34-N01", + "IP": "100.71.39.228", + "ClusterID": "CL34" + }, + { + "Name": "CL35-N01", + "IP": "100.71.39.229", + "ClusterID": "CL35" + }, + { + "Name": "CL36-N01", + "IP": "100.71.39.230", + "ClusterID": "CL36" + }, + { + "Name": "CL37-N01", + "IP": "100.71.39.231", + "ClusterID": "CL37" + }, + { + "Name": "CL38-N01", + "IP": "100.71.39.232", + "ClusterID": "CL38" + }, + { + "Name": "CL39-N01", + "IP": "100.71.39.233", + "ClusterID": "CL39" + }, + { + "Name": "CL40-N01", + "IP": "100.71.39.234", + "ClusterID": "CL40" + }, + { + "Name": "Tor1-Mgmt", + "IP": "100.71.39.251", + "ClusterID": null + }, + { + "Name": "Tor2-Mgmt", + "IP": "100.71.39.252", + "ClusterID": null + }, + { + "Name": "BMC-Mgmt", + "IP": "100.71.39.253", + "ClusterID": null + }, + { + "Name": "HLH-OS", + "IP": "100.71.39.254", + "ClusterID": null + }, + { + "Name": "Broadcast", + "IP": "100.71.39.255", + "ClusterID": null + } + ] + }, + "IPv6": {} + }, + { + "GroupName": "Infrastructure", + "Name": "Infra_7", + "VLANID": 7, + "Description": "Infrastructure Network. GroupName is unique key.", + "IPv4": { + "SwitchSVI": true, + "Name": "Infra_7", + "VLANID": 7, + "Cidr": 24, + "TORGroup": "", + "NetworkType": "Infrastructure", + "Subnet": "100.68.11.0/24", + "Network": "100.68.11.0", + "Netmask": "255.255.255.0", + "Gateway": "100.68.11.1", + "BroadcastAddress": "100.68.11.255", + "FirstAddress": "100.68.11.1", + "LastAddress": "100.68.11.254", + "Assignment": [ + { + "Name": "Network", + "IP": "100.68.11.0" + }, + { + "Name": "Gateway", + "IP": "100.68.11.1" + }, + { + "Name": "TOR1", + "IP": "100.68.11.2" + }, + { + "Name": "TOR2", + "IP": "100.68.11.3" + }, + { + "Name": "CL01-N01", + "IP": "100.68.11.4" + }, + { + "Name": "CL02-N01", + "IP": "100.68.11.5" + }, + { + "Name": "CL03-N01", + "IP": "100.68.11.6" + }, + { + "Name": "CL04-N01", + "IP": "100.68.11.7" + }, + { + "Name": "CL05-N01", + "IP": "100.68.11.8" + }, + { + "Name": "CL06-N01", + "IP": "100.68.11.9" + }, + { + "Name": "CL07-N01", + "IP": "100.68.11.10" + }, + { + "Name": "CL08-N01", + "IP": "100.68.11.11" + }, + { + "Name": "CL09-N01", + "IP": "100.68.11.12" + }, + { + "Name": "CL10-N01", + "IP": "100.68.11.13" + }, + { + "Name": "CL11-N01", + "IP": "100.68.11.14" + }, + { + "Name": "CL12-N01", + "IP": "100.68.11.15" + }, + { + "Name": "CL13-N01", + "IP": "100.68.11.16" + }, + { + "Name": "CL14-N01", + "IP": "100.68.11.17" + }, + { + "Name": "CL15-N01", + "IP": "100.68.11.18" + }, + { + "Name": "CL16-N01", + "IP": "100.68.11.19" + }, + { + "Name": "CL17-N01", + "IP": "100.68.11.20" + }, + { + "Name": "CL18-N01", + "IP": "100.68.11.21" + }, + { + "Name": "CL19-N01", + "IP": "100.68.11.22" + }, + { + "Name": "CL20-N01", + "IP": "100.68.11.23" + }, + { + "Name": "CL21-N01", + "IP": "100.68.11.24" + }, + { + "Name": "CL22-N01", + "IP": "100.68.11.25" + }, + { + "Name": "CL23-N01", + "IP": "100.68.11.26" + }, + { + "Name": "CL24-N01", + "IP": "100.68.11.27" + }, + { + "Name": "CL25-N01", + "IP": "100.68.11.28" + }, + { + "Name": "CL26-N01", + "IP": "100.68.11.29" + }, + { + "Name": "CL27-N01", + "IP": "100.68.11.30" + }, + { + "Name": "CL28-N01", + "IP": "100.68.11.31" + }, + { + "Name": "CL29-N01", + "IP": "100.68.11.32" + }, + { + "Name": "CL30-N01", + "IP": "100.68.11.33" + }, + { + "Name": "CL31-N01", + "IP": "100.68.11.34" + }, + { + "Name": "CL32-N01", + "IP": "100.68.11.35" + }, + { + "Name": "CL33-N01", + "IP": "100.68.11.36" + }, + { + "Name": "CL34-N01", + "IP": "100.68.11.37" + }, + { + "Name": "CL35-N01", + "IP": "100.68.11.38" + }, + { + "Name": "CL36-N01", + "IP": "100.68.11.39" + }, + { + "Name": "CL37-N01", + "IP": "100.68.11.40" + }, + { + "Name": "CL38-N01", + "IP": "100.68.11.41" + }, + { + "Name": "CL39-N01", + "IP": "100.68.11.42" + }, + { + "Name": "CL40-N01", + "IP": "100.68.11.43" + }, + { + "Name": "CL01-HLH-DVM01", + "IP": "100.68.11.214", + "ClusterID": "CL01" + }, + { + "Name": "CL02-HLH-DVM02", + "IP": "100.68.11.215", + "ClusterID": "CL02" + }, + { + "Name": "CL03-HLH-DVM03", + "IP": "100.68.11.216", + "ClusterID": "CL03" + }, + { + "Name": "CL04-HLH-DVM04", + "IP": "100.68.11.217", + "ClusterID": "CL04" + }, + { + "Name": "CL05-HLH-DVM05", + "IP": "100.68.11.218", + "ClusterID": "CL05" + }, + { + "Name": "CL06-HLH-DVM06", + "IP": "100.68.11.219", + "ClusterID": "CL06" + }, + { + "Name": "CL07-HLH-DVM07", + "IP": "100.68.11.220", + "ClusterID": "CL07" + }, + { + "Name": "CL08-HLH-DVM08", + "IP": "100.68.11.221", + "ClusterID": "CL08" + }, + { + "Name": "CL09-HLH-DVM09", + "IP": "100.68.11.222", + "ClusterID": "CL09" + }, + { + "Name": "CL10-HLH-DVM10", + "IP": "100.68.11.223", + "ClusterID": "CL10" + }, + { + "Name": "CL11-HLH-DVM11", + "IP": "100.68.11.224", + "ClusterID": "CL11" + }, + { + "Name": "CL12-HLH-DVM12", + "IP": "100.68.11.225", + "ClusterID": "CL12" + }, + { + "Name": "CL13-HLH-DVM13", + "IP": "100.68.11.226", + "ClusterID": "CL13" + }, + { + "Name": "CL14-HLH-DVM14", + "IP": "100.68.11.227", + "ClusterID": "CL14" + }, + { + "Name": "CL15-HLH-DVM15", + "IP": "100.68.11.228", + "ClusterID": "CL15" + }, + { + "Name": "CL16-HLH-DVM16", + "IP": "100.68.11.229", + "ClusterID": "CL16" + }, + { + "Name": "CL17-HLH-DVM17", + "IP": "100.68.11.230", + "ClusterID": "CL17" + }, + { + "Name": "CL18-HLH-DVM18", + "IP": "100.68.11.231", + "ClusterID": "CL18" + }, + { + "Name": "CL19-HLH-DVM19", + "IP": "100.68.11.232", + "ClusterID": "CL19" + }, + { + "Name": "CL20-HLH-DVM20", + "IP": "100.68.11.233", + "ClusterID": "CL20" + }, + { + "Name": "CL21-HLH-DVM21", + "IP": "100.68.11.234", + "ClusterID": "CL21" + }, + { + "Name": "CL22-HLH-DVM22", + "IP": "100.68.11.235", + "ClusterID": "CL22" + }, + { + "Name": "CL23-HLH-DVM23", + "IP": "100.68.11.236", + "ClusterID": "CL23" + }, + { + "Name": "CL24-HLH-DVM24", + "IP": "100.68.11.237", + "ClusterID": "CL24" + }, + { + "Name": "CL25-HLH-DVM25", + "IP": "100.68.11.238", + "ClusterID": "CL25" + }, + { + "Name": "CL26-HLH-DVM26", + "IP": "100.68.11.239", + "ClusterID": "CL26" + }, + { + "Name": "CL27-HLH-DVM27", + "IP": "100.68.11.240", + "ClusterID": "CL27" + }, + { + "Name": "CL28-HLH-DVM28", + "IP": "100.68.11.241", + "ClusterID": "CL28" + }, + { + "Name": "CL29-HLH-DVM29", + "IP": "100.68.11.242", + "ClusterID": "CL29" + }, + { + "Name": "CL30-HLH-DVM30", + "IP": "100.68.11.243", + "ClusterID": "CL30" + }, + { + "Name": "CL31-HLH-DVM31", + "IP": "100.68.11.244", + "ClusterID": "CL31" + }, + { + "Name": "CL32-HLH-DVM32", + "IP": "100.68.11.245", + "ClusterID": "CL32" + }, + { + "Name": "CL33-HLH-DVM33", + "IP": "100.68.11.246", + "ClusterID": "CL33" + }, + { + "Name": "CL34-HLH-DVM34", + "IP": "100.68.11.247", + "ClusterID": "CL34" + }, + { + "Name": "CL35-HLH-DVM35", + "IP": "100.68.11.248", + "ClusterID": "CL35" + }, + { + "Name": "CL36-HLH-DVM36", + "IP": "100.68.11.249", + "ClusterID": "CL36" + }, + { + "Name": "CL37-HLH-DVM37", + "IP": "100.68.11.250", + "ClusterID": "CL37" + }, + { + "Name": "CL38-HLH-DVM38", + "IP": "100.68.11.251", + "ClusterID": "CL38" + }, + { + "Name": "CL39-HLH-DVM39", + "IP": "100.68.11.252", + "ClusterID": "CL39" + }, + { + "Name": "CL40-HLH-DVM40", + "IP": "100.68.11.253", + "ClusterID": "CL40" + }, + { + "Name": "HLH-OS", + "IP": "100.68.11.254", + "ClusterID": null + } + ] + }, + "IPv6": {} + }, + { + "GroupName": "UNUSED_VLAN", + "Name": "UNUSED_VLAN", + "VLANID": 2, + "Description": "UNUSED_VLAN", + "IPv4": { + "SwitchSVI": false, + "Name": "UNUSED_VLAN", + "VLANID": 2, + "NetworkType": "UNUSED_VLAN" + } + }, + { + "GroupName": "NativeVlan", + "Name": "NativeVlan", + "VLANID": 99, + "Description": "NativeVlan", + "IPv4": { + "SwitchSVI": false, + "Name": "NativeVlan", + "VLANID": 99, + "NetworkType": "NativeVlan" + } + } + ], + "Setting": { + "TimeZone": "Pacific Standard Time", + "TimeServer": [ + "10.10.240.20" + ], + "SyslogServer": [ + "10.10.43.111" + ], + "DNSForwarder": [ + "10.10.240.23", + "10.10.240.24" + ] + } + } +} \ No newline at end of file diff --git a/tests/fixtures/s46-r21-93180hl-24-1a.config b/tests/fixtures/s46-r21-93180hl-24-1a.config new file mode 100644 index 0000000..ce2f90c --- /dev/null +++ b/tests/fixtures/s46-r21-93180hl-24-1a.config @@ -0,0 +1,1287 @@ + +! header.go.tmpl-hostname +! Name: s46-r21-93180hl-24-1a +! Make: Cisco +! Model: 93180YC-FX3 +hostname s46-r21-93180hl-24-1a + +banner motd # +NOTICE NOTICE NOTICE NOTICE NOTICE NOTICE NOTICE NOTICE NOTICE + +hostname s46-r21-93180hl-24-1a +BuildVersion: 1.2305.01 +Unauthorized access and/or use prohibited. +All access and/or use subject to monitoring. + +NOTICE NOTICE NOTICE NOTICE NOTICE NOTICE NOTICE NOTICE NOTICE +# + +! stig.go.tmpl-tor_feature +no feature telnet +feature scp-server +feature bgp +feature interface-vlan +feature lldp +feature dhcp +feature vpc +feature hsrp +feature lacp +feature ssh +feature tacacs+ + +! stig.go.tmpl-stig_ssh +no feature ssh +no ssh key ecdsa +no ssh key rsa +ssh key rsa 2048 force +ssh key ecdsa 256 force +feature ssh + +no cdp enable +lldp tlv-select dcbxp egress-queuing + +! stig.go.tmpl-stig_user +fips mode enable +user max-logins 1 +password prompt username +userpassphrase min-length 15 max-length 80 +username admin password 0 $CREDENTIAL_PLACEHOLDER$ role network-admin +username $CREDENTIAL_PLACEHOLDER$ password 0 $CREDENTIAL_PLACEHOLDER$ role network-admin +! +! SSH Key based authentication. Post process this section before applying the initial configuration. +! +! Remove the ability to login with a password +! username password 5 ! role network-admin +! +! Add the public key to the config +! username sshkey +! + + + +! qos.go.tmpl-qos +! +! Ingress traffic to the Interface +policy-map type network-qos QOS_NETWORK + class type network-qos c-8q-nq3 + pause pfc-cos 3 + mtu 9216 + class type network-qos c-8q-nq-default + mtu 9216 + class type network-qos c-8q-nq7 + mtu 9216 + +! Identify the traffic +class-map type qos match-all RDMA + match cos 3 +class-map type qos match-all CLUSTER + match cos 7 + +! Map the traffic to a queue map from the class-map +policy-map type qos AZS_SERVICES + class RDMA + set qos-group 3 + class CLUSTER + set qos-group 7 + +! Egress traffic from the interface +policy-map type queuing QOS_EGRESS_PORT + class type queuing c-out-8q-q3 + bandwidth remaining percent 50 + random-detect minimum-threshold 300 kbytes maximum-threshold 300 kbytes drop-probability 100 weight 0 ecn + class type queuing c-out-8q-q-default + bandwidth remaining percent 48 + class type queuing c-out-8q-q1 + bandwidth remaining percent 0 + class type queuing c-out-8q-q2 + bandwidth remaining percent 0 + class type queuing c-out-8q-q4 + bandwidth remaining percent 0 + class type queuing c-out-8q-q5 + bandwidth remaining percent 0 + class type queuing c-out-8q-q6 + bandwidth remaining percent 0 + class type queuing c-out-8q-q7 + bandwidth percent 2 + +! Apply to the system +system qos + service-policy type queuing output QOS_EGRESS_PORT + service-policy type network-qos QOS_NETWORK + +! vlan.go.tmpl-define_vlan +vlan 2 + name UNUSED_VLAN +vlan 6 + name HNVPA_6 +vlan 7 + name Infra_7 +vlan 99 + name NativeVlan +vlan 125 + name BMC_Mgmt_125 +vlan 201 + name Tenant_201 +vlan 301 + name LogicalTenant_301 +vlan 401 + name DhcpTenant_401 +vlan 501 + name L3forward_501 +vlan 502 + name L3forward_502 +vlan 503 + name L3forward_503 +vlan 504 + name L3forward_504 +vlan 505 + name L3forward_505 +vlan 506 + name L3forward_506 +vlan 507 + name L3forward_507 +vlan 508 + name L3forward_508 +vlan 509 + name L3forward_509 +vlan 510 + name L3forward_510 +vlan 511 + name L3forward_511 +vlan 512 + name L3forward_512 +vlan 513 + name L3forward_513 +vlan 514 + name L3forward_514 +vlan 515 + name L3forward_515 +vlan 516 + name L3forward_516 +vlan 711 + name Storage_711_TOR1 + + +! vlan.go.tmpl-interface_vlan + + + + +interface vlan6 + description HNVPA_6 + mtu 9216 + no shutdown + ip address 100.71.131.2/25 + no ip redirects + no ipv6 redirects + hsrp version 2 + hsrp 6 + priority 150 forwarding-threshold lower 1 upper 150 + ip 100.71.131.1 + +interface vlan7 + description Infra_7 + mtu 9216 + no shutdown + ip address 100.69.176.2/24 + no ip redirects + no ipv6 redirects + ip dhcp relay address 100.71.85.107 + ip dhcp relay address 100.71.85.108 + ip dhcp relay address 100.71.85.109 + ip dhcp relay address 100.71.85.110 + hsrp version 2 + hsrp 7 + priority 150 forwarding-threshold lower 1 upper 150 + ip 100.69.176.1 + + +interface vlan125 + description BMC_Mgmt_125 + mtu 9216 + no shutdown + ip address 100.71.85.123/26 + no ip redirects + no ipv6 redirects + hsrp version 2 + hsrp 125 + priority 150 forwarding-threshold lower 1 upper 150 + ip 100.71.85.65 + +interface vlan201 + description Tenant_201 + mtu 9216 + no shutdown + ip address 100.69.177.2/24 + no ip redirects + no ipv6 redirects + hsrp version 2 + hsrp 201 + priority 150 forwarding-threshold lower 1 upper 150 + ip 100.69.177.1 + +interface vlan301 + description LogicalTenant_301 + mtu 9216 + no shutdown + ip address 100.69.178.2/25 + no ip redirects + no ipv6 redirects + hsrp version 2 + hsrp 301 + priority 150 forwarding-threshold lower 1 upper 150 + ip 100.69.178.1 + +interface vlan401 + description DhcpTenant_401 + mtu 9216 + no shutdown + ip address 100.69.178.130/25 + no ip redirects + no ipv6 redirects + ip dhcp relay address 100.71.85.126 + hsrp version 2 + hsrp 401 + priority 150 forwarding-threshold lower 1 upper 150 + ip 100.69.178.129 + +interface vlan501 + description L3forward_501 + mtu 9216 + no shutdown + ip address 100.69.179.2/28 + no ip redirects + no ipv6 redirects + hsrp version 2 + hsrp 501 + priority 150 forwarding-threshold lower 1 upper 150 + ip 100.69.179.1 + +interface vlan502 + description L3forward_502 + mtu 9216 + no shutdown + ip address 100.69.179.18/28 + no ip redirects + no ipv6 redirects + hsrp version 2 + hsrp 502 + priority 150 forwarding-threshold lower 1 upper 150 + ip 100.69.179.17 + +interface vlan503 + description L3forward_503 + mtu 9216 + no shutdown + ip address 100.69.179.34/28 + no ip redirects + no ipv6 redirects + hsrp version 2 + hsrp 503 + priority 150 forwarding-threshold lower 1 upper 150 + ip 100.69.179.33 + +interface vlan504 + description L3forward_504 + mtu 9216 + no shutdown + ip address 100.69.179.50/28 + no ip redirects + no ipv6 redirects + hsrp version 2 + hsrp 504 + priority 150 forwarding-threshold lower 1 upper 150 + ip 100.69.179.49 + +interface vlan505 + description L3forward_505 + mtu 9216 + no shutdown + ip address 100.69.179.66/28 + no ip redirects + no ipv6 redirects + hsrp version 2 + hsrp 505 + priority 150 forwarding-threshold lower 1 upper 150 + ip 100.69.179.65 + +interface vlan506 + description L3forward_506 + mtu 9216 + no shutdown + ip address 100.69.179.82/28 + no ip redirects + no ipv6 redirects + hsrp version 2 + hsrp 506 + priority 150 forwarding-threshold lower 1 upper 150 + ip 100.69.179.81 + +interface vlan507 + description L3forward_507 + mtu 9216 + no shutdown + ip address 100.69.179.98/28 + no ip redirects + no ipv6 redirects + hsrp version 2 + hsrp 507 + priority 150 forwarding-threshold lower 1 upper 150 + ip 100.69.179.97 + +interface vlan508 + description L3forward_508 + mtu 9216 + no shutdown + ip address 100.69.179.114/28 + no ip redirects + no ipv6 redirects + hsrp version 2 + hsrp 508 + priority 150 forwarding-threshold lower 1 upper 150 + ip 100.69.179.113 + +interface vlan509 + description L3forward_509 + mtu 9216 + no shutdown + ip address 100.69.179.130/28 + no ip redirects + no ipv6 redirects + hsrp version 2 + hsrp 509 + priority 150 forwarding-threshold lower 1 upper 150 + ip 100.69.179.129 + +interface vlan510 + description L3forward_510 + mtu 9216 + no shutdown + ip address 100.69.179.145/28 + no ip redirects + no ipv6 redirects + +interface vlan511 + description L3forward_511 + mtu 9216 + no shutdown + ip address 100.69.179.162/28 + no ip redirects + no ipv6 redirects + hsrp version 2 + hsrp 511 + priority 150 forwarding-threshold lower 1 upper 150 + ip 100.69.179.161 + +interface vlan512 + description L3forward_512 + mtu 9216 + no shutdown + ip address 100.69.179.178/28 + no ip redirects + no ipv6 redirects + hsrp version 2 + hsrp 512 + priority 150 forwarding-threshold lower 1 upper 150 + ip 100.69.179.177 + +interface vlan513 + description L3forward_513 + mtu 9216 + no shutdown + ip address 100.69.179.194/28 + no ip redirects + no ipv6 redirects + hsrp version 2 + hsrp 513 + priority 150 forwarding-threshold lower 1 upper 150 + ip 100.69.179.193 + +interface vlan514 + description L3forward_514 + mtu 9216 + no shutdown + ip address 100.69.179.210/28 + no ip redirects + no ipv6 redirects + hsrp version 2 + hsrp 514 + priority 150 forwarding-threshold lower 1 upper 150 + ip 100.69.179.209 + +interface vlan515 + description L3forward_515 + mtu 9216 + no shutdown + ip address 100.69.179.226/28 + no ip redirects + no ipv6 redirects + hsrp version 2 + hsrp 515 + priority 150 forwarding-threshold lower 1 upper 150 + ip 100.69.179.225 + +interface vlan516 + description L3forward_516 + mtu 9216 + no shutdown + ip address 100.69.179.242/28 + no ip redirects + no ipv6 redirects + hsrp version 2 + hsrp 516 + priority 150 forwarding-threshold lower 1 upper 150 + ip 100.69.179.241 + + + +! stp.go.tmpl-stp + +spanning-tree mode mst +spanning-tree port type edge bpduguard default +spanning-tree mst 0-1 priority 8192 +spanning-tree mst 2 priority 16384 +spanning-tree mst configuration + name AzureStack + revision 1 + instance 1 vlan 1-1999 + instance 2 vlan 2000-4094 + +! vpc.go.tmpl-vpc + +vpc domain 1 + role priority 1 + peer-keepalive destination 100.71.85.18 source 100.71.85.17 vrf default + delay restore 150 + peer-gateway + auto-recovery + +! portchannel.go.tmpl-portchannel +interface port-channel101 + description VPC:MLAG_PEER + switchport + switchport mode trunk + switchport trunk native vlan 99 + priority-flow-control mode on + spanning-tree port type network + logging event port link-status + vpc peer-link + no shutdown + +interface port-channel50 + description VPC:P2P_IBGP + no switchport + priority-flow-control mode on + ip address 100.71.85.17/30 + logging event port link-status + mtu 9216 + service-policy type qos input AZS_SERVICES + no shutdown + +interface port-channel102 + description VPC:TOR_BMC + switchport + switchport mode trunk + switchport trunk native vlan 99 + switchport trunk allowed vlan 125 + spanning-tree port type network + logging event port link-status + mtu 9216 + vpc 102 + no shutdown + + +! torport.go.tmpl-torport +interface Ethernet 1/1 + description HyperConverged + no cdp enable + switchport + switchport mode trunk + switchport trunk native vlan 7 + switchport trunk allowed vlan 6-7,201,301,401,501-516,711 + priority-flow-control mode on send-tlv + spanning-tree port type edge trunk + no logging event port link-status + service-policy type qos input AZS_SERVICES + mtu 9216 + no shutdown + +interface Ethernet 1/2 + description HyperConverged + no cdp enable + switchport + switchport mode trunk + switchport trunk native vlan 7 + switchport trunk allowed vlan 6-7,201,301,401,501-516,711 + priority-flow-control mode on send-tlv + spanning-tree port type edge trunk + no logging event port link-status + service-policy type qos input AZS_SERVICES + mtu 9216 + no shutdown + +interface Ethernet 1/3 + description HyperConverged + no cdp enable + switchport + switchport mode trunk + switchport trunk native vlan 7 + switchport trunk allowed vlan 6-7,201,301,401,501-516,711 + priority-flow-control mode on send-tlv + spanning-tree port type edge trunk + no logging event port link-status + service-policy type qos input AZS_SERVICES + mtu 9216 + no shutdown + +interface Ethernet 1/4 + description HyperConverged + no cdp enable + switchport + switchport mode trunk + switchport trunk native vlan 7 + switchport trunk allowed vlan 6-7,201,301,401,501-516,711 + priority-flow-control mode on send-tlv + spanning-tree port type edge trunk + no logging event port link-status + service-policy type qos input AZS_SERVICES + mtu 9216 + no shutdown + +interface Ethernet 1/5 + description HyperConverged + no cdp enable + switchport + switchport mode trunk + switchport trunk native vlan 7 + switchport trunk allowed vlan 6-7,201,301,401,501-516,711 + priority-flow-control mode on send-tlv + spanning-tree port type edge trunk + no logging event port link-status + service-policy type qos input AZS_SERVICES + mtu 9216 + no shutdown + +interface Ethernet 1/6 + description HyperConverged + no cdp enable + switchport + switchport mode trunk + switchport trunk native vlan 7 + switchport trunk allowed vlan 6-7,201,301,401,501-516,711 + priority-flow-control mode on send-tlv + spanning-tree port type edge trunk + no logging event port link-status + service-policy type qos input AZS_SERVICES + mtu 9216 + no shutdown + +interface Ethernet 1/7 + description HyperConverged + no cdp enable + switchport + switchport mode trunk + switchport trunk native vlan 7 + switchport trunk allowed vlan 6-7,201,301,401,501-516,711 + priority-flow-control mode on send-tlv + spanning-tree port type edge trunk + no logging event port link-status + service-policy type qos input AZS_SERVICES + mtu 9216 + no shutdown + +interface Ethernet 1/8 + description HyperConverged + no cdp enable + switchport + switchport mode trunk + switchport trunk native vlan 7 + switchport trunk allowed vlan 6-7,201,301,401,501-516,711 + priority-flow-control mode on send-tlv + spanning-tree port type edge trunk + no logging event port link-status + service-policy type qos input AZS_SERVICES + mtu 9216 + no shutdown + +interface Ethernet 1/9 + description HyperConverged + no cdp enable + switchport + switchport mode trunk + switchport trunk native vlan 7 + switchport trunk allowed vlan 6-7,201,301,401,501-516,711 + priority-flow-control mode on send-tlv + spanning-tree port type edge trunk + no logging event port link-status + service-policy type qos input AZS_SERVICES + mtu 9216 + no shutdown + +interface Ethernet 1/10 + description HyperConverged + no cdp enable + switchport + switchport mode trunk + switchport trunk native vlan 7 + switchport trunk allowed vlan 6-7,201,301,401,501-516,711 + priority-flow-control mode on send-tlv + spanning-tree port type edge trunk + no logging event port link-status + service-policy type qos input AZS_SERVICES + mtu 9216 + no shutdown + +interface Ethernet 1/11 + description HyperConverged + no cdp enable + switchport + switchport mode trunk + switchport trunk native vlan 7 + switchport trunk allowed vlan 6-7,201,301,401,501-516,711 + priority-flow-control mode on send-tlv + spanning-tree port type edge trunk + no logging event port link-status + service-policy type qos input AZS_SERVICES + mtu 9216 + no shutdown + +interface Ethernet 1/12 + description HyperConverged + no cdp enable + switchport + switchport mode trunk + switchport trunk native vlan 7 + switchport trunk allowed vlan 6-7,201,301,401,501-516,711 + priority-flow-control mode on send-tlv + spanning-tree port type edge trunk + no logging event port link-status + service-policy type qos input AZS_SERVICES + mtu 9216 + no shutdown + +interface Ethernet 1/13 + description HyperConverged + no cdp enable + switchport + switchport mode trunk + switchport trunk native vlan 7 + switchport trunk allowed vlan 6-7,201,301,401,501-516,711 + priority-flow-control mode on send-tlv + spanning-tree port type edge trunk + no logging event port link-status + service-policy type qos input AZS_SERVICES + mtu 9216 + no shutdown + +interface Ethernet 1/14 + description HyperConverged + no cdp enable + switchport + switchport mode trunk + switchport trunk native vlan 7 + switchport trunk allowed vlan 6-7,201,301,401,501-516,711 + priority-flow-control mode on send-tlv + spanning-tree port type edge trunk + no logging event port link-status + service-policy type qos input AZS_SERVICES + mtu 9216 + no shutdown + +interface Ethernet 1/15 + description HyperConverged + no cdp enable + switchport + switchport mode trunk + switchport trunk native vlan 7 + switchport trunk allowed vlan 6-7,201,301,401,501-516,711 + priority-flow-control mode on send-tlv + spanning-tree port type edge trunk + no logging event port link-status + service-policy type qos input AZS_SERVICES + mtu 9216 + no shutdown + +interface Ethernet 1/16 + description HyperConverged + no cdp enable + switchport + switchport mode trunk + switchport trunk native vlan 7 + switchport trunk allowed vlan 6-7,201,301,401,501-516,711 + priority-flow-control mode on send-tlv + spanning-tree port type edge trunk + no logging event port link-status + service-policy type qos input AZS_SERVICES + mtu 9216 + no shutdown + +interface Ethernet 1/17 + description Unused + no cdp enable + switchport + switchport access vlan 2 + spanning-tree port type edge + no logging event port link-status + mtu 9216 + shutdown + +interface Ethernet 1/18 + description Unused + no cdp enable + switchport + switchport access vlan 2 + spanning-tree port type edge + no logging event port link-status + mtu 9216 + shutdown + +interface Ethernet 1/19 + description Unused + no cdp enable + switchport + switchport access vlan 2 + spanning-tree port type edge + no logging event port link-status + mtu 9216 + shutdown + +interface Ethernet 1/20 + description Unused + no cdp enable + switchport + switchport access vlan 2 + spanning-tree port type edge + no logging event port link-status + mtu 9216 + shutdown + +interface Ethernet 1/21 + description Unused + no cdp enable + switchport + switchport access vlan 2 + spanning-tree port type edge + no logging event port link-status + mtu 9216 + shutdown + +interface Ethernet 1/22 + description Unused + no cdp enable + switchport + switchport access vlan 2 + spanning-tree port type edge + no logging event port link-status + mtu 9216 + shutdown + +interface Ethernet 1/23 + description Unused + no cdp enable + switchport + switchport access vlan 2 + spanning-tree port type edge + no logging event port link-status + mtu 9216 + shutdown + +interface Ethernet 1/24 + description Unused + no cdp enable + switchport + switchport access vlan 2 + spanning-tree port type edge + no logging event port link-status + mtu 9216 + shutdown + +interface Ethernet 1/25 + description Unused + no cdp enable + switchport + switchport access vlan 2 + spanning-tree port type edge + no logging event port link-status + mtu 9216 + shutdown + +interface Ethernet 1/26 + description Unused + no cdp enable + switchport + switchport access vlan 2 + spanning-tree port type edge + no logging event port link-status + mtu 9216 + shutdown + +interface Ethernet 1/27 + description Unused + no cdp enable + switchport + switchport access vlan 2 + spanning-tree port type edge + no logging event port link-status + mtu 9216 + shutdown + +interface Ethernet 1/28 + description Unused + no cdp enable + switchport + switchport access vlan 2 + spanning-tree port type edge + no logging event port link-status + mtu 9216 + shutdown + +interface Ethernet 1/29 + description Unused + no cdp enable + switchport + switchport access vlan 2 + spanning-tree port type edge + no logging event port link-status + mtu 9216 + shutdown + +interface Ethernet 1/30 + description Unused + no cdp enable + switchport + switchport access vlan 2 + spanning-tree port type edge + no logging event port link-status + mtu 9216 + shutdown + +interface Ethernet 1/31 + description Unused + no cdp enable + switchport + switchport access vlan 2 + spanning-tree port type edge + no logging event port link-status + mtu 9216 + shutdown + +interface Ethernet 1/32 + description Unused + no cdp enable + switchport + switchport access vlan 2 + spanning-tree port type edge + no logging event port link-status + mtu 9216 + shutdown + +interface Ethernet 1/33 + description Unused + no cdp enable + switchport + switchport access vlan 2 + spanning-tree port type edge + no logging event port link-status + mtu 9216 + shutdown + +interface Ethernet 1/34 + description Unused + no cdp enable + switchport + switchport access vlan 2 + spanning-tree port type edge + no logging event port link-status + mtu 9216 + shutdown + +interface Ethernet 1/35 + description Unused + no cdp enable + switchport + switchport access vlan 2 + spanning-tree port type edge + no logging event port link-status + mtu 9216 + shutdown + +interface Ethernet 1/36 + description Unused + no cdp enable + switchport + switchport access vlan 2 + spanning-tree port type edge + no logging event port link-status + mtu 9216 + shutdown + +interface Ethernet 1/37 + description Unused + no cdp enable + switchport + switchport access vlan 2 + spanning-tree port type edge + no logging event port link-status + mtu 9216 + shutdown + +interface Ethernet 1/38 + description Unused + no cdp enable + switchport + switchport access vlan 2 + spanning-tree port type edge + no logging event port link-status + mtu 9216 + shutdown + +interface Ethernet 1/39 + description Unused + no cdp enable + switchport + switchport access vlan 2 + spanning-tree port type edge + no logging event port link-status + mtu 9216 + shutdown + +interface Ethernet 1/40 + description Unused + no cdp enable + switchport + switchport access vlan 2 + spanning-tree port type edge + no logging event port link-status + mtu 9216 + shutdown + +interface Ethernet 1/41 + description P2P_IBGP + no cdp enable + priority-flow-control mode on send-tlv + logging event port link-status + mtu 9216 + channel-group 50 mode active + no shutdown + +interface Ethernet 1/42 + description P2P_IBGP + no cdp enable + priority-flow-control mode on send-tlv + logging event port link-status + mtu 9216 + channel-group 50 mode active + no shutdown + +interface Ethernet 1/43 + description Unused + no cdp enable + switchport + switchport access vlan 2 + spanning-tree port type edge + no logging event port link-status + mtu 9216 + shutdown + +interface Ethernet 1/44 + description TOR_BMC + no cdp enable + switchport + switchport mode trunk + switchport trunk native vlan 99 + switchport trunk allowed vlan 125 + spanning-tree port type network + logging event port link-status + mtu 9216 + channel-group 102 + no shutdown + +interface Ethernet 1/45 + description Unused + no cdp enable + switchport + switchport access vlan 2 + spanning-tree port type edge + no logging event port link-status + mtu 9216 + shutdown + +interface Ethernet 1/46 + description Unused + no cdp enable + switchport + switchport access vlan 2 + spanning-tree port type edge + no logging event port link-status + mtu 9216 + shutdown + +interface Ethernet 1/47 + description P2P_Border2 + no cdp enable + no switchport + no ip redirects + ip address 100.71.85.10/30 + no ipv6 redirects + mtu 9216 + no shutdown + +interface Ethernet 1/48 + description P2P_Border1 + no cdp enable + no switchport + no ip redirects + ip address 100.71.85.2/30 + no ipv6 redirects + mtu 9216 + no shutdown + +interface Ethernet 1/49 + description MLAG_Peer + no cdp enable + switchport + switchport mode trunk + switchport trunk native vlan 99 + priority-flow-control mode on send-tlv + logging event port link-status + channel-group 101 mode active + no shutdown + +interface Ethernet 1/50 + description MLAG_Peer + no cdp enable + switchport + switchport mode trunk + switchport trunk native vlan 99 + priority-flow-control mode on send-tlv + logging event port link-status + channel-group 101 mode active + no shutdown + +interface Ethernet 1/51 + description MLAG_Peer + no cdp enable + switchport + switchport mode trunk + switchport trunk native vlan 99 + priority-flow-control mode on send-tlv + logging event port link-status + channel-group 101 mode active + no shutdown + +interface Ethernet 1/52 + description Unused + no cdp enable + switchport + switchport access vlan 2 + spanning-tree port type edge + no logging event port link-status + mtu 9216 + shutdown + +interface Ethernet 1/53 + description Unused + no cdp enable + switchport + switchport access vlan 2 + spanning-tree port type edge + no logging event port link-status + mtu 9216 + shutdown + +interface Ethernet 1/54 + description Unused + no cdp enable + switchport + switchport access vlan 2 + spanning-tree port type edge + no logging event port link-status + mtu 9216 + shutdown + +interface mgmt0 + description BMCMgmt_switch_virtual_interface + no ip redirects + no ipv6 redirects + shutdown + + + +interface loopback0 + description Loopback0_Tor1 + ip address 100.71.85.21/32 + +! settings.go.tmpl-set_global +cli alias name wr copy running-config startup-config + +! settings.go.tmpl-set_snmp +snmp-server globalEnforcePriv +no snmp-server protocol enable + +! settings.go.tmpl-set_errdisable_setting +errdisable recovery interval 600 +errdisable recovery cause link-flap +errdisable recovery cause udld +errdisable recovery cause bpduguard +system default switchport shutdown +switching-mode store-forward + +! settings.go.tmpl-set_rmon +rmon event 1 description FATAL(1) owner PMON@FATAL +rmon event 2 description CRITICAL(2) owner PMON@CRITICAL +rmon event 3 description ERROR(3) owner PMON@ERROR +rmon event 4 description WARNING(4) owner PMON@WARNING +rmon event 5 description INFORMATION(5) owner PMON@INFO + +! settings.go.tmpl-set_dhcp +service dhcp +ip dhcp relay + +! settings.go.tmpl-set_console_vty +line console + exec-timeout 10 +line vty + exec-timeout 10 + session-limit 3 + +! settings.go.tmpl-set_ntp +clock timezone PST -8 0 +clock summer-time PDT 2 Sun Apr 02:00 1 Sun Nov 02:00 60 +ntp server 10.10.240.20 +ntp source-interface vlan125 + +! settings.go.tmpl-set_syslog +logging server 10.10.43.111 7 facility local7 use-vrf default +logging source-interface vlan125 +logging level local7 7 +no logging console +login on-success log +logging origin-id hostname +! +logging level acllog 7 +logging level aclmgr 7 +logging level eth_port_channel 7 +logging level hsrp 7 +logging level icam 7 +logging level interface-vlan 7 +logging level ipqosmgr 7 +logging level vlan_mgr 7 +logging level vpc 7 +logging level netstack 7 +logging level bgp 7 + +! settings.go.tmpl-set_load_sharing +ip load-sharing address source-destination port source-destination + +! settings.go.tmpl-set_icmp_errors +ip icmp-errors source-interface vlan125 + +! settings.go.tmpl-set_tacacs +! Replace [TACACS_SERVER] and [TACACS_KEY] with your TACACS server and key +tacacs-server key [TACACS_KEY] +tacacs-server timeout 2 +ip tacacs source-interface vlan125 + +tacacs-server host [TACACS_SERVER1] +tacacs-server host [TACACS_SERVER2] + +aaa group server tacacs+ TACACS_Lab + server [TACACS_SERVER1] + server [TACACS_SERVER2] + source-interface vlan125 + +aaa authentication login default group TACACS_Lab +aaa authentication login console group TACACS_Lab +aaa accounting default group TACACS_Lab + +! prefixlist.go.tmpl-prefixlist +ip prefix-list DEFAULT-FROM-WANSIM seq 5 permit 0.0.0.0/0 +ip prefix-list DefaultRoute seq 10 permit 0.0.0.0/0 +ip prefix-list DefaultRoute seq 50 deny 0.0.0.0/0 le 32 +ip prefix-list FROM-BORDER seq 10 permit 0.0.0.0/0 +ip prefix-list FROM-BORDER seq 20 permit +ip prefix-list FROM-BORDER seq 30 deny 0.0.0.0/0 le 32 +ip prefix-list TO-BORDER seq 5 deny 0.0.0.0/0 +ip prefix-list TO-BORDER seq 10 permit 0.0.0.0/0 le 32 + +route-map PREFER-WANSIM permit 10 + match ip address prefix-list DEFAULT-FROM-WANSIM + set local-preference 200 + + +! bgp.go.tmpl-bgp +router bgp 65242 + router-id 100.71.85.21 + bestpath as-path multipath-relax + log-neighbor-changes + address-family ipv4 unicast + network 100.71.85.0/30 + network 100.71.85.8/30 + network 100.71.85.16/30 + network 100.71.85.21/32 + network 100.71.85.64/26 + network 100.71.131.0/25 + network 100.69.176.0/24 + network 100.69.177.0/24 + network 100.69.178.0/25 + network 100.69.178.128/25 + network 100.69.179.0/28 + network 100.69.179.16/28 + network 100.69.179.32/28 + network 100.69.179.48/28 + network 100.69.179.64/28 + network 100.69.179.80/28 + network 100.69.179.96/28 + network 100.69.179.112/28 + network 100.69.179.128/28 + network 100.69.179.144/28 + network 100.69.179.160/28 + network 100.69.179.176/28 + network 100.69.179.192/28 + network 100.69.179.208/28 + network 100.69.179.224/28 + network 100.69.179.240/28 + maximum-paths 8 + maximum-paths ibgp 8 + + neighbor 100.71.85.1 + description TO_Border1 + remote-as 64846 + ! + ! + address-family ipv4 unicast + maximum-prefix 12000 warning-only + prefix-list TO-BORDER out + prefix-list FROM-BORDER in + + neighbor 100.71.85.9 + description TO_Border2 + remote-as 64846 + ! + ! + address-family ipv4 unicast + maximum-prefix 12000 warning-only + prefix-list TO-BORDER out + prefix-list FROM-BORDER in + + neighbor 100.71.85.18 + description TO_TOR2 + remote-as 65242 + ! + ! + address-family ipv4 unicast + maximum-prefix 12000 warning-only + + + + neighbor 100.71.131.0/25 + description TO_MUX + remote-as 65112 + update-source Loopback0 + ebgp-multihop 3 + address-family ipv4 unicast + maximum-prefix 12000 warning-only + prefix-list DefaultRoute out + + \ No newline at end of file diff --git a/tests/fixtures/wizard-mvp/README.md b/tests/fixtures/wizard-mvp/README.md new file mode 100644 index 0000000..c86d725 --- /dev/null +++ b/tests/fixtures/wizard-mvp/README.md @@ -0,0 +1,526 @@ +# Azure Local Switch Configuration Wizard - MVP + +**Professional reference configuration tool for Azure Local deployments. Generates critical switch configurations - please review and validate before applying to production.** + +--- + +## 🚀 Quick Start + +```bash +cd /workspace/_test/wizard-mvp +python3 -m http.server 8001 +``` + +**Open:** http://localhost:8001/ + +### Demo Workflow +1. **Import Example** - Load `standard-config-example.json` (Dell) or `cisco-config-example.json` (Cisco) +2. **Auto-filled** - All fields pre-populated, jumps to review page +3. **Export** - Download JSON (reusable) or .cfg file (ready to apply) + +### Manual Workflow +1. **Step 1:** Click deployment pattern card (auto-advances) +2. **Step 2:** Fill hostname, select role/vendor/model cards +3. **Step 3:** Configure VLANs with port assignments +4. **Step 4:** Set border router connections & BGP +5. **Step 5:** Review summary & export + +--- + +## 🏗️ Architecture & Design + +### Standalone MVP (No Backend Dependencies) + +**Yes, you're correct!** This wizard is a **100% client-side, self-contained demo**: + +- **HTML + CSS** = Presenter layer (UI structure + styling) +- **JavaScript** = Business logic (validation, config generation, all processing) +- **No backend calls** = Zero dependencies on main repo Python code +- **No imports** = No external libraries, no modules from `/workspace/src/` + +``` +┌─────────────────────────────────────────────────┐ +│ This MVP (_test/wizard-mvp/) │ +│ │ +│ HTML ────► Display forms, cards, buttons │ +│ CSS ────► Style cards, modals, animations │ +│ JS ────► Validate, build config, export │ +│ (Everything happens in browser) │ +└─────────────────────────────────────────────────┘ + ↕ NO CONNECTION ↕ +┌─────────────────────────────────────────────────┐ +│ Main Repo (/workspace/src/) │ +│ │ +│ Python generators, Jinja2 templates │ +│ (Not used by MVP wizard) │ +└─────────────────────────────────────────────────┘ +``` + +**What this means:** +- MVP can run anywhere (just open index.html in browser) +- No Python installation needed for the wizard +- Main repo's Python code generates configs differently +- This is a **proof-of-concept** for the UI/UX approach +- Future integration could call main repo's generators via API + +### High-Level System Overview + +```mermaid +C4Context + title System Architecture - Switch Configuration Wizard + + Person(user, "Network Engineer", "Configures Azure Local switches") + + System_Boundary(wizard, "Wizard Application") { + Component(ui, "HTML/CSS UI", "5-step wizard interface") + Component(logic, "JavaScript Logic", "Validation & generation") + Component(data, "Config Object", "JSON data structure") + } + + System_Ext(browser, "Web Browser", "Displays UI, downloads files") + + Rel(user, ui, "Fills forms, clicks cards") + Rel(ui, logic, "User events") + Rel(logic, data, "Builds/reads config") + Rel(logic, browser, "Downloads .json/.cfg") +``` + +### Core Data Structure + +```mermaid +classDiagram + class ConfigObject { + +string deployment_pattern + +Switch switch + +VLANs vlans + +Ports ports + +BGP bgp + } + + class Switch { + +string role + +string vendor + +string model + +string hostname + } + + class VLANs { + +VLAN infrastructure + +VLAN storage_tor1 + +VLAN storage_tor2 + +VLAN bmc + +VLAN compute + } + + class VLAN { + +int id + +string subnet + +string ports + +boolean l2_only + +string[] dhcp_relay + } + + class Ports { + +BorderRouter[] border_routers + } + + class BorderRouter { + +string port + +string p2p_ip + } + + class BGP { + +int tor_asn + +string loopback + +Neighbor[] neighbors + } + + class Neighbor { + +string name + +int remote_asn + +string ip + } + + ConfigObject *-- Switch + ConfigObject *-- VLANs + ConfigObject *-- Ports + ConfigObject *-- BGP + VLANs *-- VLAN + Ports *-- BorderRouter + BGP *-- Neighbor +``` + +### Key User Flows + +**1. Manual Configuration Flow** +```mermaid +sequenceDiagram + actor User + participant UI + participant Validator + participant Config + + User->>UI: Select deployment card + UI->>UI: Auto-advance to Step 2 + + User->>UI: Fill hostname, select cards + User->>UI: Click Next + UI->>Validator: validateStep2() + Validator-->>UI: Pass ✓ + UI->>UI: Show Step 3 + + User->>UI: Fill VLANs & ports + User->>UI: Click Generate & Review + UI->>Validator: validateStep4() + Validator-->>UI: Pass ✓ + UI->>Config: Build config object + UI->>UI: Display summary, jump to Step 5 +``` + +**2. Import & Export Flow** +```mermaid +sequenceDiagram + actor User + participant UI + participant FileAPI + participant Config + + User->>UI: Click Import JSON + UI->>FileAPI: Open file picker + User->>FileAPI: Select .json file + FileAPI->>UI: Read file content + UI->>Config: Parse JSON + UI->>UI: Fill all form fields + UI->>UI: Jump to Step 5 (review) + + User->>UI: Click Export Config + UI->>Config: Read config object + Config->>UI: Generate Cisco/Dell syntax + UI->>FileAPI: Download .cfg file +``` + +### Core Component Interactions + +**UI Components (index.html)** +``` +┌─────────────────────────────────────┐ +│ Header: Title + Import Button │ +├─────────────────────────────────────┤ +│ Top Nav: [Step1] [Step2] ... [Step5]│ +├─────────────────────────────────────┤ +│ Error Banner (hidden by default) │ +├─────────────────────────────────────┤ +│ Main Container: │ +│ ┌─ Step 1: Cards (3 patterns) │ +│ ├─ Step 2: Input + Cards │ +│ ├─ Step 3: Form fields (VLANs) │ +│ ├─ Step 4: Form fields (BGP) │ +│ └─ Step 5: Summary + Export btns │ +├─────────────────────────────────────┤ +│ Modal Overlay (Promise-based) │ +└─────────────────────────────────────┘ +``` + +**JavaScript Core Functions (app.js)** +``` +User Actions Validators Config Builders +──────────── ────────── ─────────────── +selectCard() ──→ validateStep2() generateAndReview() +goToStep() ──→ validateStep3() generateCiscoConfig() +importJSON() ──→ validateStep4() generateDellConfig() +startOver() displaySummary() +customConfirm() +``` + +**CSS Styling Patterns (style.css)** +- `.card` → Hover effects, selection states +- `.modal-overlay` → Backdrop + animations +- `.btn-confirm` → Purple gradient (matches theme) +- `.summary-grid` → Auto-fit 4-column layout + +--- + +## 🎯 Design Patterns Explained + +### 1. Card Selection (Visual Feedback) +```javascript +// Remove all selected states in group +document.querySelectorAll('.card').forEach(c => c.classList.remove('selected')); +// Add selected to clicked card +cardElement.classList.add('selected'); +// CSS applies purple gradient automatically +``` + +### 2. Progressive Validation (Prevent Incomplete Config) +```javascript +function goToStep(target) { + if (!validateCurrentStep()) { + showValidationError("Please fill required fields"); + return; // Block navigation + } + // Validation passed, show next step +} +``` + +### 3. Promise-Based Modal (Clean Async/Await) +```javascript +async function startOver() { + const confirmed = await customConfirm('Start Over?', 'Lose all data?'); + if (confirmed) clearAllFields(); +} + +function customConfirm(title, msg) { + return new Promise(resolve => { + modalResolve = resolve; // Store resolver + showModal(); + }); +} +``` + +### Architecture Summary + +| Component | Technology | Role | Dependencies | +|-----------|-----------|------|--------------| +| **index.html** | HTML5 | UI structure, forms, cards | None | +| **style.css** | CSS3 | Styling, animations, responsive | None | +| **app.js** | Vanilla JavaScript | All logic, validation, config gen | **Zero** - No imports, no backend | +| **Main Repo** | Python + Jinja2 | Config generation (separate) | **Not used by MVP** | + +**Key Point:** The JavaScript in `app.js` generates config syntax (Cisco/Dell) **entirely in the browser** using string templates. It does NOT call the Python generators from `/workspace/src/generator.py` or use Jinja2 templates from `/workspace/input/jinja2_templates/`. This is intentional for the MVP to be a standalone demo. + +--- + +## 📋 Key Features + +### ✅ What's Included + +**UI/UX:** +- **Top navigation bar** - Visual progress through 5 steps +- **Card-based selection** - Click to select (deployment, role, vendor, model) +- **Validation with errors** - Cannot proceed without required fields +- **Styled confirm dialogs** - Custom modal matching page design +- **Pre-filled demo values** - No typing needed for quick demos + +**Configuration:** +- **Per-VLAN port assignment** - Each VLAN gets its own port configuration +- **BMC optional** - Marked clearly, not required for validation +- **Merged Border & BGP** - Logically grouped in Step 4 +- **Professional messaging** - Clear guidance about validation needed + +**Import/Export:** +- **Import JSON** - Auto-fills all fields, jumps to review page +- **Export JSON** - Reusable configuration +- **Export .cfg** - Cisco NX-OS or Dell OS10 ready-to-apply configs + +**Examples:** +- `standard-config-example.json` - Dell s5248f-on, TOR1, ports 1/1/x +- `cisco-config-example.json` - Cisco 93180YC-FX3, TOR2, ports Eth1/x + +--- + +## 🎯 Configuration Areas + +### 1. Deployment Pattern +- **Fully Converged** (1-4 nodes) +- **Storage Switched** ⭐ (5-16 nodes, recommended) +- **Switchless** (1-4 nodes) + +### 2. Switch Identity +- Hostname (site name embedded) +- Role: TOR1 (priority 150), TOR2 (priority 140), BMC +- Vendor: Cisco NX-OS, Dell OS10 +- Model: Validated for Azure Local + +### 3. VLANs & Port Assignments +Each VLAN configured with: +- VLAN ID +- Subnet (if L3) +- Port range (specific to each VLAN) + +**VLAN Types:** +- **Infrastructure** (default 7): Management, DHCP relay +- **Storage TOR1/TOR2** (default 711/712): L2 only, no BGP +- **BMC** (default 125): Optional, server management +- **Compute** (default 201): Tenant workloads + +**Auto-generated:** +- SVI IPs: TOR1=`.2`, TOR2=`.3` +- HSRP/VRRP: VIP=`.1`, priority 150/140 +- MTU: 9216 (data), 1500 (management) +- QoS: RDMA CoS 3, 50% BW for storage + +### 4. Border Router & BGP +**Border Routers:** +- Port assignments (e.g., 1/1/47, 1/1/48) +- P2P IPs for L3 connections + +**BGP:** +- ToR ASN & loopback +- Border router neighbors (ASN, IPs) +- Network controller (ASN, IP) + +**Auto-advertised:** Infrastructure, Compute, BMC subnets +**NOT advertised:** Storage VLANs (L2 only) + +--- + +## 📊 Input vs Auto-Generated + +| **What You Provide (~23 inputs)** | **What Tool Auto-Generates (~150+ lines)** | +|-----------------------------------|---------------------------------------------| +| Hostname | `hostname ` | +| Role (TOR1/TOR2) | HSRP/VRRP priority (150/140) | +| Vendor + Model | Feature enablement (bgp, hsrp, lacp, dhcp) | +| VLAN IDs (4-5 VLANs) | VLAN creation with names | +| Subnets (3-4 subnets) | SVI interfaces with correct IPs | +| Port ranges (per VLAN) | Interface configs (mode, native VLAN, allowed VLANs, MTU, spanning-tree) | +| DHCP relay IPs (optional) | `ip dhcp relay address` or `ip helper-address` | +| Border router ports/IPs | L3 routed interfaces, MTU 9216 | +| ToR ASN, loopback | BGP router config, router-id | +| Border ASNs, IPs | BGP neighbor configs with address-family | +| NC ASN, IP | BGP peering to network controller | + +**Total:** ~23 user inputs → ~150+ config lines + +--- + +## 🔧 Generated Config Formats + +### Cisco NX-OS Format +``` +hostname rr1-n25-r20-nx9k-01 +feature bgp +feature hsrp +system jumbomtu 9216 + +vlan 7 + name Infra_7 + +interface Vlan7 + ip address 100.69.176.2/24 + hsrp 7 + priority 150 + ip 100.69.176.1 + +interface Eth1/1-18 + switchport mode trunk + switchport trunk native vlan 7 + mtu 9216 + spanning-tree port type edge trunk + +router bgp 65001 + router-id 100.71.39.149 + address-family ipv4 unicast + network 100.69.176.0/24 +``` + +### Dell OS10 Format +``` +hostname rr1-n25-r20-5248hl-23-1a +system mtu 9216 + +interface vlan7 + ip address 100.69.176.2/24 + vrrp-group 7 + priority 150 + virtual-address 100.69.176.1 + +interface range 1/1/1-1/1/18 + switchport mode trunk + switchport access vlan 7 + mtu 9216 + spanning-tree port type edge + +router bgp 65001 + router-id 100.71.39.149 + address-family ipv4 unicast + network 100.69.176.0/24 +``` + +--- + +## ✨ Success Criteria + +### Primary Goals ✅ +- **90% accurate configs** - Critical settings correct (VLANs, IPs, BGP) +- **Time savings** - 5-8 days → 1 hour for new vendor +- **Template reduction** - Card-based UI replaces 8+ templates +- **Vendor expansion** - Easy to add new switch models + +### Quality Metrics ✅ +- No syntax errors in generated .cfg +- VLAN IDs, subnets, ports match customer intent +- BGP advertises correct networks (infra, compute, BMC), NOT storage +- HSRP/VRRP priority correct per role (TOR1=150, TOR2=140) +- MTU 9216 for data VLANs, 1500 for management +- QoS configured for RDMA storage traffic + +### UX Goals ✅ +- Professional reference messaging +- Validation with clear error messages +- Styled confirm dialogs (not browser default) +- Pre-filled demo values +- Import auto-fills and jumps to review +- Top navigation shows progress +- BMC clearly marked as optional + +--- + +## 🎨 Design Rationale + +### Why Card-Based UI? +- **Visual clarity** - See all options at once (no dropdown guessing) +- **Better UX** - Click to select, instant visual feedback +- **Odin-inspired** - Modern, professional design language +- **Reduced errors** - Can't select invalid combinations + +### Why Validation? +- **Prevents incomplete configs** - Must fill required fields +- **Clear guidance** - Red banner shows what's missing +- **Progressive workflow** - Can't skip ahead without completing step + +### Why Per-VLAN Ports? +- **Real-world flexibility** - Different ports for different VLANs +- **Matches actual deployments** - Storage may use different ports than compute +- **BMC separation** - Management ports separate from data + +--- + +## 📁 Files + +**Core:** +- `index.html` - 5-step wizard UI with styled modal +- `app.js` - Validation, card selection, config generation, custom confirm +- `style.css` - Odin-style cards, modal styling, responsive design + +**Examples:** +- `standard-config-example.json` - Dell TOR1 reference +- `cisco-config-example.json` - Cisco TOR2 reference + +--- + +## 🚦 Latest Updates (Jan 22, 2026) + +### UI Improvements +- ✅ Custom styled confirm modal (matches page design) +- ✅ Pre-filled sample values for demo +- ✅ Import jumps to review page +- ✅ Start Over properly clears all fields +- ✅ Top navigation bar with progress indicators + +### Configuration Updates +- ✅ Standard example updated with ports in VLAN structure +- ✅ Cisco example added (different vendor/values) +- ✅ BMC optional handling +- ✅ Per-VLAN port configuration +- ✅ Merged Border Router & BGP in Step 4 + +### Code Cleanup +- ✅ Removed duplicate files +- ✅ Consolidated documentation +- ✅ Single source of truth + +--- + +**Status:** ✅ Production-ready MVP for demos +**Server:** http://localhost:8001/ diff --git a/tests/fixtures/wizard-mvp/app.js b/tests/fixtures/wizard-mvp/app.js new file mode 100644 index 0000000..810a934 --- /dev/null +++ b/tests/fixtures/wizard-mvp/app.js @@ -0,0 +1,1041 @@ +// Azure Local Switch Configuration Wizard - Enhanced with Validation +let currentStep = 1; +let config = {}; +let modalResolve = null; + +// Custom confirm modal +function customConfirm(title, message) { + return new Promise((resolve) => { + modalResolve = resolve; + document.getElementById('modal-title').textContent = title; + document.getElementById('modal-message').textContent = message; + document.getElementById('custom-confirm-modal').style.display = 'flex'; + }); +} + +function closeModal(confirmed) { + document.getElementById('custom-confirm-modal').style.display = 'none'; + if (modalResolve) { + modalResolve(confirmed); + modalResolve = null; + } +} + +const modelsByVendor = { + cisco: [ + { value: '93180YC-FX', label: 'Nexus 93180YC-FX' }, + { value: '93180YC-FX3', label: 'Nexus 93180YC-FX3' }, + { value: '93108TC-FX3P', label: 'Nexus 93108TC-FX3P' } + ], + dellemc: [ + { value: 's5248f-on', label: 'S5248F-ON' }, + { value: 'N3248TE-ON', label: 'N3248TE-ON' } + ] +}; + +// Initialize +document.addEventListener('DOMContentLoaded', () => { + // Import JSON + document.getElementById('import-json').addEventListener('change', importJSON); + + // Step 1: Deployment Pattern cards - auto-advance + setupCardSelection('step1', '.card[data-field="deployment-pattern"]', (value) => { + config.deployment_pattern = value; + updateNavigation(); + goToStep(2); + }); + + // Step 2: Switch Role cards + setupCardSelection('step2', '.card[data-field="role"]', (value) => { + config.switch_role = value; + updateNavigation(); + }); + + // Step 2: Vendor cards + setupCardSelection('step2', '.card[data-field="vendor"]', (value) => { + config.vendor = value; + showModelCards(value); + updateNavigation(); + }); + + // Initialize navigation + updateNavigation(); + setupNavigationClicks(); +}); + +// Setup card click selection +function setupCardSelection(stepId, selector, callback) { + const step = document.getElementById(stepId); + const cards = step.querySelectorAll(selector); + + cards.forEach(card => { + card.addEventListener('click', function() { + // Remove selected from siblings with same data-field + const field = this.getAttribute('data-field'); + if (field) { + const siblings = step.querySelectorAll(`.card[data-field="${field}"]`); + siblings.forEach(c => c.classList.remove('selected')); + } else { + const parent = this.parentElement; + parent.querySelectorAll('.card').forEach(c => c.classList.remove('selected')); + } + + // Add selected to this card + this.classList.add('selected'); + + // Callback with value + const value = this.getAttribute('data-value'); + if (callback) callback(value); + }); + }); +} + +// Show model cards based on vendor +function showModelCards(vendor) { + const modelGroup = document.getElementById('model-group'); + const modelCards = document.getElementById('model-cards'); + + modelCards.innerHTML = ''; + + if (modelsByVendor[vendor]) { + modelsByVendor[vendor].forEach(model => { + const card = document.createElement('div'); + card.className = 'card small'; + card.setAttribute('data-value', model.value); + card.setAttribute('data-field', 'model'); + card.innerHTML = `

${model.label}

`; + + card.addEventListener('click', function() { + modelCards.querySelectorAll('.card').forEach(c => c.classList.remove('selected')); + this.classList.add('selected'); + config.model = model.value; + updateNavigation(); + }); + + modelCards.appendChild(card); + }); + + modelGroup.style.display = 'block'; + } +} + +// Validation functions +function validateStep1() { + return !!config.deployment_pattern; +} + +function validateStep2() { + const hostname = document.getElementById('hostname').value.trim(); + return hostname && config.switch_role && config.vendor && config.model; +} + +function validateStep3() { + // Required: Infrastructure, Storage VLANs, Compute + const infraVlan = document.getElementById('infra-vlan-id').value; + const infraSubnet = document.getElementById('infra-subnet').value.trim(); + const infraPorts = document.getElementById('infra-ports').value.trim(); + + const storageTor1Vlan = document.getElementById('storage-tor1-vlan').value; + const storageTor1Ports = document.getElementById('storage-tor1-ports').value.trim(); + const storageTor2Vlan = document.getElementById('storage-tor2-vlan').value; + const storageTor2Ports = document.getElementById('storage-tor2-ports').value.trim(); + + const computeVlan = document.getElementById('compute-vlan-id').value; + const computeSubnet = document.getElementById('compute-subnet').value.trim(); + const computePorts = document.getElementById('compute-ports').value.trim(); + + // BMC is optional + + return infraVlan && infraSubnet && infraPorts && + storageTor1Vlan && storageTor1Ports && + storageTor2Vlan && storageTor2Ports && + computeVlan && computeSubnet && computePorts; +} + +function validateStep4() { + const border1Port = document.getElementById('border1-port').value.trim(); + const border1P2pIp = document.getElementById('border1-p2p-ip').value.trim(); + const border2Port = document.getElementById('border2-port').value.trim(); + const border2P2pIp = document.getElementById('border2-p2p-ip').value.trim(); + + const border1Ip = document.getElementById('border1-neighbor-ip').value.trim(); + const border2Ip = document.getElementById('border2-neighbor-ip').value.trim(); + + const torAsn = document.getElementById('tor-asn').value; + const loopbackIp = document.getElementById('loopback-ip').value.trim(); + const borderAsn = document.getElementById('border-asn').value; + const ncAsn = document.getElementById('nc-asn').value; + const ncIp = document.getElementById('nc-neighbor-ip').value.trim(); + + return border1Port && border1P2pIp && border2Port && border2P2pIp && + torAsn && loopbackIp && borderAsn && + border1Ip && border2Ip && ncAsn && ncIp; +} + +// Show validation error +function showValidationError(message) { + const errorDiv = document.getElementById('validation-error'); + errorDiv.textContent = '⚠️ ' + message; + errorDiv.style.display = 'block'; + + // Scroll to top to show error + window.scrollTo({ top: 0, behavior: 'smooth' }); + + // Auto-hide after 5 seconds + setTimeout(() => { + errorDiv.style.display = 'none'; + }, 5000); +} + +// Hide validation error +function hideValidationError() { + const errorDiv = document.getElementById('validation-error'); + errorDiv.style.display = 'none'; +} + +// Show success message +function showSuccessMessage(message) { + const successDiv = document.getElementById('success-message'); + successDiv.textContent = '✓ ' + message; + successDiv.style.display = 'block'; + + // Scroll to top to show message + window.scrollTo({ top: 0, behavior: 'smooth' }); + + // Auto-hide after 3 seconds + setTimeout(() => { + successDiv.style.display = 'none'; + }, 3000); +} + +// Hide success message +function hideSuccessMessage() { + const successDiv = document.getElementById('success-message'); + successDiv.style.display = 'none'; +} + +// Navigation with validation +function goToStep(targetStep) { + hideValidationError(); + + // Validate current step before moving forward + if (targetStep > currentStep) { + let isValid = true; + let errorMessage = ''; + + switch (currentStep) { + case 1: + isValid = validateStep1(); + errorMessage = 'Please select a deployment pattern before proceeding.'; + break; + case 2: + isValid = validateStep2(); + errorMessage = 'Please complete all required fields: Hostname, Role, Vendor, and Model.'; + break; + case 3: + isValid = validateStep3(); + errorMessage = 'Please fill in all required VLAN and Port fields (Infrastructure, Storage, Compute).'; + break; + case 4: + isValid = validateStep4(); + errorMessage = 'Please complete all Border Router and BGP configuration fields.'; + break; + } + + if (!isValid) { + showValidationError(errorMessage); + return; + } + } + + // Hide all steps + document.querySelectorAll('.step').forEach(s => s.classList.remove('active')); + + // Show target step + document.getElementById(`step${targetStep}`).classList.add('active'); + + currentStep = targetStep; + updateNavigation(); + window.scrollTo({ top: 0, behavior: 'smooth' }); +} + +// Update navigation bar +function updateNavigation() { + const navSteps = document.querySelectorAll('.nav-step'); + + navSteps.forEach((navStep, index) => { + const stepNum = index + 1; + + // Remove all states + navStep.classList.remove('active', 'completed', 'disabled'); + + if (stepNum < currentStep) { + navStep.classList.add('completed'); + } else if (stepNum === currentStep) { + navStep.classList.add('active'); + } else { + navStep.classList.add('disabled'); + } + }); +} + +// Setup navigation clicks +function setupNavigationClicks() { + const navSteps = document.querySelectorAll('.nav-step'); + + navSteps.forEach((navStep) => { + navStep.addEventListener('click', () => { + const targetStep = parseInt(navStep.getAttribute('data-step')); + + // Only allow going to completed or current step + if (targetStep <= currentStep) { + goToStep(targetStep); + } else { + // Try to advance (will validate) + goToStep(targetStep); + } + }); + }); +} + +// Generate and review configuration +function generateAndReview() { + // Validate step 4 first + if (!validateStep4()) { + showValidationError('Please complete all Border Router and BGP configuration fields.'); + return; + } + + hideValidationError(); + + // Collect all data + const dhcpRelayRaw = document.getElementById('dhcp-relay').value.trim(); + const dhcpRelay = dhcpRelayRaw ? dhcpRelayRaw.split(',').map(ip => ip.trim()).filter(ip => ip) : []; + + // Check if BMC fields are filled + const bmcVlanId = document.getElementById('bmc-vlan-id').value; + const bmcSubnet = document.getElementById('bmc-subnet').value.trim(); + const bmcPorts = document.getElementById('bmc-ports').value.trim(); + + const bmcConfig = (bmcVlanId && bmcSubnet) ? { + id: parseInt(bmcVlanId), + subnet: bmcSubnet, + ports: bmcPorts || undefined + } : undefined; + + config = { + deployment_pattern: config.deployment_pattern, + switch: { + role: config.switch_role, + vendor: config.vendor, + model: config.model, + hostname: document.getElementById('hostname').value.trim() + }, + vlans: { + infrastructure: { + id: parseInt(document.getElementById('infra-vlan-id').value), + subnet: document.getElementById('infra-subnet').value.trim(), + ports: document.getElementById('infra-ports').value.trim(), + dhcp_relay: dhcpRelay.length > 0 ? dhcpRelay : undefined + }, + storage_tor1: { + id: parseInt(document.getElementById('storage-tor1-vlan').value), + ports: document.getElementById('storage-tor1-ports').value.trim(), + l2_only: true + }, + storage_tor2: { + id: parseInt(document.getElementById('storage-tor2-vlan').value), + ports: document.getElementById('storage-tor2-ports').value.trim(), + l2_only: true + }, + bmc: bmcConfig, + compute: { + id: parseInt(document.getElementById('compute-vlan-id').value), + subnet: document.getElementById('compute-subnet').value.trim(), + ports: document.getElementById('compute-ports').value.trim() + } + }, + ports: { + border_routers: [ + { + port: document.getElementById('border1-port').value.trim(), + p2p_ip: document.getElementById('border1-p2p-ip').value.trim(), + description: 'P2P to Border Router 1' + }, + { + port: document.getElementById('border2-port').value.trim(), + p2p_ip: document.getElementById('border2-p2p-ip').value.trim(), + description: 'P2P to Border Router 2' + } + ] + }, + bgp: { + tor_asn: parseInt(document.getElementById('tor-asn').value), + loopback: document.getElementById('loopback-ip').value.trim(), + neighbors: { + border1: { + asn: parseInt(document.getElementById('border-asn').value), + ip: document.getElementById('border1-neighbor-ip').value.trim() + }, + border2: { + asn: parseInt(document.getElementById('border-asn').value), + ip: document.getElementById('border2-neighbor-ip').value.trim() + }, + network_controller: { + asn: parseInt(document.getElementById('nc-asn').value), + ip: document.getElementById('nc-neighbor-ip').value.trim() + } + } + } + }; + + // Clean up undefined values + if (!config.vlans.infrastructure.dhcp_relay) { + delete config.vlans.infrastructure.dhcp_relay; + } + if (!config.vlans.bmc) { + delete config.vlans.bmc; + } + + // Display summary + displaySummary(); + + // Display JSON + document.getElementById('json-output').textContent = JSON.stringify(config, null, 2); + + // Go to review step + goToStep(5); +} + +// Display summary +function displaySummary() { + const summary = document.getElementById('config-summary'); + + const bmcInfo = config.vlans.bmc ? + `

BMC VLAN: ${config.vlans.bmc.id} (${config.vlans.bmc.subnet})

` : + '

BMC VLAN: Not configured

'; + + const html = ` +

Configuration Summary

+
+
+

Deployment & Switch

+

Pattern: ${config.deployment_pattern}

+

Hostname: ${config.switch.hostname}

+

Role: ${config.switch.role}

+

Vendor: ${config.switch.vendor}

+

Model: ${config.switch.model}

+
+ +
+

VLANs

+

Infrastructure: ${config.vlans.infrastructure.id} (${config.vlans.infrastructure.subnet})

+

Storage TOR1: ${config.vlans.storage_tor1.id} (L2 only)

+

Storage TOR2: ${config.vlans.storage_tor2.id} (L2 only)

+ ${bmcInfo} +

Compute: ${config.vlans.compute.id} (${config.vlans.compute.subnet})

+
+ +
+

Ports

+

Infrastructure: ${config.vlans.infrastructure.ports}

+

Storage TOR1: ${config.vlans.storage_tor1.ports}

+

Storage TOR2: ${config.vlans.storage_tor2.ports}

+ ${config.vlans.bmc ? `

BMC: ${config.vlans.bmc.ports || 'Not specified'}

` : ''} +

Compute: ${config.vlans.compute.ports}

+

Border 1: ${config.ports.border_routers[0].port}

+

Border 2: ${config.ports.border_routers[1].port}

+
+ +
+

BGP

+

ToR ASN: ${config.bgp.tor_asn}

+

Loopback: ${config.bgp.loopback}

+

Border ASN: ${config.bgp.neighbors.border1.asn}

+

Border 1 IP: ${config.bgp.neighbors.border1.ip}

+

Border 2 IP: ${config.bgp.neighbors.border2.ip}

+

NC ASN: ${config.bgp.neighbors.network_controller.asn}

+

NC IP: ${config.bgp.neighbors.network_controller.ip}

+
+
+ `; + + summary.innerHTML = html; +} + +// Export JSON +function exportJSON() { + const jsonStr = JSON.stringify(config, null, 2); + const blob = new Blob([jsonStr], { type: 'application/json' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `${config.switch.hostname || 'azure-local'}-config.json`; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); +} + +// Export config file +function exportConfig() { + const vendor = config.switch.vendor; + let configText = ''; + + if (vendor === 'cisco') { + configText = generateCiscoConfig(); + } else if (vendor === 'dellemc') { + configText = generateDellConfig(); + } + + const blob = new Blob([configText], { type: 'text/plain' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `${config.switch.hostname || 'switch'}.cfg`; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); +} + +// Generate Cisco config +function generateCiscoConfig() { + const role = config.switch.role; + const priority = role === 'TOR1' ? 150 : 140; + const sviOffset = role === 'TOR1' ? 2 : 3; + + let configText = `! +! Azure Local Switch Configuration +! Hostname: ${config.switch.hostname} +! Role: ${config.switch.role} +! Generated: ${new Date().toISOString()} +! + +hostname ${config.switch.hostname} + +feature bgp +feature hsrp +feature dhcp +feature lacp +feature interface-vlan + +system default switchport +system jumbomtu 9216 + +! +! VLAN Configuration +! +vlan ${config.vlans.infrastructure.id} + name Infra_${config.vlans.infrastructure.id} + +vlan ${config.vlans.storage_tor1.id} + name Storage_${config.vlans.storage_tor1.id}_TOR1 + +vlan ${config.vlans.storage_tor2.id} + name Storage_${config.vlans.storage_tor2.id}_TOR2 + +`; + + if (config.vlans.bmc) { + configText += `vlan ${config.vlans.bmc.id} + name BMC_${config.vlans.bmc.id} + +`; + } + + configText += `vlan ${config.vlans.compute.id} + name Compute_${config.vlans.compute.id} + +! +! SVI Interfaces +! +interface Vlan${config.vlans.infrastructure.id} + no shutdown + mtu 1500 + ip address ${getSubnetIP(config.vlans.infrastructure.subnet, sviOffset)} +`; + + if (config.vlans.infrastructure.dhcp_relay) { + config.vlans.infrastructure.dhcp_relay.forEach(ip => { + configText += ` ip dhcp relay address ${ip}\n`; + }); + } + + configText += ` hsrp ${config.vlans.infrastructure.id} + priority ${priority} + ip ${getSubnetIP(config.vlans.infrastructure.subnet, 1)} + +`; + + if (config.vlans.bmc) { + configText += `interface Vlan${config.vlans.bmc.id} + no shutdown + mtu 1500 + ip address ${getSubnetIP(config.vlans.bmc.subnet, sviOffset)} + hsrp ${config.vlans.bmc.id} + priority ${priority} + ip ${getSubnetIP(config.vlans.bmc.subnet, 1)} + +`; + } + + configText += `interface Vlan${config.vlans.compute.id} + no shutdown + mtu 9216 + ip address ${getSubnetIP(config.vlans.compute.subnet, sviOffset)} + hsrp ${config.vlans.compute.id} + priority ${priority} + ip ${getSubnetIP(config.vlans.compute.subnet, 1)} + +! +! Infrastructure VLAN Ports +! +interface ${config.vlans.infrastructure.ports} + switchport mode trunk + switchport trunk native vlan ${config.vlans.infrastructure.id} + switchport trunk allowed vlan ${config.vlans.infrastructure.id} + mtu 9216 + spanning-tree port type edge trunk + no shutdown + +! +! Storage VLAN Ports +! +interface ${config.vlans.storage_tor1.ports} + switchport mode trunk + switchport trunk native vlan ${role === 'TOR1' ? config.vlans.storage_tor1.id : config.vlans.storage_tor2.id} + switchport trunk allowed vlan ${role === 'TOR1' ? config.vlans.storage_tor1.id : config.vlans.storage_tor2.id} + mtu 9216 + spanning-tree port type edge trunk + no shutdown + +`; + + if (config.vlans.bmc && config.vlans.bmc.ports) { + configText += `! +! BMC VLAN Ports +! +interface ${config.vlans.bmc.ports} + switchport mode trunk + switchport trunk native vlan ${config.vlans.bmc.id} + switchport trunk allowed vlan ${config.vlans.bmc.id} + mtu 9216 + spanning-tree port type edge trunk + no shutdown + +`; + } + + configText += `! +! Compute VLAN Ports +! +interface ${config.vlans.compute.ports} + switchport mode trunk + switchport trunk native vlan ${config.vlans.compute.id} + switchport trunk allowed vlan ${config.vlans.compute.id} + mtu 9216 + spanning-tree port type edge trunk + no shutdown + +! +! Border Router Uplinks +! +interface ${config.ports.border_routers[0].port} + description ${config.ports.border_routers[0].description} + no switchport + mtu 9216 + ip address ${config.ports.border_routers[0].p2p_ip} + no shutdown + +interface ${config.ports.border_routers[1].port} + description ${config.ports.border_routers[1].description} + no switchport + mtu 9216 + ip address ${config.ports.border_routers[1].p2p_ip} + no shutdown + +! +! Loopback +! +interface loopback0 + description BGP Router ID + ip address ${config.bgp.loopback} + +! +! BGP Configuration +! +router bgp ${config.bgp.tor_asn} + router-id ${config.bgp.loopback.split('/')[0]} + address-family ipv4 unicast + network ${config.vlans.infrastructure.subnet} +`; + + if (config.vlans.bmc) { + configText += ` network ${config.vlans.bmc.subnet}\n`; + } + + configText += ` network ${config.vlans.compute.subnet} + neighbor ${config.bgp.neighbors.border1.ip} + remote-as ${config.bgp.neighbors.border1.asn} + address-family ipv4 unicast + neighbor ${config.bgp.neighbors.border2.ip} + remote-as ${config.bgp.neighbors.border2.asn} + address-family ipv4 unicast + neighbor ${config.bgp.neighbors.network_controller.ip} + remote-as ${config.bgp.neighbors.network_controller.asn} + address-family ipv4 unicast + +! +! End +! +`; + + return configText; +} + +// Generate Dell config +function generateDellConfig() { + const role = config.switch.role; + const priority = role === 'TOR1' ? 150 : 140; + const sviOffset = role === 'TOR1' ? 2 : 3; + + let configText = `! +! Azure Local Switch Configuration +! Hostname: ${config.switch.hostname} +! Role: ${config.switch.role} +! Generated: ${new Date().toISOString()} +! + +hostname ${config.switch.hostname} + +system mtu 9216 + +! +! VLAN Interfaces +! +interface vlan${config.vlans.infrastructure.id} + description Infra_${config.vlans.infrastructure.id} + no shutdown + mtu 1500 + ip address ${getSubnetIP(config.vlans.infrastructure.subnet, sviOffset)} +`; + + if (config.vlans.infrastructure.dhcp_relay) { + config.vlans.infrastructure.dhcp_relay.forEach(ip => { + configText += ` ip helper-address ${ip}\n`; + }); + } + + configText += ` vrrp-group ${config.vlans.infrastructure.id} + priority ${priority} + virtual-address ${getSubnetIP(config.vlans.infrastructure.subnet, 1)} + +`; + + if (config.vlans.bmc) { + configText += `interface vlan${config.vlans.bmc.id} + description BMC_${config.vlans.bmc.id} + no shutdown + mtu 1500 + ip address ${getSubnetIP(config.vlans.bmc.subnet, sviOffset)} + vrrp-group ${config.vlans.bmc.id} + priority ${priority} + virtual-address ${getSubnetIP(config.vlans.bmc.subnet, 1)} + +`; + } + + configText += `interface vlan${config.vlans.compute.id} + description Compute_${config.vlans.compute.id} + no shutdown + mtu 9216 + ip address ${getSubnetIP(config.vlans.compute.subnet, sviOffset)} + vrrp-group ${config.vlans.compute.id} + priority ${priority} + virtual-address ${getSubnetIP(config.vlans.compute.subnet, 1)} + +! +! Infrastructure VLAN Ports +! +interface range ${config.vlans.infrastructure.ports} + no shutdown + switchport mode trunk + switchport access vlan ${config.vlans.infrastructure.id} + switchport trunk allowed vlan ${config.vlans.infrastructure.id} + mtu 9216 + spanning-tree port type edge + +! +! Storage VLAN Ports +! +interface range ${config.vlans.storage_tor1.ports} + no shutdown + switchport mode trunk + switchport access vlan ${role === 'TOR1' ? config.vlans.storage_tor1.id : config.vlans.storage_tor2.id} + switchport trunk allowed vlan ${role === 'TOR1' ? config.vlans.storage_tor1.id : config.vlans.storage_tor2.id} + mtu 9216 + spanning-tree port type edge + +`; + + if (config.vlans.bmc && config.vlans.bmc.ports) { + configText += `! +! BMC VLAN Ports +! +interface range ${config.vlans.bmc.ports} + no shutdown + switchport mode trunk + switchport access vlan ${config.vlans.bmc.id} + switchport trunk allowed vlan ${config.vlans.bmc.id} + mtu 9216 + spanning-tree port type edge + +`; + } + + configText += `! +! Compute VLAN Ports +! +interface range ${config.vlans.compute.ports} + no shutdown + switchport mode trunk + switchport access vlan ${config.vlans.compute.id} + switchport trunk allowed vlan ${config.vlans.compute.id} + mtu 9216 + spanning-tree port type edge + +! +! Border Router Uplinks +! +interface ${config.ports.border_routers[0].port} + description ${config.ports.border_routers[0].description} + no shutdown + no switchport + mtu 9216 + ip address ${config.ports.border_routers[0].p2p_ip} + +interface ${config.ports.border_routers[1].port} + description ${config.ports.border_routers[1].description} + no shutdown + no switchport + mtu 9216 + ip address ${config.ports.border_routers[1].p2p_ip} + +! +! Loopback +! +interface loopback0 + description BGP Router ID + ip address ${config.bgp.loopback} + +! +! BGP Configuration +! +router bgp ${config.bgp.tor_asn} + router-id ${config.bgp.loopback.split('/')[0]} + address-family ipv4 unicast + network ${config.vlans.infrastructure.subnet} +`; + + if (config.vlans.bmc) { + configText += ` network ${config.vlans.bmc.subnet}\n`; + } + + configText += ` network ${config.vlans.compute.subnet} + neighbor ${config.bgp.neighbors.border1.ip} + remote-as ${config.bgp.neighbors.border1.asn} + address-family ipv4 unicast + neighbor ${config.bgp.neighbors.border2.ip} + remote-as ${config.bgp.neighbors.border2.asn} + address-family ipv4 unicast + neighbor ${config.bgp.neighbors.network_controller.ip} + remote-as ${config.bgp.neighbors.network_controller.asn} + address-family ipv4 unicast + +! +! End +! +`; + + return configText; +} + +// Helper: Get IP from subnet +function getSubnetIP(subnet, offset) { + const [network, cidr] = subnet.split('/'); + const parts = network.split('.'); + parts[3] = offset.toString(); + return parts.join('.') + '/' + cidr; +} + +// Import JSON +function importJSON(event) { + const file = event.target.files[0]; + if (!file) return; + + const reader = new FileReader(); + reader.onload = function(e) { + try { + const imported = JSON.parse(e.target.result); + + // Populate deployment pattern + if (imported.deployment_pattern) { + config.deployment_pattern = imported.deployment_pattern; + const card = document.querySelector(`#step1 .card[data-value="${imported.deployment_pattern}"]`); + if (card) card.classList.add('selected'); + } + + // Populate switch info + if (imported.switch) { + document.getElementById('hostname').value = imported.switch.hostname || ''; + + if (imported.switch.role) { + config.switch_role = imported.switch.role; + const card = document.querySelector(`#step2 .card[data-value="${imported.switch.role}"]`); + if (card) card.classList.add('selected'); + } + + if (imported.switch.vendor) { + config.vendor = imported.switch.vendor; + const card = document.querySelector(`#step2 .card[data-value="${imported.switch.vendor}"]`); + if (card) card.classList.add('selected'); + showModelCards(imported.switch.vendor); + + setTimeout(() => { + if (imported.switch.model) { + config.model = imported.switch.model; + const modelCard = document.querySelector(`#model-cards .card[data-value="${imported.switch.model}"]`); + if (modelCard) modelCard.classList.add('selected'); + } + }, 100); + } + } + + // Populate VLANs + if (imported.vlans) { + document.getElementById('infra-vlan-id').value = imported.vlans.infrastructure?.id || 7; + document.getElementById('infra-subnet').value = imported.vlans.infrastructure?.subnet || ''; + document.getElementById('infra-ports').value = imported.vlans.infrastructure?.ports || ''; + document.getElementById('dhcp-relay').value = (imported.vlans.infrastructure?.dhcp_relay || []).join(', '); + + document.getElementById('storage-tor1-vlan').value = imported.vlans.storage_tor1?.id || 711; + document.getElementById('storage-tor1-ports').value = imported.vlans.storage_tor1?.ports || ''; + document.getElementById('storage-tor2-vlan').value = imported.vlans.storage_tor2?.id || 712; + document.getElementById('storage-tor2-ports').value = imported.vlans.storage_tor2?.ports || ''; + + if (imported.vlans.bmc) { + document.getElementById('bmc-vlan-id').value = imported.vlans.bmc.id || 125; + document.getElementById('bmc-subnet').value = imported.vlans.bmc.subnet || ''; + document.getElementById('bmc-ports').value = imported.vlans.bmc.ports || ''; + } + + document.getElementById('compute-vlan-id').value = imported.vlans.compute?.id || 201; + document.getElementById('compute-subnet').value = imported.vlans.compute?.subnet || ''; + document.getElementById('compute-ports').value = imported.vlans.compute?.ports || ''; + } + + // Populate ports + if (imported.ports && imported.ports.border_routers?.length >= 2) { + document.getElementById('border1-port').value = imported.ports.border_routers[0].port || ''; + document.getElementById('border1-p2p-ip').value = imported.ports.border_routers[0].p2p_ip || ''; + document.getElementById('border2-port').value = imported.ports.border_routers[1].port || ''; + document.getElementById('border2-p2p-ip').value = imported.ports.border_routers[1].p2p_ip || ''; + } + + // Populate BGP + if (imported.bgp) { + document.getElementById('tor-asn').value = imported.bgp.tor_asn || ''; + document.getElementById('loopback-ip').value = imported.bgp.loopback || ''; + if (imported.bgp.neighbors) { + document.getElementById('border-asn').value = imported.bgp.neighbors.border1?.asn || ''; + document.getElementById('border1-neighbor-ip').value = imported.bgp.neighbors.border1?.ip || ''; + document.getElementById('border2-neighbor-ip').value = imported.bgp.neighbors.border2?.ip || ''; + document.getElementById('nc-asn').value = imported.bgp.neighbors.network_controller?.asn || ''; + document.getElementById('nc-neighbor-ip').value = imported.bgp.neighbors.network_controller?.ip || ''; + } + } + + // Store the imported config + config = imported; + + // Generate and display the review page + setTimeout(() => { + displaySummary(); + document.getElementById('json-output').textContent = JSON.stringify(config, null, 2); + showSuccessMessage('Configuration imported successfully! Jumping to review page...'); + goToStep(5); + }, 200); + + } catch (error) { + showValidationError('Error importing JSON: ' + error.message); + } finally { + // Reset file input so same file can be imported again + event.target.value = ''; + } + }; + + reader.readAsText(file); +} + +// Start over +async function startOver() { + const confirmed = await customConfirm( + 'Start Over?', + 'Are you sure you want to start over? All current configuration will be lost.' + ); + + if (confirmed) { + // Clear config object + config = {}; + + // Remove all card selections + document.querySelectorAll('.card').forEach(c => c.classList.remove('selected')); + + // Clear ALL text inputs completely + document.getElementById('hostname').value = ''; + document.getElementById('infra-subnet').value = ''; + document.getElementById('infra-ports').value = ''; + document.getElementById('dhcp-relay').value = ''; + document.getElementById('storage-tor1-ports').value = ''; + document.getElementById('storage-tor2-ports').value = ''; + document.getElementById('bmc-subnet').value = ''; + document.getElementById('bmc-ports').value = ''; + document.getElementById('compute-subnet').value = ''; + document.getElementById('compute-ports').value = ''; + document.getElementById('border1-port').value = ''; + document.getElementById('border1-p2p-ip').value = ''; + document.getElementById('border2-port').value = ''; + document.getElementById('border2-p2p-ip').value = ''; + document.getElementById('loopback-ip').value = ''; + document.getElementById('border1-neighbor-ip').value = ''; + document.getElementById('border2-neighbor-ip').value = ''; + document.getElementById('nc-neighbor-ip').value = ''; + + // Reset VLAN IDs to defaults + document.getElementById('infra-vlan-id').value = '7'; + document.getElementById('storage-tor1-vlan').value = '711'; + document.getElementById('storage-tor2-vlan').value = '712'; + document.getElementById('bmc-vlan-id').value = '125'; + document.getElementById('compute-vlan-id').value = '201'; + + // Clear ASN fields + document.getElementById('tor-asn').value = ''; + document.getElementById('border-asn').value = ''; + document.getElementById('nc-asn').value = ''; + + // Hide model selection group + document.getElementById('model-group').style.display = 'none'; + + // Clear summary and JSON preview + document.getElementById('config-summary').innerHTML = ''; + document.getElementById('json-output').textContent = ''; + + // Reset to step 1 + currentStep = 1; + document.querySelectorAll('.step').forEach(s => s.classList.remove('active')); + document.getElementById('step1').classList.add('active'); + updateNavigation(); + window.scrollTo({ top: 0, behavior: 'smooth' }); + } +} diff --git a/tests/fixtures/wizard-mvp/index.html b/tests/fixtures/wizard-mvp/index.html new file mode 100644 index 0000000..8a9b3bb --- /dev/null +++ b/tests/fixtures/wizard-mvp/index.html @@ -0,0 +1,377 @@ + + + + + + Azure Local Switch Configuration Wizard + + + +
+ +
+

🌐 Azure Local Switch Configuration Wizard

+

Reference configuration for Azure Local deployment - Covers critical switch configurations. Please review and validate before applying to production.

+
+ + +
+
+ + + + + + + + + + + +
+ +
+

Step 1: Deployment Pattern

+

Choose your Azure Local deployment type

+ +
+
+
🔄
+

Fully Converged

+

1-4 nodes

+

Simplest setup

+
+
+
💾
+

Storage Switched ⭐

+

5-16 nodes

+

Recommended for production

+
+
+
🔌
+

Switchless

+

1-4 nodes

+

Cost effective

+
+
+
+ + +
+

Step 2: Switch Identity

+

Basic information about your switch

+ +
+
+ + + Unique switch hostname (site name embedded here) +
+ +
+ +
+
+

TOR1

+ Primary (Priority 150) +
+
+

TOR2

+ Secondary (Priority 140) +
+
+

BMC

+ Management +
+
+
+ +
+ +
+
+
🔷
+

Cisco NX-OS

+
+
+
🔶
+

Dell OS10

+
+
+
+ + +
+ + +
+ + +
+

Step 3: VLAN & Port Configuration

+

Configure network VLANs and port assignments

+ +
+ +
+

📡 Infrastructure VLAN

+
+
+ + +
+
+ + +
+
+
+ + + Port range for cluster nodes on Infrastructure VLAN +
+
+ + +
+
+ 🤖 Auto-configured: SVI IPs, HSRP/VRRP (VIP: .1), MTU 9216, BGP advertisement, Trunk mode +
+
+ + +
+

💾 Storage VLANs (Layer 2 Only)

+
+
+ + +
+
+ + +
+
+
+
+ + +
+
+ + +
+
+
+ 🤖 Auto-configured: MTU 9216, QoS RDMA (CoS 3, 50% BW), NO BGP (L2 only), Trunk mode +
+
+ + +
+

🖥️ BMC VLAN (Optional)

+
+
+ + +
+
+ + +
+
+
+ + + Port range for BMC connections +
+
+ 🤖 Auto-configured: SVI IPs, HSRP/VRRP gateway, BGP advertisement, Trunk mode +
+
+ + +
+

☁️ Compute VLAN

+
+
+ + +
+
+ + +
+
+
+ + + Port range for cluster nodes on Compute VLAN +
+
+ 🤖 Auto-configured: SVI IPs, HSRP/VRRP gateway, BGP advertisement, Trunk mode +
+
+
+ +
+ + +
+
+ + +
+

Step 4: Border Router & BGP Configuration

+

Configure routing to border routers and network controller

+ +
+ +
+

🔀 Border Router Connections (L3)

+ +

Border Router 1

+
+
+ + +
+
+ + +
+
+ +

Border Router 2

+
+
+ + +
+
+ + +
+
+
+ 🤖 Auto-configured: L3 routed ports, MTU 9216 +
+
+ + +
+

🔧 ToR Switch BGP

+
+
+ + +
+
+ + +
+
+
+ +
+

🌐 BGP Neighbors

+
+ + +
+
+
+ + +
+
+ + +
+
+ +

Network Controller

+
+
+ + +
+
+ + +
+
+
+ 🤖 Auto-advertised: Infrastructure, Compute, BMC subnets | NOT advertised: Storage VLANs (L2 only) +
+
+
+ +
+ + +
+
+ + +
+

✅ Configuration Ready

+

Review and export your configuration

+ +
+
+ +
+

JSON Preview

+

+                    
+
+ +
+ + + +
+
+
+
+ + + + + + + diff --git a/tests/fixtures/wizard-mvp/standard-config-example.json b/tests/fixtures/wizard-mvp/standard-config-example.json new file mode 100644 index 0000000..41ed1f6 --- /dev/null +++ b/tests/fixtures/wizard-mvp/standard-config-example.json @@ -0,0 +1,72 @@ +{ + "deployment_pattern": "storage_switched", + "switch": { + "role": "TOR1", + "vendor": "dellemc", + "model": "s5248f-on", + "hostname": "rr1-n25-r20-5248hl-23-1a" + }, + "vlans": { + "infrastructure": { + "id": 7, + "subnet": "100.69.176.0/24", + "ports": "1/1/1-1/1/18", + "dhcp_relay": [ + "100.71.85.107", + "100.71.85.108" + ] + }, + "storage_tor1": { + "id": 711, + "ports": "1/1/1-1/1/18", + "l2_only": true + }, + "storage_tor2": { + "id": 712, + "ports": "1/1/1-1/1/18", + "l2_only": true + }, + "bmc": { + "id": 125, + "subnet": "100.71.85.64/26", + "ports": "1/1/19-1/1/20" + }, + "compute": { + "id": 201, + "subnet": "100.69.177.0/24", + "ports": "1/1/1-1/1/18" + } + }, + "ports": { + "border_routers": [ + { + "port": "1/1/47", + "p2p_ip": "100.71.39.130/30", + "description": "P2P to Border Router 1" + }, + { + "port": "1/1/48", + "p2p_ip": "100.71.39.138/30", + "description": "P2P to Border Router 2" + } + ] + }, + "bgp": { + "tor_asn": 65001, + "loopback": "100.71.39.149/32", + "neighbors": { + "border1": { + "asn": 65000, + "ip": "100.71.39.129" + }, + "border2": { + "asn": 65000, + "ip": "100.71.39.137" + }, + "network_controller": { + "asn": 65002, + "ip": "100.69.176.10" + } + } + } +} diff --git a/tests/fixtures/wizard-mvp/style.css b/tests/fixtures/wizard-mvp/style.css new file mode 100644 index 0000000..6f5013a --- /dev/null +++ b/tests/fixtures/wizard-mvp/style.css @@ -0,0 +1,709 @@ +/* Azure Local Network Config Generator - Odin-style UI */ + +* { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +body { + font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; + background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); + min-height: 100vh; + padding: 20px; +} + +.container { + max-width: 1000px; + margin: 0 auto; +} + +/* Header */ +header { + background: white; + padding: 30px; + border-radius: 12px; + box-shadow: 0 8px 16px rgba(0,0,0,0.1); + margin-bottom: 30px; + text-align: center; +} + +header h1 { + color: #333; + font-size: 28px; + margin-bottom: 10px; +} + +.subtitle { + color: #666; + font-size: 14px; + margin-bottom: 20px; + line-height: 1.5; +} + +/* Top Navigation Bar */ +.top-nav { + background: white; + padding: 20px; + border-radius: 12px; + box-shadow: 0 4px 12px rgba(0,0,0,0.08); + margin-bottom: 20px; + display: flex; + justify-content: space-between; + gap: 10px; +} + +.nav-step { + flex: 1; + display: flex; + flex-direction: column; + align-items: center; + cursor: pointer; + padding: 15px 10px; + border-radius: 8px; + transition: all 0.3s ease; + position: relative; +} + +.nav-step::after { + content: ''; + position: absolute; + right: -10px; + top: 50%; + transform: translateY(-50%); + width: 0; + height: 0; + border-left: 8px solid #e0e0e0; + border-top: 8px solid transparent; + border-bottom: 8px solid transparent; +} + +.nav-step:last-child::after { + display: none; +} + +.nav-number { + width: 36px; + height: 36px; + border-radius: 50%; + background: #e0e0e0; + color: #999; + display: flex; + align-items: center; + justify-content: center; + font-weight: bold; + font-size: 16px; + margin-bottom: 8px; + transition: all 0.3s ease; +} + +.nav-label { + font-size: 12px; + color: #999; + text-align: center; + transition: all 0.3s ease; +} + +.nav-step.active .nav-number { + background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); + color: white; + transform: scale(1.1); +} + +.nav-step.active .nav-label { + color: #667eea; + font-weight: 600; +} + +.nav-step.completed .nav-number { + background: #4caf50; + color: white; +} + +.nav-step.completed .nav-label { + color: #4caf50; +} + +.nav-step.disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.nav-step:not(.disabled):hover { + background: #f5f5f5; +} + +/* Validation Error */ +.validation-error { + background: #ffebee; + border-left: 4px solid #f44336; + color: #c62828; + padding: 15px 20px; + border-radius: 8px; + margin-bottom: 20px; + font-weight: 500; + box-shadow: 0 2px 8px rgba(244, 67, 54, 0.2); + animation: slideDown 0.3s ease; +} + +.success-message { + background: #e8f5e9; + border-left: 4px solid #4caf50; + color: #2e7d32; + padding: 15px 20px; + border-radius: 8px; + margin-bottom: 20px; + font-weight: 500; + box-shadow: 0 2px 8px rgba(76, 175, 80, 0.2); + animation: slideDown 0.3s ease; +} + +@keyframes slideDown { + from { + opacity: 0; + transform: translateY(-10px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +.import-section { + margin-top: 20px; +} + +.import-btn { + display: inline-block; + background: #764ba2; + color: white; + padding: 12px 24px; + border-radius: 8px; + cursor: pointer; + font-size: 14px; + font-weight: 600; + transition: all 0.3s; +} + +.import-btn:hover { + background: #5a3782; + transform: translateY(-2px); + box-shadow: 0 4px 12px rgba(118, 75, 162, 0.4); +} + +/* Wizard Container */ +.wizard-container { + background: white; + padding: 40px; + border-radius: 12px; + box-shadow: 0 8px 16px rgba(0,0,0,0.1); + min-height: 500px; +} + +.step { + display: none; +} + +.step.active { + display: block; + animation: fadeIn 0.3s; +} + +@keyframes fadeIn { + from { opacity: 0; transform: translateY(10px); } + to { opacity: 1; transform: translateY(0); } +} + +.step h2 { + color: #333; + font-size: 24px; + margin-bottom: 10px; +} + +.description { + color: #666; + font-size: 14px; + margin-bottom: 30px; +} + +/* Cards - Odin Style */ +.cards { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); + gap: 20px; + margin-bottom: 30px; +} + +.cards.inline { + grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); + gap: 15px; +} + +.card { + background: #f8f9fa; + border: 3px solid #e0e0e0; + border-radius: 12px; + padding: 30px 20px; + text-align: center; + cursor: pointer; + transition: all 0.3s; + position: relative; +} + +.card:hover { + border-color: #667eea; + background: #f0f4ff; + transform: translateY(-5px); + box-shadow: 0 8px 20px rgba(102, 126, 234, 0.2); +} + +.card.selected { + border-color: #667eea; + background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); + color: white; +} + +.card.selected h3, +.card.selected h4, +.card.selected p, +.card.selected small { + color: white; +} + +.card-icon { + font-size: 48px; + margin-bottom: 15px; +} + +.vendor-icon { + font-size: 32px; + margin-bottom: 10px; +} + +.card h3 { + color: #333; + font-size: 18px; + margin-bottom: 10px; +} + +.card h4 { + color: #333; + font-size: 16px; + margin-bottom: 5px; +} + +.card-detail { + color: #555; + font-size: 14px; + margin-bottom: 5px; +} + +.card-hint { + color: #888; + font-size: 12px; + font-style: italic; +} + +.card.small { + padding: 20px 15px; +} + +.card.small h4 { + font-size: 14px; +} + +.card.small small { + font-size: 11px; + color: #666; +} + +/* Form Elements */ +.form-container { + margin-bottom: 30px; +} + +.form-group { + margin-bottom: 25px; +} + +.form-row { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 20px; +} + +.form-group label { + display: block; + font-weight: 600; + color: #333; + margin-bottom: 8px; + font-size: 14px; +} + +.required { + color: #e74c3c; +} + +.optional-badge { + display: inline-block; + background: #e3f2fd; + color: #1976d2; + padding: 4px 12px; + border-radius: 12px; + font-size: 12px; + font-weight: 600; + margin-left: 8px; +} + +.form-group input[type="text"], +.form-group input[type="number"] { + width: 100%; + padding: 12px 15px; + border: 2px solid #ddd; + border-radius: 8px; + font-size: 14px; + transition: border-color 0.3s; +} + +.form-group input:focus { + outline: none; + border-color: #667eea; +} + +.form-group small { + display: block; + color: #888; + font-size: 12px; + margin-top: 4px; +} + +/* VLAN/Port/BGP Cards */ +.vlan-card, +.port-card, +.bgp-card { + background: #f8f9fa; + padding: 20px; + border-radius: 10px; + margin-bottom: 20px; + border-left: 4px solid #667eea; +} + +.vlan-card h3, +.port-card h3, +.bgp-card h3 { + color: #333; + font-size: 16px; + margin-bottom: 15px; +} + +.vlan-card h4, +.port-card h4, +.bgp-card h4 { + color: #555; + font-size: 14px; + margin-top: 20px; + margin-bottom: 10px; +} + +/* Auto-note */ +.auto-note { + background: #e8f4f8; + border-left: 4px solid #3498db; + padding: 12px 15px; + border-radius: 6px; + color: #2c3e50; + font-size: 13px; + margin-top: 20px; +} + +.auto-note strong { + color: #2980b9; +} + +/* Buttons */ +.btn-next, +.btn-back, +.btn-export, +.btn-reset { + padding: 14px 28px; + border: none; + border-radius: 8px; + font-size: 15px; + font-weight: 600; + cursor: pointer; + transition: all 0.3s; +} + +.btn-next { + background: #667eea; + color: white; + width: 100%; +} + +.btn-next:hover { + background: #5568d3; + transform: translateY(-2px); + box-shadow: 0 6px 16px rgba(102, 126, 234, 0.4); +} + +.btn-back { + background: #95a5a6; + color: white; +} + +.btn-back:hover { + background: #7f8c8d; +} + +.btn-group { + display: flex; + gap: 15px; + margin-top: 30px; +} + +.btn-group .btn-next { + flex: 1; +} + +/* Export Page */ +.summary-container { + margin-bottom: 30px; +} + +#config-summary { + background: #f8f9fa; + padding: 20px; + border-radius: 10px; + margin-bottom: 20px; + border-left: 4px solid #667eea; +} + +#config-summary h3 { + color: #333; + font-size: 18px; + margin-bottom: 15px; +} + +#config-summary p { + color: #555; + font-size: 13px; + margin-bottom: 8px; + line-height: 1.6; +} + +.summary-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(250px, 1fr)); + gap: 20px; + margin-top: 15px; +} + +.summary-section { + background: white; + padding: 15px; + border-radius: 8px; + border: 1px solid #e0e0e0; +} + +.summary-section h4 { + color: #667eea; + font-size: 14px; + margin-bottom: 10px; + padding-bottom: 8px; + border-bottom: 2px solid #e0e0e0; +} + +.summary-section p { + font-size: 12px; + margin-bottom: 6px; +} + + +#config-summary strong { + color: #333; +} + +.preview-container { + background: #2c3e50; + padding: 20px; + border-radius: 10px; +} + +.preview-container h3 { + color: #ecf0f1; + font-size: 16px; + margin-bottom: 15px; +} + +.preview-container pre { + background: #34495e; + color: #ecf0f1; + padding: 15px; + border-radius: 8px; + overflow-x: auto; + font-family: 'Courier New', monospace; + font-size: 12px; + line-height: 1.6; + max-height: 400px; +} + +.export-buttons { + display: flex; + gap: 15px; + justify-content: center; +} + +.btn-export { + background: #27ae60; + color: white; + flex: 1; + max-width: 250px; +} + +.btn-export:hover { + background: #229954; + transform: translateY(-2px); + box-shadow: 0 6px 16px rgba(39, 174, 96, 0.4); +} + +.btn-reset { + background: #e74c3c; + color: white; +} + +.btn-reset:hover { + background: #c0392b; + transform: translateY(-2px); + box-shadow: 0 6px 16px rgba(231, 76, 60, 0.4); +} + +/* Custom Modal */ +.modal-overlay { + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + background: rgba(0, 0, 0, 0.6); + display: flex; + align-items: center; + justify-content: center; + z-index: 9999; + animation: fadeIn 0.2s; +} + +.modal-content { + background: white; + padding: 30px; + border-radius: 12px; + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3); + max-width: 450px; + width: 90%; + animation: slideUp 0.3s; +} + +@keyframes slideUp { + from { + opacity: 0; + transform: translateY(20px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +.modal-content h3 { + color: #333; + font-size: 20px; + margin-bottom: 15px; +} + +.modal-content p { + color: #666; + font-size: 15px; + line-height: 1.6; + margin-bottom: 25px; +} + +.modal-buttons { + display: flex; + gap: 12px; + justify-content: flex-end; +} + +.btn-cancel { + padding: 12px 24px; + border: 2px solid #ddd; + border-radius: 8px; + background: white; + color: #666; + font-size: 14px; + font-weight: 600; + cursor: pointer; + transition: all 0.3s; +} + +.btn-cancel:hover { + border-color: #999; + color: #333; + background: #f5f5f5; +} + +.btn-confirm { + padding: 12px 24px; + border: none; + border-radius: 8px; + background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); + color: white; + font-size: 14px; + font-weight: 600; + cursor: pointer; + transition: all 0.3s; +} + +.btn-confirm:hover { + transform: translateY(-2px); + box-shadow: 0 4px 12px rgba(102, 126, 234, 0.4); +} + +/* Responsive */ +@media (max-width: 768px) { + .top-nav { + flex-wrap: wrap; + padding: 15px; + } + + .nav-step { + flex-basis: calc(33.333% - 10px); + padding: 10px 5px; + } + + .nav-step::after { + display: none; + } + + .nav-label { + font-size: 10px; + } + + .form-row { + grid-template-columns: 1fr; + } + + .cards { + grid-template-columns: 1fr; + } + + .export-buttons { + flex-direction: column; + } + + .btn-export, .btn-reset { + max-width: 100%; + } + + .summary-grid { + grid-template-columns: 1fr; + } +} diff --git a/tests/wizard-e2e.spec.ts b/tests/wizard-e2e.spec.ts new file mode 100644 index 0000000..3b2a540 --- /dev/null +++ b/tests/wizard-e2e.spec.ts @@ -0,0 +1,509 @@ +import { test, expect } from '@playwright/test'; + +/** + * Azure Local Switch Configuration Wizard - E2E Tests + * + * Tests for Odin-style UI with single-page scroll layout. + * + * TIMEOUT CONFIG (playwright.config.ts): + * - Per test: 30 seconds + * - Global: 3 minutes + * - Action: 10 seconds + * - Navigation: 15 seconds + * - Expect: 5 seconds + */ + +// Set default timeout for all tests in this file +test.setTimeout(30000); + +// ============================================================================ +// TEST HELPERS +// ============================================================================ + +/** Complete Phase 1 (Pattern & Switch) quickly */ +async function setupSwitch(page: any, options: { + pattern?: string; + vendor?: string; + model?: string; + role?: string; +} = {}) { + const { + pattern = 'fully_converged', + vendor = 'dellemc', + model = 's5248f-on', + role = 'TOR1' + } = options; + + // Select pattern + await page.locator(`.pattern-card[data-pattern="${pattern}"] h4`).click(); + await page.waitForTimeout(100); + + // Select hardware + await page.selectOption('#vendor-select', vendor); + await page.selectOption('#model-select', model); + + // Select role (class is .role-btn not .role-card) + await page.click(`.role-btn[data-role="${role}"]`); + await page.waitForTimeout(100); +} + +/** Load a template quickly */ +async function loadTemplate(page: any, pattern = 'Fully Converged', role = 'TOR1') { + await page.click('button:has-text("Load")', { timeout: 5000 }); + await expect(page.locator('#template-modal')).toBeVisible({ timeout: 5000 }); + + if (pattern === 'Fully Converged') { + await page.click(`.template-card:has-text("${role}")`, { timeout: 5000 }); + } else { + const section = page.locator(`.template-category:has-text("${pattern}") + .template-grid`); + await section.locator(`.template-card:has-text("${role}")`).click({ timeout: 5000 }); + } + await page.waitForTimeout(300); +} + + +// ============================================================================ +// 1. PAGE LOAD & LAYOUT +// ============================================================================ + +test.describe('1. Page Load & Layout', () => { + + test('loads with header and title', async ({ page }) => { + await page.goto('/'); + await expect(page).toHaveTitle(/Azure Local Switch Configuration/i); + await expect(page.locator('h1')).toContainText('Azure Local'); + }); + + test('displays navigation elements', async ({ page }) => { + await page.goto('/'); + // Check for breadcrumb nav (primary navigation) + const hasBreadcrumb = await page.locator('.breadcrumb-nav').count() > 0; + expect(hasBreadcrumb).toBeTruthy(); + }); +}); + + +// ============================================================================ +// 2. NAVIGATION +// ============================================================================ + +test.describe('2. Navigation', () => { + + test('breadcrumb navigation items are clickable', async ({ page }) => { + await page.goto('/'); + + // Click second breadcrumb item + const breadcrumbItems = page.locator('.breadcrumb-item'); + if (await breadcrumbItems.count() > 1) { + await breadcrumbItems.nth(1).click(); + await page.waitForTimeout(300); + } + }); + + test('all 6 sections are visible on scroll', async ({ page }) => { + await page.goto('/'); + + // All wizard-step sections should be visible (single-page scroll) + await expect(page.locator('#phase1')).toBeVisible(); + await expect(page.locator('#phase2')).toBeVisible(); + await expect(page.locator('#phase2-ports')).toBeVisible(); + await expect(page.locator('#phase2-redundancy')).toBeVisible(); + await expect(page.locator('#phase3')).toBeVisible(); + await expect(page.locator('#review')).toBeVisible(); + }); +}); + + +// ============================================================================ +// 3. PATTERN SELECTION +// ============================================================================ + +test.describe('3. Pattern Selection', () => { + + test('displays 3 pattern cards', async ({ page }) => { + await page.goto('/'); + await expect(page.locator('.pattern-card')).toHaveCount(3); + await expect(page.locator('.pattern-card[data-pattern="switchless"]')).toBeVisible(); + await expect(page.locator('.pattern-card[data-pattern="switched"]')).toBeVisible(); + await expect(page.locator('.pattern-card[data-pattern="fully_converged"]')).toBeVisible(); + }); + + test('selecting pattern marks it as selected', async ({ page }) => { + await page.goto('/'); + await page.locator('.pattern-card[data-pattern="fully_converged"] h4').click(); + + const selectedCard = page.locator('.pattern-card[data-pattern="fully_converged"]'); + await expect(selectedCard).toHaveClass(/selected/); + }); + + test('selecting pattern reveals hardware section', async ({ page }) => { + await page.goto('/'); + await page.locator('.pattern-card[data-pattern="switched"] h4').click(); + + await expect(page.locator('#vendor-select')).toBeVisible(); + }); +}); + + +// ============================================================================ +// 4. HARDWARE SELECTION +// ============================================================================ + +test.describe('4. Hardware Selection', () => { + + test('vendor dropdown populates model dropdown', async ({ page }) => { + await page.goto('/'); + await page.click('.pattern-card[data-pattern="fully_converged"]'); + + await expect(page.locator('#model-select')).toBeDisabled(); + await page.selectOption('#vendor-select', 'cisco'); + await expect(page.locator('#model-select')).not.toBeDisabled(); + }); + + test('model selection reveals role section', async ({ page }) => { + await page.goto('/'); + await page.click('.pattern-card[data-pattern="fully_converged"]'); + await page.selectOption('#vendor-select', 'dellemc'); + await page.selectOption('#model-select', 's5248f-on'); + + await expect(page.locator('.role-btn[data-role="TOR1"]')).toBeVisible(); + }); + + test('role selection auto-generates hostname', async ({ page }) => { + await page.goto('/'); + await setupSwitch(page); + + const hostname = page.locator('#hostname'); + await expect(hostname).toHaveValue(/tor1/i); + }); +}); + + +// ============================================================================ +// 5. SUMMARY SIDEBAR +// ============================================================================ + +test.describe('5. Summary Sidebar', () => { + + test('sidebar or summary panel exists', async ({ page }) => { + await page.goto('/'); + // Config summary sidebar should exist + const hasSidebar = await page.locator('#config-summary-sidebar, .config-summary-sidebar').count() > 0; + expect(hasSidebar).toBeTruthy(); + }); + + test('pattern selection updates config summary', async ({ page }) => { + await page.goto('/'); + await page.locator('.pattern-card[data-pattern="fully_converged"] h4').click(); + + // Config summary should update with pattern + await expect(page.locator('#sum-pattern')).toContainText(/converged/i); + }); + + test('config summary updates on selection', async ({ page }) => { + await page.goto('/'); + await setupSwitch(page); + + // Summary should reflect selections + const summaryText = await page.locator('#sum-pattern, #summary-content').textContent(); + expect(summaryText).toBeTruthy(); + }); +}); + + +// ============================================================================ +// 6. VLAN CONFIGURATION +// ============================================================================ + +test.describe('6. VLAN Configuration', () => { + + test('fully converged pattern shows both storage VLANs', async ({ page }) => { + await page.goto('/'); + await loadTemplate(page, 'Fully Converged', 'TOR1'); + + // VLANs section is now always visible (single-page scroll) + await expect(page.locator('#vlan-storage1-id')).toHaveValue('711'); + await expect(page.locator('#vlan-storage2-id')).toHaveValue('712'); + }); + + test('VLAN name auto-updates on ID change', async ({ page }) => { + await page.goto('/'); + await loadTemplate(page); + + // VLANs visible without navigation + await page.fill('#vlan-storage1-id', '800'); + await page.locator('#vlan-storage1-id').dispatchEvent('change'); + + await expect(page.locator('#vlan-storage1-name')).toHaveValue(/800/); + }); + + test('switchless pattern has empty storage VLANs', async ({ page }) => { + await page.goto('/'); + await loadTemplate(page, 'Switchless', 'TOR1'); + + // VLANs visible without navigation + await expect(page.locator('#vlan-storage1-id')).toHaveValue(''); + }); +}); + + +// ============================================================================ +// 7. PORT CONFIGURATION +// ============================================================================ + +test.describe('7. Port Configuration', () => { + + test('fully converged shows converged port section', async ({ page }) => { + await page.goto('/'); + await setupSwitch(page, { pattern: 'fully_converged' }); + + // Port section visible on single-page scroll + await expect(page.locator('#port-section-converged')).toBeVisible(); + }); + + test('switched TOR1 shows Storage1 section', async ({ page }) => { + await page.goto('/'); + await setupSwitch(page, { pattern: 'switched', role: 'TOR1' }); + + // Port sections visible on single-page scroll + await expect(page.locator('#port-section-storage1')).toBeVisible(); + await expect(page.locator('#port-section-storage2')).not.toBeVisible(); + }); + + test('switched TOR2 shows Storage2 section', async ({ page }) => { + await page.goto('/'); + await setupSwitch(page, { pattern: 'switched', role: 'TOR2' }); + + // Port sections visible on single-page scroll + await expect(page.locator('#port-section-storage2')).toBeVisible(); + await expect(page.locator('#port-section-storage1')).not.toBeVisible(); + }); +}); + + +// ============================================================================ +// 8. ROUTING CONFIGURATION +// ============================================================================ + +test.describe('8. Routing Configuration', () => { + + test('BGP and Static options visible', async ({ page }) => { + await page.goto('/'); + await loadTemplate(page); + + // Routing section visible on single-page scroll + await expect(page.locator('.routing-card[data-routing="bgp"]')).toBeVisible(); + await expect(page.locator('.routing-card[data-routing="static"]')).toBeVisible(); + }); + + test('can add BGP neighbor', async ({ page }) => { + await page.goto('/'); + await loadTemplate(page); + + // BGP section visible on single-page scroll + const initialCount = await page.locator('.neighbor-entry').count(); + await page.click('#btn-add-neighbor'); + + await expect(page.locator('.neighbor-entry')).toHaveCount(initialCount + 1); + }); +}); + + +// ============================================================================ +// 9. TEMPLATE LOADING +// ============================================================================ + +test.describe('9. Template Loading', () => { + + test('template modal opens and closes', async ({ page }) => { + await page.goto('/'); + + await page.click('button:has-text("Load")'); + await expect(page.locator('#template-modal')).toBeVisible(); + + await page.click('.modal-close'); + await expect(page.locator('#template-modal')).not.toBeVisible(); + }); + + test('loading template populates form', async ({ page }) => { + await page.goto('/'); + await loadTemplate(page, 'Fully Converged', 'TOR1'); + + await expect(page.locator('#hostname')).toHaveValue('sample-tor1'); + // Config summary should update + await expect(page.locator('#sum-pattern')).toContainText(/converged/i); + }); + + test('switched template loads correct VLANs', async ({ page }) => { + await page.goto('/'); + await loadTemplate(page, 'Switched', 'TOR1'); + + // VLANs visible on single-page scroll + await expect(page.locator('#vlan-storage1-id')).toHaveValue('711'); + }); +}); + + +// ============================================================================ +// 10. EXPORT FUNCTIONALITY +// ============================================================================ + +test.describe('10. Export Functionality', () => { + + test('JSON preview is visible in review', async ({ page }) => { + await page.goto('/'); + await loadTemplate(page); + + // Review section visible on single-page scroll + await expect(page.locator('#json-preview')).toBeVisible(); + }); + + test('export button triggers download', async ({ page }) => { + await page.goto('/'); + await loadTemplate(page); + + // Export button visible on single-page scroll + const downloadPromise = page.waitForEvent('download', { timeout: 10000 }); + await page.click('#btn-export'); + const download = await downloadPromise; + + expect(download.suggestedFilename()).toMatch(/\.json$/); + }); +}); + + +// ============================================================================ +// 11. IMPORT FUNCTIONALITY +// ============================================================================ + +test.describe('11. Import Functionality', () => { + + test('import JSON file works', async ({ page }) => { + await page.goto('/'); + + const testConfig = { + switch: { + vendor: 'dellemc', + model: 's5248f-on', + firmware: 'os10', + hostname: 'imported-switch', + role: 'TOR1', + deployment_pattern: 'fully_converged' + }, + vlans: [{ vlan_id: 7, name: 'Mgmt_7', purpose: 'management' }] + }; + + await page.locator('input#import-json').setInputFiles({ + name: 'test-config.json', + mimeType: 'application/json', + buffer: Buffer.from(JSON.stringify(testConfig)) + }); + await page.waitForTimeout(300); + + await expect(page.locator('#hostname')).toHaveValue('imported-switch'); + }); +}); + + +// ============================================================================ +// 12. START OVER / RESET +// ============================================================================ + +test.describe('12. Start Over', () => { + + test('start over resets with confirmation', async ({ page }) => { + await page.goto('/'); + await loadTemplate(page); + + page.on('dialog', dialog => dialog.accept()); + + // Reset button visible on single-page scroll + await page.click('#btn-reset'); + await page.waitForTimeout(300); + + // Check that form is reset + await expect(page.locator('.pattern-card.selected')).toHaveCount(0); + }); +}); + + +// ============================================================================ +// 13. CRITICAL BUSINESS RULES +// ============================================================================ + +test.describe('13. Critical Business Rules', () => { + + test('peer-link tagged_vlans excludes storage VLANs', async ({ page }) => { + await page.goto('/'); + await loadTemplate(page, 'Fully Converged', 'TOR1'); + + // JSON preview visible on single-page scroll + await page.waitForTimeout(500); + + const jsonText = await page.locator('#json-preview').textContent(); + const config = JSON.parse(jsonText || '{}'); + + // Should have port_channels with peer-link + const peerLink = config.port_channels?.find((pc: any) => pc.vpc_peer_link === true); + if (peerLink) { + // If peer-link exists, tagged_vlans should NOT include 711 or 712 (storage) + const taggedVlans = peerLink.tagged_vlans || ''; + expect(taggedVlans).not.toContain('711'); + expect(taggedVlans).not.toContain('712'); + } + // Test passes if no peer-link (config structure may vary) + }); + + test('switchless pattern excludes storage VLANs from config', async ({ page }) => { + await page.goto('/'); + await loadTemplate(page, 'Switchless', 'TOR1'); + + // JSON preview visible on single-page scroll + await page.waitForTimeout(300); + + const jsonText = await page.locator('#json-preview').textContent(); + const config = JSON.parse(jsonText || '{}'); + + // Should not have storage VLANs + const storageVlans = config.vlans?.filter((v: any) => + v.purpose === 'storage_1' || v.purpose === 'storage_2' + ); + expect(storageVlans?.length || 0).toBe(0); + }); +}); + + +// ============================================================================ +// 14. UI COMPONENTS +// ============================================================================ + +test.describe('14. UI Components', () => { + + test('pattern cards show selected state', async ({ page }) => { + await page.goto('/'); + + // Click a pattern card + await page.locator('.pattern-card[data-pattern="switched"]').click(); + + // Verify selected state + await expect(page.locator('.pattern-card[data-pattern="switched"]')).toHaveClass(/selected/); + await expect(page.locator('.pattern-card[data-pattern="fully_converged"]')).not.toHaveClass(/selected/); + + // Click different pattern + await page.locator('.pattern-card[data-pattern="fully_converged"]').click(); + await expect(page.locator('.pattern-card[data-pattern="fully_converged"]')).toHaveClass(/selected/); + await expect(page.locator('.pattern-card[data-pattern="switched"]')).not.toHaveClass(/selected/); + }); + + test('collapsible BMC section toggles', async ({ page }) => { + await page.goto('/'); + await loadTemplate(page); + + // BMC section visible on single-page scroll + const bmcContent = page.locator('#vlan-bmc-section .collapsible-content'); + await expect(bmcContent).not.toBeVisible(); + + await page.click('#vlan-bmc-section .collapsible-header'); + await expect(bmcContent).toBeVisible(); + }); +});