---
name: omada-controller-administration
description: >-
  Class-level skill for TP-Link Omada Cloud Controller administration via browser automation.
  Covers login flow, SPA navigation quirks, iframe-based site management, VLAN configuration,
  ACL rules, inter-VLAN mDNS limitations, WLAN/channel optimization, device management,
  and firmware updates. Applies to cloud-based Omada controllers at omada.tplinkcloud.com.
  device management, and firmware updates. Applies to cloud-based Omada controllers at omada.tplinkcloud.com.
tags:
  - tp-link
  - omada
  - wlan
  - channel-optimization
  - network-management
  - browser-automation
---

# TP-Link Omada Cloud Controller Administration

## When to Use
Any task involving TP-Link Omada Cloud Controller — WLAN configuration, channel optimization, device management, firmware updates, site settings, or viewing logs/alerts.

## Environment
- **URL**: `https://omada.tplinkcloud.com/`
- **Credentials**: 1Password vault "Hermes", item "omada - Netzwerkverwaltung"
- **Controller name**: "Schön" (Cloud Based System)
- **Site name**: "Rohr"
- **Devices** (all on VLAN 1 / 172.16.1.x management subnet):
  - Router: DR3650v-4G HW v1.0, FW 1.1.1, IP 172.16.1.1 (also 10.0.30.1 on VLAN 30)
  - Switch: SG3428MP HW v6.20, FW 6.20.26 (update 6.20.27 avail), IP 172.16.1.2
  - AP Erdgeschoss: EAP683 UR v1.0, FW 1.4.0, IP 172.16.1.3 (switch port 16)
  - AP Obergeschoss: EAP683 UR v1.0, FW 1.4.0, IP 172.16.1.4 (switch port 14)
  - AP Garage: EAP615-Wall v1.0, FW 1.5.4, IP 172.16.1.5 (switch port 21)
  - AP Garten: EAP225-Outdoor v3.0, FW 5.2.2, IP 172.16.1.6
- **Local router fallback**: `http://172.16.1.1/webpages/login.html` (login blocked when managed by controller — see "Known Issue" section)
- **Local switch UI**: `http://172.16.1.2` (accessible but requires unknown device-specific credentials)
- **Monitoring stack**: CT 141 (proxmox7), Docker-based: prometheus (:9090), grafana (:3000), alertmanager, blackbox, pve-exporter, telegram-bridge (:9099). snmp_exporter runs as systemd binary (:9116) — Docker deployment fails on CT 141.
- **Grafana dashboard**: `network-infra` (uid: `network-infra`) — Proxmox nodes, guests, HA state, blackbox probes. Omada metrics NOT included (no exporter available for cloud controller).
- **Local Omada Controller** (in progress): CT 142 (`omada-controller`, 10.0.30.142/24, VLAN 30, proxmox7, vm_disks storage). Will replace cloud controller to enable SNMP + API exporters.
- **Docker hosts in cluster**: Only CT 141 (6 containers: monitoring stack) and CT 121 (1 container: portainer). All other CTs/VMs run services natively without Docker.

## Login Flow

### Steps
1. Navigate to `https://omada.tplinkcloud.com/#/login`
2. Wait 5-8 seconds — the SPA loads slowly, initial snapshot shows `(empty page)` with 0 elements
3. Take snapshot to confirm login form is visible
4. Type TP-Link ID into the "TP-Link ID (Partner ID)" textbox
5. Type password into the "Password" textbox (nested inside a generic wrapper — use the inner textbox ref, not the outer one)
6. Press **Enter** (do NOT click the "Sign In" link — it redirects to tp-link.com marketing site instead of submitting the form)
7. Wait 5-8 seconds for redirect
8. Dismiss cookie banner if present ("Alle Cookies akzeptieren")
9. Dismiss any notice dialogs ("OK" or "Do not remind again")

### Alternative: Console-Based Login

Instead of `browser_type` + `browser_press`, you can fill the login form via `browser_console`:

```javascript
(async function(){
  const sleep = ms => new Promise(r => setTimeout(r, ms));
  const inputs = document.querySelectorAll('input');
  if (inputs.length >= 2) {
    inputs[0].focus();
    inputs[0].value = 'dominik@familie-schoen.com';
    inputs[0].dispatchEvent(new Event('input', {bubbles: true}));
    const pwInputs = document.querySelectorAll('input[type="password"]');
    if (pwInputs.length > 0) {
      pwInputs[pwInputs.length - 1].focus();
      pwInputs[pwInputs.length - 1].value = '<password>';
      pwInputs[pwInputs.length - 1].dispatchEvent(new Event('input', {bubbles: true}));
    }
  }
  await sleep(500);
  // Submit via Enter keydown event
  document.dispatchEvent(new KeyboardEvent('keydown', {key: 'Enter', code: 'Enter', keyCode: 13, which: 13, bubbles: true}));
  // Also click Sign In link as backup
  for (const el of document.querySelectorAll('a, button')) {
    if (el.textContent.trim() === 'Sign In' && el.tagName === 'A') { el.click(); break; }
  }
  return 'login submitted';
})()
```

> Note: This approach dispatches React-compatible `input` events. The `browser_type` + `Enter` approach is simpler and equally reliable for the login form.

### Critical Pitfalls
- **Empty page on navigate**: The Omada SPA frequently returns `(empty page)` with `element_count: 0` right after navigation. Always wait 5-8s then re-snapshot before concluding the page failed to load.
- **"Sign In" link vs form submit**: The "Sign In" element (`@e9` in observed session) is a **link**, not a submit button. Clicking it navigates to `https://www.tp-link.com/de/support/`. Use `browser_press` with `Enter` while focused in the password field to submit the form.
- **Password field nesting**: The password input is wrapped in a generic div containing two textboxes — use the one with aria-label "Password" (the inner ref), not the outer wrapper ref.
- **Multiple dismissable dialogs**: After login, expect up to 3 sequential dialogs: cookie consent, regional isolation notice, and firmware/system update announcement. Dismiss each before proceeding.
- **Session expiry / white screen**: After extended interaction (especially after clicking buttons inside the iframe), the SPA may go completely blank/white. The iframe disappears from the DOM entirely (`document.querySelector('iframe')` returns null). Recovery: re-navigate to `https://omada.tplinkcloud.com/#/login` and repeat the full login flow. The session does NOT persist across browser navigations.
- **`browser_console` variable re-declaration**: Repeated `browser_console` calls that declare variables (e.g., `const fr = document.querySelector(...)`) fail with `SyntaxError: Identifier 'xxx' has already been declared` because the execution context persists between calls. Always wrap code in an IIFE: `(function(){ ... })()` to avoid polluting the persistent scope.
- **`browser_click` refs for iframe content**: Refs from `browser_snapshot` for elements inside the iframe (e.g., `@e14` for "Optimize Now") sometimes fail with "Unknown ref". Fall back to `browser_console` with IIFE-scoped DOM queries to click iframe-internal elements directly.

## Navigation: Controller → Site → WLAN

### Structure
```
Omada Cloud Landing
  └─ Dropdown: "On Premise Systems" / "Cloud Based Systems" → select "Cloud Based Systems"
      └─ Controller card ("Schön") → click to enter
          └─ Global View (org overview, site list)
              └─ Site "Rohr" → click site name cell
                  └─ Site Dashboard with left nav:
                      ├─ Management: Dashboard, Devices, Clients
                      ├─ Monitoring: Map, Logs
                      ├─ Configuration: Network Config, Device Config
                      ├─ Hotspot
                      └─ Maintenance: Network Tools, IntelliRecover
```

### Switching to Cloud Based Systems
The top-left dropdown defaults to "On Premise Systems". Click the combobox, then use `browser_console` to find and click the "Cloud Based Systems" `<li>` inside `.ant-dropdown-menu`.

### Switching Back to a Site from Global View
After performing actions in Global View (e.g., checking org-wide logs), returning to a specific site requires navigating through the Dashboard site list:

1. Click **Dashboard** in the Global View sidebar
2. Find the site name in the Site List table
3. Click the `span.text-link.cursor-pointer` inside the site name cell (NOT the `<td>` itself — it has no click handler)
4. Verify by checking the sidebar: site-level menus (Management, Monitoring, Configuration, Hotspot, Maintenance) should appear instead of Global View menus (Dashboard, Devices, Logs, SD-WAN, Accounts, Settings)

**Pitfall: Combobox Does Not List Sites**
The top-left combobox (showing controller name "Schön") opens a dropdown with controller/system options (e.g., "SchönEssentials") but does **NOT** list individual sites. It cannot be used to switch between sites. Always use the Dashboard → Site List table to navigate to a specific site.

### Entering a Site
Click the site name in the Site List table. **Pitfall**: clicking the `<td>` cell itself does NOT navigate into the site — the cell has `cursor: auto` and no click handler. The actual clickable element is a `span.text-link.cursor-pointer` *inside* the cell. Double-clicking the cell also does not work. Use `browser_console`:

```javascript
(function(){
  const fr = document.querySelector('iframe');
  if (!fr || !fr.contentDocument) return 'no iframe';
  const spans = fr.contentDocument.querySelectorAll('span.text-link, .cursor-pointer');
  for (const s of spans) {
    if (s.textContent.trim() === 'Rohr') { s.click(); return 'clicked Rohr text-link'; }
  }
  return 'not found';
})()
```

**Verification**: After clicking, the sidebar should show site-level menus (Management, Monitoring, Configuration, Hotspot, Maintenance) instead of Global View menus (Dashboard, Devices, Logs, SD-WAN, Accounts, Settings). If you still see "SD-WAN" or "Accounts" in the sidebar, you're still in Global View — the click didn't work.

### Iframe Navigation

> **Reference**: See `references/navigation-css-structure.md` for detailed CSS class hierarchy of the Omada site iframe navigation.

Once inside a site, all content is rendered inside an `<iframe>`. Standard `browser_click` refs from the outer snapshot may not work for iframe-internal elements. Use `browser_console` with `document.querySelector('iframe').contentDocument` to find and click menu items:

```javascript
(function(){
  const fr = document.querySelector('iframe');
  if (!fr || !fr.contentDocument) return 'no iframe';
  const items = fr.contentDocument.querySelectorAll('li');
  for (const item of items) {
    if (item.textContent.trim() === 'WLAN') {
      item.click();
      return 'clicked WLAN';
    }
  }
  return 'not found';
})()
```

### Full Menu Structure (inside site iframe)
- **Configuration** → expands to:
  - Network Config → General Settings (Site Settings, SSH, VoIP), Network Settings (**Internet, LAN, WLAN**), Authentication (Portal), VPN, Security (ACL), Transmission (Routing, NAT, DHCP Reservation, Bandwidth Control, Gateway QoS), Profile (Groups, Time Range, Rate Limit, APN Profile)
  - Device Config → Gateway (DNS), Switch (Switch Ports)

## VLAN Configuration

### VLAN Inventory (site "Rohr")

Navigate to Configuration → Network Config → Network Settings → LAN tab.

| VLAN | Name | Subnet | Purpose |
|------|------|--------|---------|
| 1 | CatchAll(Default) | 172.16.1.1/24 | Default/untagged |
| 10 | Infrastructure | 10.0.10.1/24 | Network infrastructure |
| 20 | Server / Hypervisors | — | PVE hosts (10.0.20.x) |
| 30 | Server / VMs & LXCs | — | Containers & VMs (10.0.30.x) |
| 40 | Clients | — | Client devices (Mac, PC, mobile) |
| 50 | IoT | 10.0.50.1/24 | IoT devices |
| 60 | DMZ | 10.0.60.1/24 | Demilitarized zone |
| 192 | 192.168.100.x | 192.168.100.x | Special purpose |

**Pitfall**: The VLAN list in the LAN tab only shows 4 VLANs by default
(1, 10, 50, 60). VLANs 20, 30, 40, 192 are visible in the **Isolation Settings**
view but not in the main VLAN table. They may be configured elsewhere or
inherited from the switch/gateway.

### VLAN Isolation Settings

Navigate to Configuration → Network Config → Network Settings → LAN → **Isolation Settings** button.

Two zones:
- **Isolated Network** — can only access internet, no inter-VLAN communication
- **Interconnected Network** — can communicate with each other

As of 2026-07-05: **0 isolated**, all 8 VLANs are interconnected (all can
reach each other). Inter-VLAN routing is handled by the DR3650 gateway.

### ACL Rules (Gateway)

Navigate to Configuration → Network Config → Security → ACL.

Key rules (LAN→LAN direction, relevant for inter-VLAN access):

| # | Description | Direction | Proto | Source → Destination |
|---|-------------|-----------|-------|---------------------|
| 1 | Permit Internet to everyone | LAN→WAN | All | All VLANs → Any |
| 5 | Allow Hypervisors from Clients | LAN→LAN | All | Clients → Hypervisors |
| 6 | Allow Clients to VMs | LAN→LAN | **TCP** | Clients → VMs & LXCs |
| 8 | Allow Access to IoT | LAN→LAN | All | Clients/Hyper/VMs/192 → IoT |
| 9 | DENY Botnet Stuff for IoT | LAN→LAN | Deny All | IoT/CatchAll → Clients/Hyper/VMs/Infra |
| 10 | Deny Ingress to VLANs | WAN IN | Deny All | Any → Client/Hyper/Infra/IoT/VM IPs |

**Important**: Rule 6 allows **TCP only** from Clients (VLAN 40) to VMs (VLAN 30).
UDP and ICMP are NOT explicitly permitted — they may be allowed by default
(no deny rule for LAN→LAN between these VLANs), but if you need UDP services
(e.g. mDNS, SNMP) from VLAN 40 to VLAN 30, verify with a packet capture.

### mDNS / Bonjour Across VLANs

**Omada does NOT provide mDNS/multicast relay or IGMP proxy functionality.**

mDNS broadcasts (224.0.0.251:5353) are confined to their originating VLAN.
Bonjour service discovery (AirPlay, Time Machine, printer discovery) does NOT
cross VLAN boundaries.

**Workarounds**:
1. **Manual connection** — clients specify the IP/path directly
   (e.g. `smb://10.0.30.21/share` for Time Machine)
2. **External mDNS repeater** — run `mdns-repeater` or `avahi-reflector` on
   a host with interfaces in both VLANs (e.g. a CT with trunk or dual NIC)

**Deployed Solution**: CT 127 (avahi-reflector) on proxmox6 runs
`avahi-daemon` in reflector mode with interfaces in VLAN 30 (10.0.30.5,
eth0), VLAN 40 (10.0.40.5, eth1), and VLAN 50 (10.0.50.5, eth2).
Configured with a **directional matrix** via iptables:
- 30→40 ✅ (server services visible to clients)
- 50→30 ✅ (IoT services visible to servers)
- 50→40 ✅ (IoT services visible to clients)
- 40→30, 40→50, 30→50 ❌ (blocked by iptables INPUT/OUTPUT rules)

See the `proxmox-ve-administration` skill, `references/avahi-reflector-setup.md`
for full setup details including the multi-VLAN iptables rule pattern.

The DR3650 gateway does not expose multicast forwarding or IGMP proxy settings
in the Omada controller UI. This is a firmware limitation.

### Navigating to VLAN/ACL Views

**VLAN list**: Configuration → Network Config → Network Settings → LAN tab.
Shows only 4 VLANs by default (1, 10, 50, 60). VLANs 20, 30, 40, 192 are
hidden — visible only in Isolation Settings.

**Isolation Settings**: Click the "Isolation Settings" button on the LAN tab
(next to "Add"). Shows all 8 VLANs grouped into Isolated vs Interconnected.
As of 2026-07-05: 0 isolated, all interconnected.

**ACL Rules**: Configuration → Network Config → Security → ACL.
Shows Gateway ACL rules table with INDEX, ENABLED, DESCRIPTION, DIRECTION,
POLICY, PROTOCOLS, SOURCE, DESTINATION, ACTION columns.

## WLAN Management

### SSID Overview
Navigate to Configuration → Network Settings → WLAN. Shows tabs: **SSID**, **WLAN Optimization**, **Optimization History**.

Observed SSIDs (site "Rohr"):
| SSID | Security | Bands | VLAN |
|------|----------|-------|------|
| Schön | WPA-Personal | 2.4/5/6 GHz | 40 |
| Schön - Gäste | WPA-Personal | 2.4/5/6 GHz | — |
| IoT | WPA-Personal | 2.4/5 GHz | 50 |
| 2Schoen | WPA-Personal | 2.4/5 GHz | 40 |

### WLAN Optimization (Channel Optimization)
1. Navigate to WLAN → "WLAN Optimization" tab
2. Click "Optimize Now" link
3. **Optimization Mode dialog** appears with two radio options:
   - **Partial** (default) — scans and optimizes only channels needing adjustment
   - **Global** — full scan and reassignment of all wireless configuration
   - Select mode, then click "Confirm" to start
4. **Warning**: Internet connectivity drops for several minutes during optimization. Schedule during low-traffic periods.
5. Progress can be tracked under "Optimization History" tab
6. "Excluded Devices List" allows excluding specific APs from optimization

### Per-SSID Channel Configuration
To manually set channels (instead of auto-optimization):
1. Go to WLAN → SSID tab
2. Click the SSID name or the action icon in the row
3. Edit the wireless network settings — channel selection is per-band (2.4 GHz, 5 GHz, 6 GHz)
4. Available 5 GHz channels observed: 36, 40, 44, 48, 149, 153, 157, 161

## Async Console Automation Pattern

For multi-step flows (login → dismiss dialogs → navigate to page → action), chaining individual `browser_snapshot` + `browser_click` calls is fragile and slow due to iframe ref instability and session expiry. Instead, use a **single `browser_console` call with an async IIFE** that sleeps between steps:

```javascript
(async function(){
  const sleep = ms => new Promise(r => setTimeout(r, ms));
  // Step 1: close notice
  for (const el of document.querySelectorAll('a, button')) {
    if (el.textContent.trim() === 'OK') { el.click(); break; }
  }
  await sleep(1000);
  // Step 2: click site cell
  let fr = document.querySelector('iframe');
  if (!fr || !fr.contentDocument) return 'no iframe';
  for (const c of fr.contentDocument.querySelectorAll('td, [role="cell"]')) {
    if (c.textContent.trim() === 'Rohr') { c.click(); break; }
  }
  await sleep(5000);
  // Step 3: expand Configuration + click WLAN
  fr = document.querySelector('iframe');
  for (const li of fr.contentDocument.querySelectorAll('li')) {
    if (li.textContent.trim() === 'Configuration' && li.classList.contains('title')) { li.click(); break; }
  }
  await sleep(1000);
  fr = document.querySelector('iframe');
  for (const li of fr.contentDocument.querySelectorAll('li')) {
    if (li.textContent.trim() === 'WLAN' && li.classList.contains('navigator-li-effective')) { li.click(); break; }
  }
  await sleep(3000);
  // ... continue with tab click, Optimize Now, mode selection, Confirm
  return 'done';
})()
```

### Why This Works Better
- **Atomic**: All steps run in one execution context — no risk of session expiring between tool calls
- **No ref fragility**: Direct DOM queries inside iframe don't depend on snapshot refs staying valid
- **Sleep-based timing**: `await sleep(ms)` gives SPA time to render between steps
- **Early return on failure**: Each step checks for iframe existence and returns descriptive error if missing

### Triggering WLAN Optimization (Full Async Flow)

To run the entire optimization (site click → WLAN → Optimization tab → Optimize Now → Global → Confirm) in one shot:

```javascript
(async function(){
  const sleep = ms => new Promise(r => setTimeout(r, ms));
  // ... (site click + Configuration expansion + WLAN click as above)
  // Click "WLAN Optimization" tab
  fr = document.querySelector('iframe');
  for (const el of fr.contentDocument.querySelectorAll('span, div, a, li')) {
    if (el.textContent.trim() === 'WLAN Optimization' && el.children.length <= 1) { el.click(); break; }
  }
  await sleep(3000);
  // Click "Optimize Now"
  fr = document.querySelector('iframe');
  for (const el of fr.contentDocument.querySelectorAll('a, button, span')) {
    if (el.textContent.trim() === 'Optimize Now' && el.children.length <= 2) { el.click(); break; }
  }
  await sleep(2000);
  // Select "Global" radio
  fr = document.querySelector('iframe');
  for (const el of fr.contentDocument.querySelectorAll('label, span, div, input')) {
    if (el.textContent.trim().startsWith('Global')) { el.click(); break; }
  }
  await sleep(1000);
  // Click "Confirm"
  fr = document.querySelector('iframe');
  for (const el of fr.contentDocument.querySelectorAll('a, button')) {
    if (el.textContent.trim() === 'Confirm') { el.click(); return '✅ Global optimization started!'; }
  }
  return 'Confirm not found';
})()
```

### Reading Optimization Results

After the optimization starts, the SPA typically goes blank (session expires). To check results:
1. Re-login (navigate + login flow)
2. Navigate to site Rohr → Configuration → WLAN → "Optimization History" tab
3. Read the iframe body text:

```javascript
(async function(){
  const sleep = ms => new Promise(r => setTimeout(r, ms));
  // ... (navigate to Optimization History tab as above)
  fr = document.querySelector('iframe');
  return fr.contentDocument.body.innerText.substring(0, 3000);
})()
```

The history table shows: TIME, TYPE (Manual/Adaptive), DEVICES, DURATION, MODE, RESULT (score before→after), and ACTION.

Observed result (Global mode, 2026-06-29):
```
Jun 29, 2026 08:36:34 am    3    4m 21s    Manual
86 → 88 (+2)
```

Score scale: 0-100. Improvements of +2-4 points per optimization run are typical. 3 of 4 APs were optimized (one may have already been on an optimal channel).

**Post-optimization**: The optimization generated 2 "monitor link down" alerts (see "Pitfall: Optimization Causes Monitor Link Down Alerts" below). These were batch-resolved, bringing the total to 20 resolved / 0 unresolved.

### Full Optimization Flow (Confirmed Working)

The entire flow — from Global View to optimization confirmation — can be executed as a single async IIFE after login. This was confirmed end-to-end on 2026-06-29:

1. Login (Enter key in password field)
2. Dismiss notice dialog (OK button)
3. Click site "Rohr" cell in iframe
4. Expand Configuration → click WLAN
5. Click "WLAN Optimization" tab
6. Click "Optimize Now" link
7. Select "Global" radio in the optimization mode dialog
8. Click "Confirm"

Steps 3-8 can all run in one `browser_console` async IIFE (see "Triggering WLAN Optimization" code block above). After Confirm, the SPA goes blank — re-login to check results via Optimization History.

### Session Expiry After Confirm

Clicking "Confirm" in the optimization dialog causes the SPA to go blank — the iframe disappears entirely. This is expected: the optimization disrupts connectivity (including the controller's own management plane). Recovery requires a full re-login cycle.

## Device Detail Pages

### Opening Device Details — Two Levels

There are TWO ways to open device details, producing different views:

#### 1. Simple View: Click device name (`span.device-name`)
Click the `span.device-name` element in the Devices table. Opens a
compact detail panel with limited tabs.

```javascript
// Correct selector for device names in the table
const spans = doc.querySelectorAll('span.device-name');
for (const s of spans) {
  if (s.textContent.trim() === 'Router') { s.click(); break; }
}
```

> **Pitfall**: `span.text-link` does NOT match device names in the table.
> The correct CSS class is `span.device-name` (class:
> `device-name u-text-word-break-all ellipsis`). The `span.text-link`
> selector works for site names in the Site List, not device names.

#### 2. Rich View: Click "Manage Device" button
After opening the simple view, click the **"Manage Device"** button
(`a.manage-device-button`). This opens a wider drawer with full tabs:

**Router (DR3650v-4G) tabs in Manage Device view:**
- **Overview** — Upload/Download rate, Temperature, CPU%, Memory%, Channel
  Utilization, Link graph, Current Clients table (shows connected clients
  with MAC, IP, IPv6)
- **Network View** — Network topology visualization
- **Ports** — Port-level statistics (packets, dropped, errors, bytes per port)
- **Logs** — Device-specific log entries
- **Tools** — Network Check, Terminal
- **Config** — Device configuration with left-side menu:
  - **General**: Name, Description, LED, Device Labels, Remember Device
  - **Wireless**: Radio settings
  - **SIM Config**: PIN Management, Statistics, SMS Message, SMS Settings
  - **Transmission**: Routing, NAT, Bandwidth Control, Gateway QoS
  - **Network Security**: ACL
  - **Advanced**: General, DNS
  - **No SNMP section exists** (verified 2026-07-05 — see SNMP Monitoring section)

**Switch (SG3428MP) tabs in Manage Device view:**
- Same 6 tabs (Overview, Network View, Ports, Logs, Tools, Config)
- **Config** menu differs: General, VLAN Interface, Services (Loopback
  Control, Spanning Tree), Static Route
- **No SNMP section exists**

> **Pitfall**: The Config tab's left-side menu uses `.ant-menu-item` class.
> Some items show as `[skeleton-item fake-menu-item]` placeholders that
  never load (20 skeleton items remained permanently on the router config).
  Scrolling the menu container does not trigger lazy loading. This appears
  to be a bug in the Omada SPA when certain firmware versions don't expose
  all expected config sections.

### Known Issue: DR3650 ISP Statistics Empty

**Symptom**: ISP statistics page for the DR3650v-4G router is always empty in Omada.

**Root Cause**: The DR3650v-4G with **Hardware v1.0 / Firmware 1.1.1** does not push ISP-level telemetry (WAN traffic, cellular data volume, signal strength, connection statistics) to the Omada controller. The controller can only read basic device telemetry (temperature, CPU, memory, port status). This is a **hardware/firmware limitation**, not a configuration issue.

**Observations** (2026-06-29):
- Router detail page has only "Overview" and "Ports" tabs — no ISP/Cellular/WAN section
- Active WAN ports: DSL + Port 5 (green/active) — DSL is primary WAN, not 4G/SIM
- Router reports: 75°C, CPU 24%, Memory 57%, uptime 6+ days
- Port statistics ARE available (packets, bytes, errors per port) but aggregated WAN/ISP stats are NOT

**Workaround**: Enable local management on the router to access its built-in web UI directly:
1. In Omada: Configuration → Device Config → Gateway → Local Management → Enable
2. Access router UI at `http://10.0.30.1/webpages/login.html`
3. ISP/cellular statistics are available in the router's own web UI under WAN/Cellular sections

**Pitfall: Router Local Login Blocked When Managed by Controller**
When the DR3650 is adopted by an Omada controller, the local web UI at `http://10.0.30.1/webpages/login.html` displays: *"Note: This Gateway is being managed by Controller [IP]"*. Login attempts with admin credentials or the Omada cloud password will fail. Local management must be explicitly enabled through the Omada controller's Device Config → Gateway → Local Management settings before local login works.

## Logs & Alerts

### Viewing Alerts
Navigate to site → **Logs** (left nav, under Monitoring). The Logs badge shows the count of unresolved alerts. Three tabs: **Alerts**, **Events**, **Audit Logs**.

Filter options:
- **Unresolved / Resolved / All** — toggle between alert states
- **System / Device** — filter by alert source
- **Filter** link — advanced filtering

### Alert Types Observed
| Type | Level | Cause | Action |
|------|-------|-------|--------|
| Device informed monitor link down | Warning | AP lost TCP connection to Controller on port 29817 | Usually transient — see pitfall below |

### Resolving Alerts (Batch Resolve)

To mark multiple alerts as resolved at once:

1. Navigate to site → **Logs** → **Alerts** tab (must be in site view, NOT Global View — Global View only has "Events" and "Audit Logs" tabs, no "Alerts")
2. Click the **"Select all"** checkbox in the table header (first `ant-checkbox-input` in the table)
3. Click **"Batch Resolved"** link (class `s-button s-button-link`) in the toolbar
4. No confirmation dialog appears — alerts are resolved immediately
5. Verify: the "Unresolved" count drops to 0

```javascript
// Async flow: select all + batch resolve
(async function(){
  const sleep = ms => new Promise(r => setTimeout(r, ms));
  const fr = document.querySelector('iframe');
  if (!fr || !fr.contentDocument) return 'no iframe';
  const doc = fr.contentDocument;
  // Select all checkboxes
  const cbs = doc.querySelectorAll('.ant-checkbox-input');
  if (cbs.length > 0) cbs[0].click(); // first visible CB = select all
  await sleep(1000);
  // Click "Batch Resolved"
  for (const l of doc.querySelectorAll('a, button, span')) {
    if (l.textContent.trim() === 'Batch Resolved' && l.offsetParent !== null) {
      l.click();
      return 'batch resolved';
    }
  }
  return 'Batch Resolved not found';
})()
```

### Checking Firmware Versions & Available Updates

Navigate to site → **Devices** (left nav). The device table shows all 6 devices with columns: NAME, IP, STATUS, MODEL, ACTION.

- **Firmware update indicator**: A green upward-arrow icon in the ACTION column signals an available firmware update for that device.
- **"Start Rolling Upgrade"** button (top right) initiates staged firmware updates across multiple devices.
- Devices without the arrow icon are on the latest firmware.

Observed firmware status (2026-06-29):

| Device | Model | HW | Update? |
|--------|-------|----|---------|
| Router | DR3650v-4G | v1.0 | ✅ Current |
| Core (Switch) | SG3428MP | v6.20 | ⬆️ Update available |
| AP Erdgeschoss | EAP683 UR(EU) | v1.0 | ✅ Current |
| AP Obergeschoss | EAP683 UR(EU) | v1.0 | ✅ Current |
| AP Garage | EAP615-Wall(EU) | v1.0 | ✅ Current |
| AP Garten | EAP225-Outdoor(EU) | v3.0 | ✅ Current |

### Pitfall: Optimization Causes "Monitor Link Down" Alerts
Running WLAN Optimization (especially Global mode) disrupts AP connectivity for several minutes. Each affected AP generates a "Device informed monitor link down" Warning alert (TCP connection to Controller on port 29817 failed). These are **expected and harmless** — the APs reconnect after optimization completes. Don't treat them as real network problems. Use "Batch Resolved" (see above) to clear them.

### Pitfall: Global View vs Site View — Different Tab Sets
The Global View Logs page only has "Events" and "Audit Logs" tabs. The "Alerts" tab exists ONLY in the site-level Logs page. If you see only "Events" and "Audit Logs", you're in Global View — navigate back to the site first. Similarly, the site-level sidebar has "Management", "Monitoring", "Configuration", "Hotspot", "Maintenance" sections, while Global View has "Dashboard", "Devices", "Logs", "SD-WAN", "Accounts", "Settings". Use the sidebar structure to verify which view you're in.

## SNMP Monitoring

### CRITICAL FINDING: SNMP Cannot Be Enabled via Omada Cloud Controller

**As of 2026-07-05, the Omada Cloud Controller does NOT expose SNMP
configuration for ANY device type.** This was verified exhaustively:

- **Router (DR3650v-4G, FW 1.1.1)**: Config tab has sections: General,
  Wireless, SIM Config (PIN Management, Statistics, SMS Message, SMS
  Settings), Transmission (Routing, NAT, Bandwidth Control, Gateway QoS),
  Network Security (ACL), Advanced (General, DNS). **None contain SNMP.**
  Some sections show `[skeleton-item fake-menu-item]` placeholders that
  never load (20 skeleton items remain permanently).
- **Switch (SG3428MP, FW 6.20.26)**: Config tab has sections: General,
  VLAN Interface, Services (Loopback Control, Spanning Tree), Static Route.
  **No SNMP.**
- **APs**: Config tab not tested for SNMP, but given the pattern above,
  unlikely to have SNMP settings either.

**Conclusion**: SNMP configuration is only available through the devices'
local web UIs, NOT through the Omada cloud controller. The cloud controller
simply does not provide this capability.

### Prerequisites for SNMP via Local Device UIs

To enable SNMP on the devices, you need access to each device's local web UI:

1. **Router (172.16.1.1)**: Local login is BLOCKED — shows *"Note: This
   Gateway is being managed by Controller 99.80.193.119"*. Must enable
   Local Management via Omada controller first (Configuration → Device
   Config → Gateway → Local Management — if this setting exists in your
   controller version).
2. **Switch (172.16.1.2)**: Local web UI is accessible (no "managed by
   controller" message), but requires device-specific admin credentials.
   Neither `admin/admin` nor the Omada cloud password work. The local
   credentials are NOT stored in 1Password.
3. **APs**: Each AP may have a local web UI at its 172.16.1.x address.
   Credentials unknown.

### Omada API Exporters — Do NOT Work with Cloud Controller

Two community Prometheus exporters exist for TP-Link Omada:
- `charlie-haley/omada_exporter` (140 stars) — Docker image `chhaley/omada_exporter`
- `RCooLeR/omada_exporter` (56 stars) — Docker image `ghcr.io/rcooler/omada_exporter`

**Both require a LOCAL Omada controller URL** (`OMADA_HOST=https://192.168.x.x`).
They use the Omada controller's REST API (`/api/v2/...`), which is only
served by on-premise controllers. The cloud controller at
`omada.tplinkcloud.com` returns HTML (SPA) for all API paths — it does NOT
expose a REST API backend.

**To use these exporters, you would need to run a local Omada Software
Controller** (Docker container) and migrate devices from the cloud
controller to the local one.

### Local Omada Software Controller Migration (In Progress)

**Decision (2026-07-05)**: Migrate from cloud controller to a local Omada
Software Controller to enable SNMP and API-based monitoring.

**Rationale**: The cloud controller cannot enable SNMP or serve API
exporters. A local controller unlocks both paths. Devices retain cached
config during controller downtime, so the circular dependency
(controller needs network ↔ network managed by controller) is not
real — devices boot with last-known config and continue operating.

**Deployment target**: CT 142 (`omada-controller`, 10.0.30.142/24,
VLAN 30, proxmox7, vm_disks storage, 2 cores / 4 GB RAM / 32 GB disk).
Created via `pct create` with Debian 12 template.

**Migration steps** (planned):
1. Install Docker on CT 142
2. Deploy Omada Software Controller container (TP-Link official image)
3. Run setup wizard (admin user, site "Rohr")
4. Remove devices from cloud controller
5. Adopt devices on local controller (devices at 172.16.1.x)
6. Deploy `omada_exporter` (charlie-haley or RCooLeR) pointing at
   `http://10.0.30.142:8088`
7. Add Prometheus scrape config for omada_exporter
8. Extend Grafana dashboard with Omada panels

**Circular Dependency Analysis** (user-raised concern):
- Cold-start sequence: Gateway boots (cached config) → Switch boots
  (cached config) → PVE nodes boot → HA manager starts CT 142 →
  Controller reconnects to devices
- Risk: only if gateway config is corrupt AND controller is down
  simultaneously — very unlikely
- Mitigation: CT 142 is in HA-manager (auto-restart on any node)

### Alternatives for Omada Metrics Collection

Since neither SNMP nor API exporters work with the cloud controller:

1. **Run a local Omada Software Controller** — IN PROGRESS (CT 142).
   Devices will be re-adopted from the local controller. Then both
   SNMP (via local device UIs) and API exporters will work.
2. **Browser automation scraping** — Periodically read device metrics
   (CPU, memory, temperature, port stats, client counts) from the Omada
   cloud controller via browser automation and push to Prometheus via
   pushgateway. Hacky but functional.
3. **Accept the limitation** — The existing Grafana dashboard
   (`network-infra`, uid: `network-infra`) already tracks PVE nodes,
   guests, HA state, and blackbox probes. Omada device metrics (CPU,
   memory, temp) are visible in the Omada UI but not in Prometheus.

### Device Inventory (Verified 2026-07-05)

All devices are on VLAN 1 (CatchAll/Default, 172.16.1.0/24):

| Device | Model | HW/FW | IP | Notes |
|--------|-------|-------|-----|-------|
| Router | DR3650v-4G | HW v1.0 / FW 1.1.1 | 172.16.1.1 | Primary gateway, VLAN routing. Also at 10.0.30.1 (VLAN 30 interface) |
| Core (Switch) | SG3428MP | HW v6.20 / FW 6.20.26 | 172.16.1.2 | PoE switch, 28 ports, firmware update available (6.20.27) |
| AP Erdgeschoss | EAP683 UR(EU) | HW v1.0 / FW 1.4.0 | 172.16.1.3 | Connected to switch port 16 |
| AP Obergeschoss | EAP683 UR(EU) | HW v1.0 / FW 1.4.0 | 172.16.1.4 | Connected to switch port 14 |
| AP Garage | EAP615-Wall(EU) | HW v1.0 / FW 1.5.4 | 172.16.1.5 | Connected to switch port 21 |
| AP Garten | EAP225-Outdoor(EU) | HW v3.0 / FW 5.2.2 | 172.16.1.6 | Outdoor AP |

> **Note**: The 10.0.30.1, 10.0.20.1, etc. addresses are the gateway's VLAN
> interfaces, not separate devices. All Omada-managed devices use
> 172.16.1.x (VLAN 1 / CatchAll) as their management IP. The Omada
> controller communicates with devices via the organization connection IP
> (84.39.78.100) through the cloud.

### snmp_exporter Deployment Status

**snmp_exporter IS deployed** on CT 141 as a binary + systemd service
(v0.27.0, port 9116, using the official snmp.yml with all standard MIBs).
Docker-based deployment on CT 141 does not work — see the deployment
section below for details. The exporter is running and ready; it just
needs SNMP to be enabled on the Omada devices to start collecting.

The monitoring stack on CT 141 runs via Docker: prometheus, grafana,
alertmanager, blackbox-exporter, pve-exporter, telegram-bridge.
snmp_exporter is the exception — it runs as a systemd service, not Docker.

### Deploying snmp_exporter in Monitoring Stack

> **Critical**: Docker-based deployment of snmp_exporter on CT 141 FAILS.
> Containers get stuck in "Created" state, `docker start`/`docker logs`/
> `docker inspect` all hang indefinitely. This affects both `docker run`
> and `docker compose up`. The root cause is unclear (other containers
> like prometheus/grafana work fine), but the pattern is 100%
> reproducible. **Use the binary + systemd approach instead** (below).

#### Approach A (RECOMMENDED): Binary + systemd service

Works reliably on CT 141. Tested with snmp_exporter v0.27.0.

```bash
# On CT 141 (via SSH to proxmox7, pct exec 141):
curl -sL -o /tmp/snmp_exporter.tar.gz \
  https://github.com/prometheus/snmp_exporter/releases/download/v0.27.0/snmp_exporter-0.27.0.linux-amd64.tar.gz
tar xzf /tmp/snmp_exporter.tar.gz -C /tmp/

# Install binary
cp /tmp/snmp_exporter-0.27.0.linux-amd64/snmp_exporter /usr/local/bin/snmp_exporter
chmod +x /usr/local/bin/snmp_exporter

# Use the official snmp.yml bundled with the release (1.4MB, 63574 lines)
# Contains all standard MIBs including if_mib, host_resources, etc.
cp /tmp/snmp_exporter-0.27.0.linux-amd64/snmp.yml /etc/snmp_exporter.yml

# Create systemd service
cat > /etc/systemd/system/snmp-exporter.service << 'EOF'
[Unit]
Description=SNMP Exporter for Prometheus
After=network.target

[Service]
ExecStart=/usr/local/bin/snmp_exporter --config.file=/etc/snmp_exporter.yml
Restart=always
RestartSec=5

[Install]
WantedBy=multi-user.target
EOF

systemctl daemon-reload
systemctl enable --now snmp-exporter
# Verify: systemctl status snmp-exporter
# Test:   curl -s "http://localhost:9116/snmp?target=172.16.1.1&module=if_mib&auth=public_v2"
# Metrics: curl -s http://localhost:9116/metrics | head -5
```

**Pitfall**: v0.27.0 does NOT have the `/-/healthy` endpoint (returns 404).
Use `curl -s http://localhost:9116/metrics` to verify the exporter is alive.

#### Approach B (NOT RECOMMENDED): Docker container

Adding snmp_exporter as a Docker container on CT 141 does not work.
`docker create` succeeds but `docker start` hangs for 30+ seconds,
then the container enters a crash-restart loop. `docker logs`,
`docker inspect`, and `docker rm` all hang. The only way to recover
is `docker container prune -f` or waiting for operations to eventually
complete after 30-60s timeouts. Both the full 2MB snmp.yml and a
minimal custom snmp.yml produce the same result. This is specific to
CT 141 — not a general snmp_exporter Docker issue.

### Prometheus Scrape Configuration

Add to `/opt/monitoring/prometheus/prometheus.yml` on CT 141:

```yaml
  - job_name: snmp_omada
    scrape_interval: 30s
    static_configs:
      - targets:
          - 172.16.1.1  # Gateway (DR3650v-4G)
          - 172.16.1.2  # Switch (SG3428MP)
          - 172.16.1.3  # AP Erdgeschoss (EAP683 UR)
          - 172.16.1.4  # AP Obergeschoss (EAP683 UR)
          - 172.16.1.5  # AP Garage (EAP615-Wall)
          - 172.16.1.6  # AP Garten (EAP225-Outdoor)
    metrics_path: /snmp
    params:
      module: [if_mib]
      auth: [public_v2]
    relabel_configs:
      - source_labels: [__address__]
        target_label: __param_target
      - source_labels: [__param_target]
        target_label: instance
      - target_label: __address__
        replacement: localhost:9116  # snmp_exporter runs as systemd, not Docker
```

Then reload: `curl -s -X POST http://localhost:9090/-/reload`
Verify: `curl -s http://localhost:9090/api/v1/targets | python3 -m json.tool | grep snmp`

### TP-Link Enterprise OIDs
TP-Link uses enterprise OID 11863 (per IANA assignment). Key MIB subtrees:
- `1.3.6.1.4.1.11863.1` — tp-linkDevice
- `1.3.6.1.4.1.11863.2` — tp-linkCommon
- `1.3.6.1.4.1.11863.3` — tp-linkPort
- `1.3.6.1.4.1.11863.6` — tp-linkSwitch

Standard IF-MIB (`1.3.6.1.2.1.2`) works for interface stats on all devices.
Host Resources MIB (`1.3.6.1.2.1.25`) works for CPU/memory on devices that
expose it.

## Best Practices
- Always wait 5-8s after navigation before snapshotting — the SPA is slow
- Use `browser_console` for iframe-internal interactions when `browser_click` refs fail
- **Prefer async IIFE console chains** over individual tool calls for multi-step flows — fewer session-expiry failures
- Dismiss all popup dialogs before attempting navigation
- Channel optimization disrupts connectivity — confirm with user before triggering
- Check "Optimization History" to verify optimization completed successfully
- After triggering optimization, expect session to drop — re-login to check results
