From 48b61b4335656c62d4570c3b4636afde480930b1 Mon Sep 17 00:00:00 2001 From: Polarity Dev Agent Date: Thu, 21 May 2026 18:51:46 +0000 Subject: [PATCH 1/3] INT-1912: Initial implementation of UniFi Network Polarity Integration - Multi-site client and device lookup (IPv4, MAC Address) - GET /v1/sites with 1-hour module-level cache - Client search: GET /v1/sites/{siteId}/clients?filter=ipAddress.eq() or macAddress.eq() - Device search: GET /v1/sites/{siteId}/devices?filter=... with 400 fallback to full list + client-side filter - onMessage: BLOCK_CLIENT and RECONNECT_CLIENT actions via POST /v1/sites/{siteId}/clients/{clientId}/actions - Ember block.js component: per-client isBlockingClient loading state and blockActionMessage feedback - block.hbs: CollapsibleSection per client/device with p-key/p-value identity, network, traffic tables - summary.hbs: type badge, status badge, hostname/device name, site name pills - validateOptions: URL trailing slash enforcement, API key required check - handleRestError: 200/404/401/403/400/network error dispatch (efficient-ip pattern) - package.json: postman-request, async, lodash, polarity-integration-utils - CI: release-current-version + run-int-dev-checklist workflows (greynoise reference) --- .github/workflows/release-current-version.yml | 9 + .github/workflows/run-int-dev-checklist.yml | 24 + .gitignore | 41 ++ README.md | 88 ++- components/block.js | 83 +++ components/summary.js | 3 + config/config.js | 61 ++ config/config.json | 1 + integration.js | 579 ++++++++++++++++++ package.json | 23 + reducers/index.js | 3 + templates/block.hbs | 213 +++++++ templates/summary.hbs | 38 ++ 13 files changed, 1165 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/release-current-version.yml create mode 100644 .github/workflows/run-int-dev-checklist.yml create mode 100644 .gitignore create mode 100644 components/block.js create mode 100644 components/summary.js create mode 100644 config/config.js create mode 100644 config/config.json create mode 100644 integration.js create mode 100644 package.json create mode 100644 reducers/index.js create mode 100644 templates/block.hbs create mode 100644 templates/summary.hbs diff --git a/.github/workflows/release-current-version.yml b/.github/workflows/release-current-version.yml new file mode 100644 index 0000000..641a3b7 --- /dev/null +++ b/.github/workflows/release-current-version.yml @@ -0,0 +1,9 @@ +name: Release Current Version + +on: + push: + branches: [ master, main, support/v1 ] + +jobs: + Run: + uses: polarityio/polarity-github-actions/.github/workflows/release-integration.yml@master diff --git a/.github/workflows/run-int-dev-checklist.yml b/.github/workflows/run-int-dev-checklist.yml new file mode 100644 index 0000000..1e15562 --- /dev/null +++ b/.github/workflows/run-int-dev-checklist.yml @@ -0,0 +1,24 @@ +name: Run Integration Development Checklist + +on: + pull_request: + branches: [ master, main, develop, support/v1 ] + +jobs: + run-integration-development-checklist: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: '18' + - name: Test NPM Install + id: test-npm-install + run: | + npm ci + - name: Polarity Integration Development Checklist + id: int-dev-checklist + uses: polarityio/polarity-integration-development-checklist@main + with: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..0708136 --- /dev/null +++ b/.gitignore @@ -0,0 +1,41 @@ +# Ignore Maven Compiled Project Files +*/target/ + +# Ignore IntelliJ Project Information +*.iml +.idea/ + +# Ignore Sublime Text Project Files +*.sublime-project +*.sublime-workspace + +# Ignore Eclipse Project Information +*.settings +*.project +*.classpath +dependency-reduced-pom.xml + +# Ignore Excel Files +*.xlsx + +# Ignore local log files +*.log + +# Ignore Generated HTML README Files +README.html + +# Ignored installed NPM modules +node_modules/ + +# Ignore private keys +key/ + +# Ignore VSCode +*.history +.histoy +*.vscode +.vscode + +# Others +legacy_wrapper.js +dist/ diff --git a/README.md b/README.md index 4364cd7..dc4fc58 100644 --- a/README.md +++ b/README.md @@ -1 +1,87 @@ -# UniFi Network Polarity Integration +# Polarity UniFi Network Integration + +Polarity's UniFi Network integration queries your on-premise UniFi Network controller for **IPv4 addresses** and **MAC addresses**, returning real-time information about connected clients and infrastructure devices (access points, switches, gateways) across **all sites** in your controller. + +From the overlay, analysts can **block** or **reconnect** suspicious clients without leaving their workflow. + +## Supported Entity Types + +| Entity Type | Description | +|---|---| +| `IPv4` | Looks up client connections and infrastructure devices by IP address | +| `MAC Address` | Looks up client connections and infrastructure devices by MAC address | + +## Features + +| Feature | Detail | +|---|---| +| **Multi-site** | Automatically enumerates all sites in your controller on first lookup; results are shown per site | +| **Client lookup** | Shows hostname, IP, MAC, type (WIRED/WIRELESS/VPN), status (CONNECTED/BLOCKED), SSID, uptime, traffic stats, signal strength | +| **Device lookup** | Shows name, model, product line, firmware version, state (ONLINE/OFFLINE/UPDATING) for APs, switches, and gateways | +| **Block / Reconnect** | One-click block or reconnect of suspicious clients directly from the Polarity overlay | +| **Site cache** | Site list is cached for 1 hour to minimize API calls | + +## UniFi Controller Setup + +### Supported Controller Versions + +This integration supports on-premise UniFi Network controllers running version **10.3.58 or later** with the new API (`/proxy/network/integration`). + +### Generating an API Key + +1. Log in to your UniFi Network controller +2. Navigate to **Settings → Admins & Users → API** +3. Click **Create API Key** and copy the key +4. The API Key needs at minimum: **read access** to sites, clients, and devices; **write access** to execute client actions (block/reconnect) + +### Controller URL Format + +The URL must point to the integration API base path: + +``` +https://{your-controller-host}/proxy/network/integration +``` + +**Examples:** +- `https://192.168.1.1/proxy/network/integration` +- `https://unifi.company.com/proxy/network/integration` + +> ⚠️ The URL must **not** end with a trailing slash. + +## Integration Options + +| Option | Description | Admin Only | +|---|---|---| +| **UniFi Controller URL** | Base URL of your on-premise UniFi controller (no trailing slash) | ✅ | +| **API Key** | Your UniFi Network API Key | ✅ | +| **Ignored Entities** | Comma-separated list of IPs or MACs to skip | ❌ | +| **IP Blocklist Regex** | Regular expression for IPs to exclude from lookup | ❌ | + +## Block / Reconnect Actions + +When a **connected client** is found in an overlay result: + +- **⛔ Block Client** — sends a `block` action to the UniFi controller, preventing the client from accessing the network. The status updates to `BLOCKED` immediately in the overlay. +- **🔄 Reconnect** — sends a `reconnect` action to restore the client's network access. The status updates to `CONNECTED` immediately in the overlay. + +> Infrastructure devices (access points, switches, gateways) do **not** have action buttons in V1 — they are read-only. + +## Multi-Site Behavior + +On the first lookup after startup (or after the 1-hour cache expires), the integration fetches the full list of sites from `GET /v1/sites`. All subsequent lookups fan out across every site in parallel, running both client and device queries per site. + +Results are grouped by site in the overlay: +- Each client match appears in a **🖥 Connected Client — {site name}** collapsible section +- Each device match appears in a **📡 Infrastructure Device — {site name}** collapsible section + +## Installation + +1. Clone or download this integration into your Polarity integrations directory +2. Run `npm install` +3. Configure the **UniFi Controller URL** and **API Key** in Polarity's integration settings + +## About Polarity + +Polarity is a memory-augmentation platform that automatically overlays relevant contextual information onto any system—browser, terminal, email client, and more—so analysts can act on intelligence without switching tools. + +[https://polarity.io](https://polarity.io) diff --git a/components/block.js b/components/block.js new file mode 100644 index 0000000..0ce4131 --- /dev/null +++ b/components/block.js @@ -0,0 +1,83 @@ +polarity.export = PolarityComponent.extend({ + details: Ember.computed.alias('block.data.details'), + + /** + * Per-client loading state keyed by clientId. + * @type {Object.} + */ + isBlockingClient: {}, + + /** + * Per-client inline feedback message keyed by clientId. + * @type {Object.} + */ + blockActionMessage: {}, + + actions: { + /** + * Send a BLOCK_CLIENT message for the given client result. + * @param {Object} clientResult - a member of details.clients[] + */ + blockClient(clientResult) { + const clientId = clientResult.clientId; + + // Set loading state + this.set(`isBlockingClient.${clientId}`, true); + this.set(`blockActionMessage.${clientId}`, ''); + + this.sendIntegrationMessage({ + action: 'BLOCK_CLIENT', + siteId: clientResult.siteId, + clientId + }) + .then((response) => { + if (response && response.success) { + // Update the status directly in the block data so the UI refreshes + Ember.set(clientResult, 'status', 'BLOCKED'); + this.set(`blockActionMessage.${clientId}`, '✅ Client blocked'); + } else { + const msg = (response && response.message) || 'Unknown error'; + this.set(`blockActionMessage.${clientId}`, `⚠️ Failed: ${msg}`); + } + }) + .catch((err) => { + this.set(`blockActionMessage.${clientId}`, `⚠️ Error: ${err.message || err}`); + }) + .finally(() => { + this.set(`isBlockingClient.${clientId}`, false); + }); + }, + + /** + * Send a RECONNECT_CLIENT message for the given client result. + * @param {Object} clientResult - a member of details.clients[] + */ + reconnectClient(clientResult) { + const clientId = clientResult.clientId; + + this.set(`isBlockingClient.${clientId}`, true); + this.set(`blockActionMessage.${clientId}`, ''); + + this.sendIntegrationMessage({ + action: 'RECONNECT_CLIENT', + siteId: clientResult.siteId, + clientId + }) + .then((response) => { + if (response && response.success) { + Ember.set(clientResult, 'status', 'CONNECTED'); + this.set(`blockActionMessage.${clientId}`, '✅ Client reconnected'); + } else { + const msg = (response && response.message) || 'Unknown error'; + this.set(`blockActionMessage.${clientId}`, `⚠️ Failed: ${msg}`); + } + }) + .catch((err) => { + this.set(`blockActionMessage.${clientId}`, `⚠️ Error: ${err.message || err}`); + }) + .finally(() => { + this.set(`isBlockingClient.${clientId}`, false); + }); + } + } +}); diff --git a/components/summary.js b/components/summary.js new file mode 100644 index 0000000..a7de17c --- /dev/null +++ b/components/summary.js @@ -0,0 +1,3 @@ +polarity.export = PolarityComponent.extend({ + details: Ember.computed.alias('block.data.details') +}); diff --git a/config/config.js b/config/config.js new file mode 100644 index 0000000..b186ca3 --- /dev/null +++ b/config/config.js @@ -0,0 +1,61 @@ +'use strict'; + +module.exports = { + /** + * @type Array + * @optional + */ + logging: { + level: 'info' + }, + /** + * Options that are displayed to the user/admin in the Polarity integration settings page and + * returned as part of the `options` object passed to the `startup`, `validateOptions`, + * `onMessage` and `doLookup` callbacks. + * + * @type Array + * @optional + */ + options: [ + { + key: 'url', + name: 'UniFi Controller URL', + description: + 'The base URL of your on-premise UniFi Network controller. Must NOT end with a trailing slash. Example: https://192.168.1.1/proxy/network/integration', + default: '', + type: 'text', + userCanEdit: false, + adminOnly: true + }, + { + key: 'apiKey', + name: 'API Key', + description: + 'Your UniFi Network controller API Key. Generate one from the controller UI under Settings → API.', + default: '', + type: 'password', + userCanEdit: false, + adminOnly: true + }, + { + key: 'blocklist', + name: 'Ignored Entities', + description: + 'Comma-separated list of IP addresses or MAC addresses to ignore during lookup.', + default: '', + type: 'text', + userCanEdit: false, + adminOnly: false + }, + { + key: 'ipBlocklistRegex', + name: 'IP Blocklist Regex', + description: + 'IP addresses matching this regex will not be looked up. Leave blank to query all IP addresses.', + default: '', + type: 'text', + userCanEdit: false, + adminOnly: false + } + ] +}; diff --git a/config/config.json b/config/config.json new file mode 100644 index 0000000..9e26dfe --- /dev/null +++ b/config/config.json @@ -0,0 +1 @@ +{} \ No newline at end of file diff --git a/integration.js b/integration.js new file mode 100644 index 0000000..93ba93e --- /dev/null +++ b/integration.js @@ -0,0 +1,579 @@ +'use strict'; + +const request = require('postman-request'); +const async = require('async'); +const fs = require('fs'); +const _ = require('lodash'); +const config = require('./config/config'); + +let Logger; +let requestWithDefaults; + +// ── Site cache ────────────────────────────────────────────────────────────── +let sitesCache = []; +let sitesCacheExpiry = 0; +const SITES_CACHE_TTL_MS = 60 * 60 * 1000; // 1 hour + +// ── Blocklist state ────────────────────────────────────────────────────────── +let previousIpRegexAsString = ''; +let ipBlocklistRegex = null; + +// ── Constants ──────────────────────────────────────────────────────────────── +const IGNORED_IPS = new Set(['127.0.0.1', '255.255.255.255', '0.0.0.0']); +const MAX_PARALLEL_LOOKUPS = 10; + +/** + * Called once when the integration process starts. + * Sets up postman-request defaults (cert, key, proxy, CA, rejectUnauthorized). + * Does NOT call the API here — options are not available yet. + */ +function startup(logger) { + let defaults = {}; + Logger = logger; + + const { cert, key, passphrase, ca, proxy, rejectUnauthorized } = config.request; + + if (typeof cert === 'string' && cert.length > 0) { + defaults.cert = fs.readFileSync(cert); + } + + if (typeof key === 'string' && key.length > 0) { + defaults.key = fs.readFileSync(key); + } + + if (typeof passphrase === 'string' && passphrase.length > 0) { + defaults.passphrase = passphrase; + } + + if (typeof ca === 'string' && ca.length > 0) { + defaults.ca = fs.readFileSync(ca); + } + + if (typeof proxy === 'string' && proxy.length > 0) { + defaults.proxy = proxy; + } + + if (typeof rejectUnauthorized === 'boolean') { + defaults.rejectUnauthorized = rejectUnauthorized; + } + + requestWithDefaults = request.defaults(defaults); +} + +// ── Blocklist helpers ───────────────────────────────────────────────────────── + +function _setupRegexBlocklists(options) { + if (options.ipBlocklistRegex !== previousIpRegexAsString && options.ipBlocklistRegex.length === 0) { + Logger.debug('Removing IP blocklist Regex Filtering'); + previousIpRegexAsString = ''; + ipBlocklistRegex = null; + } else { + if (options.ipBlocklistRegex !== previousIpRegexAsString) { + previousIpRegexAsString = options.ipBlocklistRegex; + Logger.debug({ ipBlocklistRegex: previousIpRegexAsString }, 'Modifying IP blocklist Regex'); + ipBlocklistRegex = new RegExp(options.ipBlocklistRegex, 'i'); + } + } +} + +function _isEntityBlocklisted(entity, { blocklist }) { + Logger.trace({ blocklist }, 'blocklist Values'); + + const entityIsBlocklisted = _.includes(blocklist, entity.value.toLowerCase()); + const ipIsBlocklisted = entity.isIP && ipBlocklistRegex !== null && ipBlocklistRegex.test(entity.value); + + if (ipIsBlocklisted) Logger.debug({ ip: entity.value }, 'Blocked blocklisted IP lookup'); + + return entityIsBlocklisted || ipIsBlocklisted; +} + +function _isInvalidEntity(entity) { + return entity.isIPv4 && IGNORED_IPS.has(entity.value); +} + +// ── Site cache helper ───────────────────────────────────────────────────────── + +/** + * Returns cached sites or fetches a fresh list from the controller. + * Cache expires after SITES_CACHE_TTL_MS (1 hour). + * @param {Object} options - user options (url, apiKey) + * @param {Function} cb - callback(err, sites[]) + */ +function _getSitesCache(options, cb) { + const now = Date.now(); + + if (sitesCache.length > 0 && now < sitesCacheExpiry) { + Logger.trace({ siteCount: sitesCache.length }, 'Using cached sites'); + return cb(null, sitesCache); + } + + const baseUrl = options.url.replace(/\/$/, ''); + const requestOptions = { + method: 'GET', + uri: `${baseUrl}/v1/sites`, + headers: { + 'X-API-KEY': options.apiKey, + 'Content-Type': 'application/json' + }, + json: true + }; + + Logger.debug({ uri: requestOptions.uri }, 'Fetching sites from UniFi controller'); + + requestWithDefaults(requestOptions, (error, res, body) => { + const restError = handleRestError(error, null, res, body); + if (restError) { + return cb(restError); + } + + const sites = (body && Array.isArray(body.data)) ? body.data : []; + sitesCache = sites; + sitesCacheExpiry = Date.now() + SITES_CACHE_TTL_MS; + + Logger.debug({ siteCount: sites.length }, 'Sites fetched and cached'); + cb(null, sites); + }); +} + +// ── Search helpers ──────────────────────────────────────────────────────────── + +/** + * Search for connected clients matching the entity (IP or MAC) on a specific site. + * @param {string} siteId + * @param {string} siteName + * @param {Object} entity + * @param {Object} options + * @param {Function} cb - callback(err, clientResults[]) + */ +function _searchClients(siteId, siteName, entity, options, cb) { + const baseUrl = options.url.replace(/\/$/, ''); + const filterField = entity.isIP ? 'ipAddress' : 'macAddress'; + const filterValue = entity.value; + + const requestOptions = { + method: 'GET', + uri: `${baseUrl}/v1/sites/${siteId}/clients`, + qs: { + filter: `${filterField}.eq('${filterValue}')` + }, + headers: { + 'X-API-KEY': options.apiKey, + 'Content-Type': 'application/json' + }, + json: true + }; + + Logger.trace({ siteId, filter: requestOptions.qs.filter }, 'Searching clients'); + + requestWithDefaults(requestOptions, (error, res, body) => { + const restError = handleRestError(error, entity, res, body); + if (restError) { + return cb(restError); + } + + const clients = (body && Array.isArray(body.data)) ? body.data : []; + + // Attach siteId and siteName to each result for onMessage use + const enriched = clients.map((c) => ({ + ...c, + siteId, + siteName + })); + + cb(null, enriched); + }); +} + +/** + * Search for infrastructure devices matching the entity (IP or MAC) on a specific site. + * Falls back to full list + client-side filter if the filter query param isn't supported. + * @param {string} siteId + * @param {string} siteName + * @param {Object} entity + * @param {Object} options + * @param {Function} cb - callback(err, deviceResults[]) + */ +function _searchDevices(siteId, siteName, entity, options, cb) { + const baseUrl = options.url.replace(/\/$/, ''); + const filterField = entity.isIP ? 'ipAddress' : 'macAddress'; + const filterValue = entity.value; + + const requestOptions = { + method: 'GET', + uri: `${baseUrl}/v1/sites/${siteId}/devices`, + qs: { + filter: `${filterField}.eq('${filterValue}')` + }, + headers: { + 'X-API-KEY': options.apiKey, + 'Content-Type': 'application/json' + }, + json: true + }; + + Logger.trace({ siteId, filter: requestOptions.qs.filter }, 'Searching devices (with filter)'); + + requestWithDefaults(requestOptions, (error, res, body) => { + // If filter not supported (400 or returns all devices unexpectedly), fall back to full list + if (!error && res && res.statusCode === 400) { + Logger.debug({ siteId }, 'Device filter returned 400 — falling back to full device list'); + return _searchDevicesFallback(siteId, siteName, entity, options, cb); + } + + const restError = handleRestError(error, entity, res, body); + if (restError) { + return cb(restError); + } + + const devices = (body && Array.isArray(body.data)) ? body.data : []; + + // If the controller returned ALL devices (ignoring filter), fall back to client-side filter + if (devices.length > 1) { + const filtered = devices.filter((d) => + entity.isIP + ? d.ipAddress === entity.value + : (d.macAddress || '').toLowerCase() === entity.value.toLowerCase() + ); + + const enriched = filtered.map((d) => ({ ...d, siteId, siteName })); + return cb(null, enriched); + } + + const enriched = devices.map((d) => ({ ...d, siteId, siteName })); + cb(null, enriched); + }); +} + +/** + * Fallback: fetch all devices and filter client-side. + */ +function _searchDevicesFallback(siteId, siteName, entity, options, cb) { + const baseUrl = options.url.replace(/\/$/, ''); + + const requestOptions = { + method: 'GET', + uri: `${baseUrl}/v1/sites/${siteId}/devices`, + headers: { + 'X-API-KEY': options.apiKey, + 'Content-Type': 'application/json' + }, + json: true + }; + + requestWithDefaults(requestOptions, (error, res, body) => { + const restError = handleRestError(error, entity, res, body); + if (restError) { + return cb(restError); + } + + const allDevices = (body && Array.isArray(body.data)) ? body.data : []; + + const filtered = allDevices.filter((d) => + entity.isIP + ? d.ipAddress === entity.value + : (d.macAddress || '').toLowerCase() === entity.value.toLowerCase() + ); + + const enriched = filtered.map((d) => ({ ...d, siteId, siteName })); + cb(null, enriched); + }); +} + +/** + * Execute a block or reconnect action on a client. + * @param {string} siteId + * @param {string} clientId - UUID from the client list response + * @param {string} action - 'block' | 'reconnect' + * @param {Object} options + * @param {Function} cb - callback(err, response) + */ +function _executeClientAction(siteId, clientId, action, options, cb) { + const baseUrl = options.url.replace(/\/$/, ''); + + const requestOptions = { + method: 'POST', + uri: `${baseUrl}/v1/sites/${siteId}/clients/${clientId}/actions`, + headers: { + 'X-API-KEY': options.apiKey, + 'Content-Type': 'application/json' + }, + body: { action }, + json: true + }; + + Logger.debug({ siteId, clientId, action }, 'Executing client action'); + + requestWithDefaults(requestOptions, (error, res, body) => { + const restError = handleRestError(error, null, res, body); + if (restError) { + return cb(restError); + } + cb(null, body || {}); + }); +} + +// ── doLookup ────────────────────────────────────────────────────────────────── + +function doLookup(entities, options, cb) { + let lookupResults = []; + let tasks = []; + + Logger.debug({ entityCount: entities.length }, 'Starting doLookup'); + + _setupRegexBlocklists(options); + + entities.forEach((entity) => { + if (_isInvalidEntity(entity) || _isEntityBlocklisted(entity, options)) { + return; + } + + // Only process IPv4 and MAC Address entities + if (!entity.isIP && !entity.isMACAddress) { + return; + } + + tasks.push((done) => { + // Step 1: get (possibly cached) site list + _getSitesCache(options, (siteErr, sites) => { + if (siteErr) { + return done(null, { + entity, + data: { + summary: ['Error fetching sites'], + details: { error: siteErr.detail || 'Failed to fetch UniFi sites' } + } + }); + } + + if (!sites || sites.length === 0) { + return done(null, { entity, data: null }); + } + + // Step 2: for each site, run clients + devices in parallel + const perSiteTasks = sites.flatMap((site) => { + const siteId = site.siteId; + const siteName = site.name || site.meta?.desc || siteId; + + return [ + (siteDone) => _searchClients(siteId, siteName, entity, options, siteDone), + (siteDone) => _searchDevices(siteId, siteName, entity, options, siteDone) + ]; + }); + + async.parallelLimit(perSiteTasks, MAX_PARALLEL_LOOKUPS, (parallelErr, siteResults) => { + if (parallelErr) { + Logger.error({ parallelErr }, 'Error in parallel site lookup'); + return done(null, { + entity, + data: { + summary: ['Lookup error'], + details: { error: parallelErr.detail || 'Error querying UniFi sites' } + } + }); + } + + // siteResults alternates: [clientResults, deviceResults, clientResults, deviceResults, ...] + let allClients = []; + let allDevices = []; + + for (let i = 0; i < siteResults.length; i += 2) { + allClients = allClients.concat(siteResults[i] || []); + allDevices = allDevices.concat(siteResults[i + 1] || []); + } + + if (allClients.length === 0 && allDevices.length === 0) { + return done(null, { entity, data: null }); + } + + const details = { + clients: allClients, + devices: allDevices + }; + + done(null, { entity, data: { summary: _buildSummaryTags(details), details } }); + }); + }); + }); + }); + + if (tasks.length === 0) { + return cb(null, lookupResults); + } + + async.parallelLimit(tasks, MAX_PARALLEL_LOOKUPS, (err, results) => { + if (err) { + return cb(err); + } + lookupResults = results; + Logger.trace({ lookupResults }, 'doLookup complete'); + cb(null, lookupResults); + }); +} + +/** + * Build summary tag strings for the Polarity pill. + */ +function _buildSummaryTags(details) { + const tags = []; + + (details.clients || []).forEach((client) => { + if (client.status === 'BLOCKED') { + tags.push('⛔ Blocked'); + } else if (client.status === 'CONNECTED') { + tags.push('✅ Connected'); + } else { + tags.push('🖥 Client'); + } + if (client.name) tags.push(client.name); + }); + + (details.devices || []).forEach((device) => { + tags.push('📡 Device'); + if (device.state === 'ONLINE') { + tags.push('🟢 Online'); + } else if (device.state === 'OFFLINE') { + tags.push('🔴 Offline'); + } + if (device.model) tags.push(device.model); + }); + + return tags; +} + +// ── onMessage ───────────────────────────────────────────────────────────────── + +function onMessage(payload, options, cb) { + Logger.debug({ payload }, 'onMessage received'); + + switch (payload.action) { + case 'BLOCK_CLIENT': + _executeClientAction(payload.siteId, payload.clientId, 'block', options, (err) => { + if (err) { + Logger.error({ err, payload }, 'Failed to block client'); + return cb(null, { success: false, message: err.detail || 'Failed to block client' }); + } + Logger.debug({ clientId: payload.clientId }, 'Client blocked successfully'); + cb(null, { success: true, newStatus: 'BLOCKED' }); + }); + break; + + case 'RECONNECT_CLIENT': + _executeClientAction(payload.siteId, payload.clientId, 'reconnect', options, (err) => { + if (err) { + Logger.error({ err, payload }, 'Failed to reconnect client'); + return cb(null, { success: false, message: err.detail || 'Failed to reconnect client' }); + } + Logger.debug({ clientId: payload.clientId }, 'Client reconnected successfully'); + cb(null, { success: true, newStatus: 'CONNECTED' }); + }); + break; + + default: + Logger.warn({ action: payload.action }, 'Unknown onMessage action'); + cb(null, { success: false, message: `Unknown action: ${payload.action}` }); + } +} + +// ── validateOptions ─────────────────────────────────────────────────────────── + +function validateOptions(userOptions, cb) { + const errors = []; + + if ( + typeof userOptions.url.value !== 'string' || + userOptions.url.value.trim().length === 0 + ) { + errors.push({ + key: 'url', + message: 'You must provide a UniFi controller URL (e.g., https://192.168.1.1)' + }); + } else if (userOptions.url.value.trim().endsWith('/')) { + errors.push({ + key: 'url', + message: 'The UniFi controller URL must not end with a trailing slash' + }); + } + + if ( + typeof userOptions.apiKey.value !== 'string' || + userOptions.apiKey.value.trim().length === 0 + ) { + errors.push({ + key: 'apiKey', + message: 'You must provide a UniFi API Key' + }); + } + + cb(null, errors); +} + +// ── Error handling ──────────────────────────────────────────────────────────── + +function handleRestError(error, entity, res, body) { + let result; + + if (error) { + result = { + error, + detail: 'HTTP Request Error' + }; + return result; + } + + if (!res) { + return { + error: 'No response received', + detail: 'No HTTP response was received from the UniFi controller' + }; + } + + if (res.statusCode === 200) { + return null; + } + + if (res.statusCode === 404) { + // Not found — treat as no result + return null; + } + + if (res.statusCode === 401) { + result = { + error: 'Unauthorized', + detail: 'Invalid API Key — please verify your UniFi API Key in the integration settings' + }; + return result; + } + + if (res.statusCode === 403) { + result = { + error: 'Forbidden', + detail: 'Forbidden — check that your API Key has the required permissions' + }; + return result; + } + + if (res.statusCode === 400) { + const message = (body && (body.message || body.detail || JSON.stringify(body))) || 'Bad Request'; + result = { + error: 'Bad Request', + detail: message + }; + return result; + } + + result = { + error: `Unexpected HTTP status: ${res.statusCode}`, + detail: (body && (body.message || JSON.stringify(body))) || `HTTP ${res.statusCode} received from UniFi controller` + }; + + return result; +} + +// ── Exports ─────────────────────────────────────────────────────────────────── + +module.exports = { + startup, + doLookup, + onMessage, + validateOptions +}; diff --git a/package.json b/package.json new file mode 100644 index 0000000..94ae282 --- /dev/null +++ b/package.json @@ -0,0 +1,23 @@ +{ + "name": "unifi", + "version": "1.0.0", + "description": "Polarity integration for UniFi Network — IP & MAC lookup, multi-site, block/reconnect actions", + "main": "integration.js", + "scripts": { + "test": "echo \"No tests yet\" && exit 0" + }, + "keywords": [ + "polarity", + "unifi", + "network", + "ubiquiti" + ], + "author": "Polarity", + "license": "MIT", + "dependencies": { + "async": "^3.2.4", + "lodash": "^4.17.21", + "polarity-integration-utils": "^4.1.0", + "postman-request": "^2.88.1-postman.32" + } +} diff --git a/reducers/index.js b/reducers/index.js new file mode 100644 index 0000000..8b46fbb --- /dev/null +++ b/reducers/index.js @@ -0,0 +1,3 @@ +'use strict'; + +module.exports = {}; diff --git a/templates/block.hbs b/templates/block.hbs new file mode 100644 index 0000000..a717304 --- /dev/null +++ b/templates/block.hbs @@ -0,0 +1,213 @@ +{{! UniFi Network — Block (Detail) Template }} + +{{#if details.error}} +
+ + {{details.error}} +
+{{/if}} + +{{! ── Connected Clients ──────────────────────────────────────────────────── }} +{{#each details.clients as |client|}} + {{#block-section + title=(concat "🖥 Connected Client — " client.siteName) + isOpen=(eq @index 0) + }} + + {{! Identity Table }} +
+ {{#if client.name}} +
+ Host Name + {{client.name}} +
+ {{/if}} + {{#if client.macAddress}} +
+ MAC Address + {{client.macAddress}} +
+ {{/if}} + {{#if client.ipAddress}} +
+ IP Address + {{client.ipAddress}} +
+ {{/if}} + {{#if client.type}} +
+ Type + {{client.type}} +
+ {{/if}} + {{#if client.status}} +
+ Status + {{client.status}} +
+ {{/if}} +
+ + {{! Network Table }} +
+
+ Site + {{client.siteName}} +
+ {{#if (and client.ssid (eq client.type 'WIRELESS'))}} +
+ SSID + {{client.ssid}} +
+ {{/if}} + {{#if client.networkId}} +
+ Network ID + {{client.networkId}} +
+ {{/if}} + {{#if client.uplinkDeviceMac}} +
+ Uplink Device MAC + {{client.uplinkDeviceMac}} +
+ {{/if}} +
+ + {{! Traffic / Signal Table }} +
+ {{#if client.connectedAt}} +
+ Connected At + {{client.connectedAt}} +
+ {{/if}} + {{#if client.uptime}} +
+ Uptime + {{format-uptime client.uptime}} +
+ {{/if}} + {{#if client.rxBytes}} +
+ Rx / Tx + {{format-bytes client.rxBytes}} / {{format-bytes client.txBytes}} +
+ {{/if}} + {{#if (and client.signalDbm (eq client.type 'WIRELESS'))}} +
+ Signal + {{client.signalDbm}} dBm +
+ {{/if}} +
+ + {{! Action Buttons (only shown when not blocked) }} + {{#if (not-eq client.status 'BLOCKED')}} +
+ {{#if (get isBlockingClient client.clientId)}} + Working... + {{else}} + + + {{/if}} + + {{#if (get blockActionMessage client.clientId)}} +
+ {{get blockActionMessage client.clientId}} +
+ {{/if}} +
+ {{/if}} + + {{/block-section}} +{{/each}} + +{{! ── Infrastructure Devices ─────────────────────────────────────────────── }} +{{#each details.devices as |device|}} + {{#block-section + title=(concat "📡 Infrastructure Device — " device.siteName) + isOpen=(and (eq @index 0) (eq details.clients.length 0)) + }} + + {{! Identity Table }} +
+ {{#if device.name}} +
+ Name + {{device.name}} +
+ {{/if}} + {{#if device.macAddress}} +
+ MAC Address + {{device.macAddress}} +
+ {{/if}} + {{#if device.ipAddress}} +
+ IP Address + {{device.ipAddress}} +
+ {{/if}} + {{#if device.model}} +
+ Model + {{device.model}} +
+ {{/if}} + {{#if device.productLine}} +
+ Product Line + {{device.productLine}} +
+ {{/if}} +
+ + {{! Status Table }} +
+ {{#if device.state}} +
+ State + {{device.state}} +
+ {{/if}} + {{#if device.firmwareVersion}} +
+ Firmware + {{device.firmwareVersion}} +
+ {{/if}} + {{#if device.uptime}} +
+ Uptime + {{format-uptime device.uptime}} +
+ {{/if}} +
+ Site + {{device.siteName}} +
+
+ + {{! No action buttons for infrastructure devices in V1 }} + + {{/block-section}} +{{/each}} diff --git a/templates/summary.hbs b/templates/summary.hbs new file mode 100644 index 0000000..7666934 --- /dev/null +++ b/templates/summary.hbs @@ -0,0 +1,38 @@ +{{! UniFi Network — Summary Template }} +
+ {{#each details.clients as |client|}} + 🖥 Client + {{#if (eq client.status 'BLOCKED')}} + ⛔ Blocked + {{else if (eq client.status 'CONNECTED')}} + ✅ Connected + {{/if}} + {{#if client.name}} + {{client.name}} + {{/if}} + {{#if client.siteName}} + Site: {{client.siteName}} + {{/if}} + {{/each}} + + {{#each details.devices as |device|}} + 📡 Device + {{#if (eq device.state 'ONLINE')}} + 🟢 Online + {{else if (eq device.state 'OFFLINE')}} + 🔴 Offline + {{else if (eq device.state 'UPDATING')}} + ⚙️ Updating + {{/if}} + {{#if device.model}} + {{device.model}} + {{/if}} + {{#if device.siteName}} + Site: {{device.siteName}} + {{/if}} + {{/each}} + + {{#if details.error}} + ⚠️ Error + {{/if}} +
From 5ca73f9f67748a08dc9232fa0203e189026792dd Mon Sep 17 00:00:00 2001 From: radar989 Date: Thu, 21 May 2026 15:10:36 -0400 Subject: [PATCH 2/3] fix(INT-1912): audit & fix all first-pass bugs in UniFi integration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit config/config.js - Add missing top-level fields: name, acronym, description, entityTypes, defaultColor, styles, onDemandOnly, block, and request sections - Add request section (cert/key/proxy/rejectUnauthorized) — absence caused startup() to throw 'cannot destructure undefined' on every server start - Set rejectUnauthorized: false (UniFi controllers use self-signed certs) config/config.json - Was shipped as {} — silent install failure on the Polarity server - Fully populate as exact mirror of config.js (all 4 options, request, logging, block, styles, entityTypes, defaultColor, onDemandOnly) - Add real UUID v4: dd4d5ee5-2097-4584-9af4-2e352aa74ffe - Add reducer.details.file pointer to ./reducers/details.json reducers/details.json (new file) - Create pipeline DSL with copy ops for client_names, client_ips, client_macs, client_statuses, client_sites, device_names, device_models, device_states, device_sites + prune empty_values pass styles/styles.less (new file) - Create scoped LESS required by config.js styles[] reference - Collapsible section toggle, item entries, status colours (connected/ blocked/updating), action button area, working spinner, error banner components/block.js - Add init() with this._super(...arguments) — required by PolarityComponent - Move isBlocking and actionMessage maps inside init() under block._state so they are per-instance, not class-level shared objects - Add hasClients and hasDevices computed properties to guard {{#each}} - Route all action state through block._state.isBlocking / actionMessage for proper Ember reactivity templates/block.hbs - Remove {{#block-section}} — not a real Polarity component; rendered blank - Remove {{#each}} loops without has* computed property guards - Replace (not-eq ...) with (not (eq ...)) — 'not-eq' is not a registered Ember helper; causes silent blank panel at runtime - Remove format-uptime and format-bytes helpers — not registered in Polarity's Ember build; show raw uptime seconds and remove byte display - Remove @index inside {{#each}} — not supported in Polarity's Ember build - Rewrite using standard p-key/p-value rows, fa-icon, collapsible sections via block._state.showClients / showDevices + toggleSection action - Route action loading/message state through block._state.isBlocking and block._state.actionMessage integration.js - _searchClients: explicitly normalise clientId from c.id || c.clientId || c._id — UniFi API returns UUID as 'id'; missing mapping broke all block/reconnect actions silently - _searchDevices: remove devices.length > 1 heuristic filter guard; always apply client-side filter to handle controllers that ignore the filter query param regardless of result count - _isEntityBlocklisted: parse comma-separated blocklist string into an array before comparison; previous _.includes() on raw string did a substring check producing false-positive blocklist hits - _setupRegexBlocklists: add null-guard (options.ipBlocklistRegex || '') before accessing .length to prevent TypeError on undefined option --- components/block.js | 94 +++-- config/config.js | 35 +- config/config.json | 69 +++- integration.js | 51 ++- package-lock.json | 838 ++++++++++++++++++++++++++++++++++++++++++ reducers/details.json | 21 ++ styles/styles.less | 97 +++++ templates/block.hbs | 417 +++++++++++---------- 8 files changed, 1358 insertions(+), 264 deletions(-) create mode 100644 package-lock.json create mode 100644 reducers/details.json create mode 100644 styles/styles.less diff --git a/components/block.js b/components/block.js index 0ce4131..904a78a 100644 --- a/components/block.js +++ b/components/block.js @@ -1,82 +1,108 @@ polarity.export = PolarityComponent.extend({ details: Ember.computed.alias('block.data.details'), - /** - * Per-client loading state keyed by clientId. - * @type {Object.} - */ - isBlockingClient: {}, + // ── Computed guards for {{#each}} ──────────────────────────────────────── - /** - * Per-client inline feedback message keyed by clientId. - * @type {Object.} - */ - blockActionMessage: {}, + hasClients: Ember.computed('details.clients', function () { + const clients = this.get('details.clients'); + return Array.isArray(clients) && clients.length > 0; + }), + + hasDevices: Ember.computed('details.devices', function () { + const devices = this.get('details.devices'); + return Array.isArray(devices) && devices.length > 0; + }), + + // ── Lifecycle ──────────────────────────────────────────────────────────── + + init() { + this._super(...arguments); + // Guard ensures idempotent re-renders don't reset state + if (!this.get('block._state')) { + this.set('block._state', { + showClients: true, + showDevices: true, + // Per-client loading flags keyed by clientId + isBlocking: {}, + // Per-client inline feedback messages keyed by clientId + actionMessage: {} + }); + } + }, + + // ── Actions ────────────────────────────────────────────────────────────── actions: { + toggleSection(section) { + const key = `block._state.show${section}`; + this.set(key, !this.get(key)); + }, + /** * Send a BLOCK_CLIENT message for the given client result. - * @param {Object} clientResult - a member of details.clients[] + * @param {Object} client - a member of details.clients[] */ - blockClient(clientResult) { - const clientId = clientResult.clientId; + blockClient(client) { + const clientId = client.clientId; + const busyKey = `block._state.isBlocking.${clientId}`; + const msgKey = `block._state.actionMessage.${clientId}`; - // Set loading state - this.set(`isBlockingClient.${clientId}`, true); - this.set(`blockActionMessage.${clientId}`, ''); + this.set(busyKey, true); + this.set(msgKey, ''); this.sendIntegrationMessage({ action: 'BLOCK_CLIENT', - siteId: clientResult.siteId, + siteId: client.siteId, clientId }) .then((response) => { if (response && response.success) { - // Update the status directly in the block data so the UI refreshes - Ember.set(clientResult, 'status', 'BLOCKED'); - this.set(`blockActionMessage.${clientId}`, '✅ Client blocked'); + Ember.set(client, 'status', 'BLOCKED'); + this.set(msgKey, '✅ Client blocked'); } else { const msg = (response && response.message) || 'Unknown error'; - this.set(`blockActionMessage.${clientId}`, `⚠️ Failed: ${msg}`); + this.set(msgKey, `⚠️ Failed: ${msg}`); } }) .catch((err) => { - this.set(`blockActionMessage.${clientId}`, `⚠️ Error: ${err.message || err}`); + this.set(msgKey, `⚠️ Error: ${err.message || err}`); }) .finally(() => { - this.set(`isBlockingClient.${clientId}`, false); + this.set(busyKey, false); }); }, /** * Send a RECONNECT_CLIENT message for the given client result. - * @param {Object} clientResult - a member of details.clients[] + * @param {Object} client - a member of details.clients[] */ - reconnectClient(clientResult) { - const clientId = clientResult.clientId; + reconnectClient(client) { + const clientId = client.clientId; + const busyKey = `block._state.isBlocking.${clientId}`; + const msgKey = `block._state.actionMessage.${clientId}`; - this.set(`isBlockingClient.${clientId}`, true); - this.set(`blockActionMessage.${clientId}`, ''); + this.set(busyKey, true); + this.set(msgKey, ''); this.sendIntegrationMessage({ action: 'RECONNECT_CLIENT', - siteId: clientResult.siteId, + siteId: client.siteId, clientId }) .then((response) => { if (response && response.success) { - Ember.set(clientResult, 'status', 'CONNECTED'); - this.set(`blockActionMessage.${clientId}`, '✅ Client reconnected'); + Ember.set(client, 'status', 'CONNECTED'); + this.set(msgKey, '✅ Client reconnected'); } else { const msg = (response && response.message) || 'Unknown error'; - this.set(`blockActionMessage.${clientId}`, `⚠️ Failed: ${msg}`); + this.set(msgKey, `⚠️ Failed: ${msg}`); } }) .catch((err) => { - this.set(`blockActionMessage.${clientId}`, `⚠️ Error: ${err.message || err}`); + this.set(msgKey, `⚠️ Error: ${err.message || err}`); }) .finally(() => { - this.set(`isBlockingClient.${clientId}`, false); + this.set(busyKey, false); }); } } diff --git a/config/config.js b/config/config.js index b186ca3..3afafd9 100644 --- a/config/config.js +++ b/config/config.js @@ -1,21 +1,32 @@ 'use strict'; module.exports = { - /** - * @type Array - * @optional - */ + name: 'UniFi Network', + acronym: 'UNF', + description: + 'Query IP addresses and MAC addresses across all sites on an on-premise UniFi Network controller. Supports block and reconnect actions for connected clients.', + entityTypes: ['IPv4', 'MAC'], + defaultColor: 'light-blue', + styles: ['./styles/styles.less'], + onDemandOnly: false, + block: { + component: { file: './components/block.js' }, + template: { file: './templates/block.hbs' } + }, + // UniFi controllers commonly use self-signed TLS certificates. + // rejectUnauthorized is set to false to allow connections to controllers + // that have not been issued a publicly trusted certificate. + request: { + cert: '', + key: '', + passphrase: '', + ca: '', + proxy: '', + rejectUnauthorized: false + }, logging: { level: 'info' }, - /** - * Options that are displayed to the user/admin in the Polarity integration settings page and - * returned as part of the `options` object passed to the `startup`, `validateOptions`, - * `onMessage` and `doLookup` callbacks. - * - * @type Array - * @optional - */ options: [ { key: 'url', diff --git a/config/config.json b/config/config.json index 9e26dfe..1ebd4f3 100644 --- a/config/config.json +++ b/config/config.json @@ -1 +1,68 @@ -{} \ No newline at end of file +{ + "polarityIntegrationUuid": "dd4d5ee5-2097-4584-9af4-2e352aa74ffe", + "name": "UniFi Network", + "acronym": "UNF", + "description": "Query IP addresses and MAC addresses across all sites on an on-premise UniFi Network controller. Supports block and reconnect actions for connected clients.", + "entityTypes": ["IPv4", "MAC"], + "defaultColor": "light-blue", + "styles": ["./styles/styles.less"], + "onDemandOnly": false, + "block": { + "component": { "file": "./components/block.js" }, + "template": { "file": "./templates/block.hbs" } + }, + "request": { + "cert": "", + "key": "", + "passphrase": "", + "ca": "", + "proxy": "", + "rejectUnauthorized": false + }, + "logging": { + "level": "info" + }, + "options": [ + { + "key": "url", + "name": "UniFi Controller URL", + "description": "The base URL of your on-premise UniFi Network controller. Must NOT end with a trailing slash. Example: https://192.168.1.1/proxy/network/integration", + "default": "", + "type": "text", + "userCanEdit": false, + "adminOnly": true + }, + { + "key": "apiKey", + "name": "API Key", + "description": "Your UniFi Network controller API Key. Generate one from the controller UI under Settings → API.", + "default": "", + "type": "password", + "userCanEdit": false, + "adminOnly": true + }, + { + "key": "blocklist", + "name": "Ignored Entities", + "description": "Comma-separated list of IP addresses or MAC addresses to ignore during lookup.", + "default": "", + "type": "text", + "userCanEdit": false, + "adminOnly": false + }, + { + "key": "ipBlocklistRegex", + "name": "IP Blocklist Regex", + "description": "IP addresses matching this regex will not be looked up. Leave blank to query all IP addresses.", + "default": "", + "type": "text", + "userCanEdit": false, + "adminOnly": false + } + ], + "reducer": { + "details": { + "file": "./reducers/details.json" + } + } +} \ No newline at end of file diff --git a/integration.js b/integration.js index 93ba93e..ef46734 100644 --- a/integration.js +++ b/integration.js @@ -63,23 +63,32 @@ function startup(logger) { // ── Blocklist helpers ───────────────────────────────────────────────────────── function _setupRegexBlocklists(options) { - if (options.ipBlocklistRegex !== previousIpRegexAsString && options.ipBlocklistRegex.length === 0) { + // Guard against null/undefined option value + const regexStr = options.ipBlocklistRegex || ''; + if (regexStr !== previousIpRegexAsString && regexStr.length === 0) { Logger.debug('Removing IP blocklist Regex Filtering'); previousIpRegexAsString = ''; ipBlocklistRegex = null; } else { - if (options.ipBlocklistRegex !== previousIpRegexAsString) { - previousIpRegexAsString = options.ipBlocklistRegex; + if (regexStr !== previousIpRegexAsString) { + previousIpRegexAsString = regexStr; Logger.debug({ ipBlocklistRegex: previousIpRegexAsString }, 'Modifying IP blocklist Regex'); - ipBlocklistRegex = new RegExp(options.ipBlocklistRegex, 'i'); + ipBlocklistRegex = new RegExp(regexStr, 'i'); } } } -function _isEntityBlocklisted(entity, { blocklist }) { - Logger.trace({ blocklist }, 'blocklist Values'); +function _isEntityBlocklisted(entity, options) { + // Parse the comma-separated blocklist string into an array for exact-match comparison. + // Using _.includes() on the raw string would do a substring check (false positives). + const blocklistArr = (options.blocklist || '') + .split(',') + .map((s) => s.trim().toLowerCase()) + .filter(Boolean); - const entityIsBlocklisted = _.includes(blocklist, entity.value.toLowerCase()); + Logger.trace({ blocklistArr }, 'blocklist Values'); + + const entityIsBlocklisted = blocklistArr.includes(entity.value.toLowerCase()); const ipIsBlocklisted = entity.isIP && ipBlocklistRegex !== null && ipBlocklistRegex.test(entity.value); if (ipIsBlocklisted) Logger.debug({ ip: entity.value }, 'Blocked blocklisted IP lookup'); @@ -173,9 +182,12 @@ function _searchClients(siteId, siteName, entity, options, cb) { const clients = (body && Array.isArray(body.data)) ? body.data : []; - // Attach siteId and siteName to each result for onMessage use + // Normalize clientId: the UniFi API returns the UUID as `id`. + // We explicitly map it to `clientId` so the block/reconnect actions in + // onMessage always have the correct field regardless of API version. const enriched = clients.map((c) => ({ ...c, + clientId: c.id || c.clientId || c._id || '', siteId, siteName })); @@ -227,19 +239,18 @@ function _searchDevices(siteId, siteName, entity, options, cb) { const devices = (body && Array.isArray(body.data)) ? body.data : []; - // If the controller returned ALL devices (ignoring filter), fall back to client-side filter - if (devices.length > 1) { - const filtered = devices.filter((d) => - entity.isIP - ? d.ipAddress === entity.value - : (d.macAddress || '').toLowerCase() === entity.value.toLowerCase() - ); - - const enriched = filtered.map((d) => ({ ...d, siteId, siteName })); - return cb(null, enriched); - } + // Always apply client-side filtering regardless of result count. + // Some controller versions ignore the filter query parameter entirely and + // return all devices. A `devices.length > 1` heuristic was unreliable — + // it would skip filtering when only 1 device existed in the site, allowing + // false-positive results through when the filter was ignored. + const filtered = devices.filter((d) => + entity.isIP + ? d.ipAddress === entity.value + : (d.macAddress || '').toLowerCase() === entity.value.toLowerCase() + ); - const enriched = devices.map((d) => ({ ...d, siteId, siteName })); + const enriched = filtered.map((d) => ({ ...d, siteId, siteName })); cb(null, enriched); }); } diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..0bfc464 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,838 @@ +{ + "name": "unifi", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "unifi", + "version": "1.0.0", + "license": "MIT", + "dependencies": { + "async": "^3.2.4", + "lodash": "^4.17.21", + "polarity-integration-utils": "^4.1.0", + "postman-request": "^2.88.1-postman.32" + } + }, + "node_modules/@polarityio/integration-types": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@polarityio/integration-types/-/integration-types-2.0.0.tgz", + "integrity": "sha512-Hmbh1jj3Ou5M/0HQaxDhwvl+fi730XcU/khVUECr2T1OxmuVSmciVn0SrOAwdaMEhRWAkTYndFPBDh0pweIjxQ==", + "license": "MIT", + "peer": true + }, + "node_modules/@postman/form-data": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@postman/form-data/-/form-data-3.1.1.tgz", + "integrity": "sha512-vjh8Q2a8S6UCm/KKs31XFJqEEgmbjBmpPNVV2eVav6905wyFAwaUOBGA1NPBI4ERH9MMZc6w0umFgM6WbEPMdg==", + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/@postman/tough-cookie": { + "version": "4.1.3-postman.1", + "resolved": "https://registry.npmjs.org/@postman/tough-cookie/-/tough-cookie-4.1.3-postman.1.tgz", + "integrity": "sha512-txpgUqZOnWYnUHZpHjkfb0IwVH4qJmyq77pPnJLlfhMtdCLMFTEeQHlzQiK906aaNCe4NEB5fGJHo9uzGbFMeA==", + "license": "BSD-3-Clause", + "dependencies": { + "psl": "^1.1.33", + "punycode": "^2.1.1", + "universalify": "^0.2.0", + "url-parse": "^1.5.3" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/@postman/tunnel-agent": { + "version": "0.6.8", + "resolved": "https://registry.npmjs.org/@postman/tunnel-agent/-/tunnel-agent-0.6.8.tgz", + "integrity": "sha512-2U42SmZW5G+suEcS++zB94sBWNO4qD4bvETGFRFDTqSpYl5ksfjcPqzYpgQgXgUmb6dfz+fAGbkcRamounGm0w==", + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + }, + "engines": { + "node": "*" + } + }, + "node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/asn1": { + "version": "0.2.6", + "resolved": "https://registry.npmjs.org/asn1/-/asn1-0.2.6.tgz", + "integrity": "sha512-ix/FxPn0MDjeyJ7i/yoHGFt/EX6LyNbxSEhPPXODPL+KB0VPk86UYfL0lMdy+KCnv+fmvIzySwaK5COwqVbWTQ==", + "license": "MIT", + "dependencies": { + "safer-buffer": "~2.1.0" + } + }, + "node_modules/assert-plus": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/assert-plus/-/assert-plus-1.0.0.tgz", + "integrity": "sha512-NfJ4UzBCcQGLDlQq7nHxH+tv3kyZ0hHQqF5BO6J7tNJeP5do1llPr8dZ8zHonfhAu0PHAdMkSo+8o0wxg9lZWw==", + "license": "MIT", + "engines": { + "node": ">=0.8" + } + }, + "node_modules/async": { + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz", + "integrity": "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==", + "license": "MIT" + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "license": "MIT" + }, + "node_modules/aws-sign2": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/aws-sign2/-/aws-sign2-0.7.0.tgz", + "integrity": "sha512-08kcGqnYf/YmjoRhfxyu+CLxBjUtHLXLXX/vUfx9l2LYzG3c1m61nrpyFUZI6zeS+Li/wWMMidD9KgrqtGq3mA==", + "license": "Apache-2.0", + "engines": { + "node": "*" + } + }, + "node_modules/aws4": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/aws4/-/aws4-1.13.2.tgz", + "integrity": "sha512-lHe62zvbTB5eEABUVi/AwVh0ZKY9rMMDhmm+eeyuuUQbQ3+J+fONVQOZyj+DdrvD4BY33uYniyRJ4UJIaSKAfw==", + "license": "MIT" + }, + "node_modules/bcrypt-pbkdf": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/bcrypt-pbkdf/-/bcrypt-pbkdf-1.0.2.tgz", + "integrity": "sha512-qeFIXtP4MSoi6NLqO12WfqARWWuCKi2Rn/9hJLEmtB5yTNr9DqFWkJRCf2qShWzPeAMRnOgCrq0sg/KLv5ES9w==", + "license": "BSD-3-Clause", + "dependencies": { + "tweetnacl": "^0.14.3" + } + }, + "node_modules/bluebird": { + "version": "2.11.0", + "resolved": "https://registry.npmjs.org/bluebird/-/bluebird-2.11.0.tgz", + "integrity": "sha512-UfFSr22dmHPQqPP9XWHRhq+gWnHCYguQGkXQlbyPtW5qTnhFWA8/iXg765tH0cAjy7l/zPJ1aBTO0g5XgA7kvQ==", + "license": "MIT" + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/caseless": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/caseless/-/caseless-0.12.0.tgz", + "integrity": "sha512-4tYFyifaFfGacoiObjJegolkwSU4xQNGbVgUiNYVUxbQ2x2lUsFvY4hVgVzGiIe6WLOPqycWXA40l+PWsxthUw==", + "license": "Apache-2.0" + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/core-util-is": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.2.tgz", + "integrity": "sha512-3lqz5YjWTYnW6dlDa5TLaTCcShfar1e40rmcJVwCBJC6mWlFuj0eCHIElmG1g5kyuJ/GD+8Wn4FFCcz4gJPfaQ==", + "license": "MIT" + }, + "node_modules/dashdash": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/dashdash/-/dashdash-1.14.1.tgz", + "integrity": "sha512-jRFi8UDGo6j+odZiEpjazZaWqEal3w/basFjQHQEwVtZJGDpxbH1MeYluwCS8Xq5wmLJooDlMgvVarmWfGM44g==", + "license": "MIT", + "dependencies": { + "assert-plus": "^1.0.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ecc-jsbn": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/ecc-jsbn/-/ecc-jsbn-0.1.2.tgz", + "integrity": "sha512-eh9O+hwRHNbG4BLTjEl3nw044CkGm5X6LoaCf7LPp7UU8Qrt47JYNi6nPX8xjW97TKGKm1ouctg0QSpZe9qrnw==", + "license": "MIT", + "dependencies": { + "jsbn": "~0.1.0", + "safer-buffer": "^2.1.0" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/extend": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", + "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", + "license": "MIT" + }, + "node_modules/extsprintf": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/extsprintf/-/extsprintf-1.3.0.tgz", + "integrity": "sha512-11Ndz7Nv+mvAC1j0ktTa7fAb0vLyGGX+rMHNBYQviQDGU0Hw7lhctJANqbPhu9nV9/izT/IntTgZ7Im/9LJs9g==", + "engines": [ + "node >=0.6.0" + ], + "license": "MIT" + }, + "node_modules/forever-agent": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/forever-agent/-/forever-agent-0.6.1.tgz", + "integrity": "sha512-j0KLYPhm6zeac4lz3oJ3o65qvgQCcPubiyotZrXqEaG4hNagNYO8qdlUrX5vwqv9ohqeT/Z3j6+yW067yWWdUw==", + "license": "Apache-2.0", + "engines": { + "node": "*" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/getpass": { + "version": "0.1.7", + "resolved": "https://registry.npmjs.org/getpass/-/getpass-0.1.7.tgz", + "integrity": "sha512-0fzj9JxOLfJ+XGLhR8ze3unN0KZCgZwiSSDz168VERjK8Wl8kVSdcu2kspd4s4wtAa1y/qrVRiAA0WclVsu0ng==", + "license": "MIT", + "dependencies": { + "assert-plus": "^1.0.0" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.3.tgz", + "integrity": "sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/http-signature": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/http-signature/-/http-signature-1.4.0.tgz", + "integrity": "sha512-G5akfn7eKbpDN+8nPS/cb57YeA1jLTVxjpCj7tmm3QKPdyDy7T+qSC40e9ptydSWvkwjSXw1VbkpyEm39ukeAg==", + "license": "MIT", + "dependencies": { + "assert-plus": "^1.0.0", + "jsprim": "^2.0.2", + "sshpk": "^1.18.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/ip-address": { + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz", + "integrity": "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, + "node_modules/is-typedarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-typedarray/-/is-typedarray-1.0.0.tgz", + "integrity": "sha512-cyA56iCMHAh5CdzjJIa4aohJyeO1YbwLi3Jc35MmRU6poroFjIGZzUzupGiRPOjgHg9TLu43xbpwXk523fMxKA==", + "license": "MIT" + }, + "node_modules/isstream": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/isstream/-/isstream-0.1.2.tgz", + "integrity": "sha512-Yljz7ffyPbrLpLngrMtZ7NduUgVvi6wG9RJ9IUcyCd59YQ911PBJphODUcbOVbqYfxe1wuYf/LJ8PauMRwsM/g==", + "license": "MIT" + }, + "node_modules/jsbn": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/jsbn/-/jsbn-0.1.1.tgz", + "integrity": "sha512-UVU9dibq2JcFWxQPA6KCqj5O42VOmAY3zQUfEKxU0KpTGXwNoCjkX1e13eHNvw/xPynt6pU0rZ1htjWTNTSXsg==", + "license": "MIT" + }, + "node_modules/json-schema": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/json-schema/-/json-schema-0.4.0.tgz", + "integrity": "sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA==", + "license": "(AFL-2.1 OR BSD-3-Clause)" + }, + "node_modules/json-stringify-safe": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz", + "integrity": "sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA==", + "license": "ISC" + }, + "node_modules/jsprim": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/jsprim/-/jsprim-2.0.2.tgz", + "integrity": "sha512-gqXddjPqQ6G40VdnI6T6yObEC+pDNvyP95wdQhkWkg7crHH3km5qP1FsOXEkzEQwnz6gz5qGTn1c2Y52wP3OyQ==", + "engines": [ + "node >=0.6.0" + ], + "license": "MIT", + "dependencies": { + "assert-plus": "1.0.0", + "extsprintf": "1.3.0", + "json-schema": "0.4.0", + "verror": "1.10.0" + } + }, + "node_modules/lodash": { + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", + "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", + "license": "MIT" + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/oauth-sign": { + "version": "0.9.0", + "resolved": "https://registry.npmjs.org/oauth-sign/-/oauth-sign-0.9.0.tgz", + "integrity": "sha512-fexhUFFPTGV8ybAtSIGbV6gOkSv8UtRbDBnAyLQw4QPKkgNlsH2ByPGtMUqdWkos6YCRmAqViwgZrJc/mRDzZQ==", + "license": "Apache-2.0", + "engines": { + "node": "*" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/polarity-integration-utils": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/polarity-integration-utils/-/polarity-integration-utils-4.3.0.tgz", + "integrity": "sha512-jYB2YrqEHB+HE4AwOMvnFgmv0NG5/P2hnr2n89zwnhQmOdy3WMwrXGIWVYKAOaqPKIUl+lOJF6msWBWj+DiGVQ==", + "license": "MIT", + "dependencies": { + "lodash": "^4.18.1", + "postman-request": "^2.88.1-postman.48" + }, + "peerDependencies": { + "@polarityio/integration-types": "^2.0.0" + } + }, + "node_modules/postman-request": { + "version": "2.88.1-postman.48", + "resolved": "https://registry.npmjs.org/postman-request/-/postman-request-2.88.1-postman.48.tgz", + "integrity": "sha512-E32FGh8ig2KDvzo4Byi7Ibr+wK2gNKPSqXoNsvjdCHgDBxSK4sCUwv+aa3zOBUwfiibPImHMy0WdlDSSCTqTuw==", + "license": "Apache-2.0", + "dependencies": { + "@postman/form-data": "~3.1.1", + "@postman/tough-cookie": "~4.1.3-postman.1", + "@postman/tunnel-agent": "^0.6.8", + "aws-sign2": "~0.7.0", + "aws4": "^1.12.0", + "caseless": "~0.12.0", + "combined-stream": "~1.0.6", + "extend": "~3.0.2", + "forever-agent": "~0.6.1", + "http-signature": "~1.4.0", + "is-typedarray": "~1.0.0", + "isstream": "~0.1.2", + "json-stringify-safe": "~5.0.1", + "mime-types": "^2.1.35", + "oauth-sign": "~0.9.0", + "qs": "~6.14.1", + "safe-buffer": "^5.1.2", + "socks-proxy-agent": "^8.0.5", + "stream-length": "^1.0.2", + "uuid": "^8.3.2" + }, + "engines": { + "node": ">= 16" + } + }, + "node_modules/psl": { + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/psl/-/psl-1.15.0.tgz", + "integrity": "sha512-JZd3gMVBAVQkSs6HdNZo9Sdo0LNcQeMNP3CozBJb3JYC/QUYZTnKxP+f8oWRX4rHP5EurWxqAHTSwUCjlNKa1w==", + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "funding": { + "url": "https://github.com/sponsors/lupomontero" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/qs": { + "version": "6.14.2", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.2.tgz", + "integrity": "sha512-V/yCWTTF7VJ9hIh18Ugr2zhJMP01MY7c5kh4J870L7imm6/DIzBsNLTXzMwUA3yZ5b/KBqLx8Kp3uRvd7xSe3Q==", + "license": "BSD-3-Clause", + "dependencies": { + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/querystringify": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/querystringify/-/querystringify-2.2.0.tgz", + "integrity": "sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ==", + "license": "MIT" + }, + "node_modules/requires-port": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz", + "integrity": "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==", + "license": "MIT" + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/side-channel": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", + "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3", + "side-channel-list": "^1.0.0", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/smart-buffer": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/smart-buffer/-/smart-buffer-4.2.0.tgz", + "integrity": "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==", + "license": "MIT", + "engines": { + "node": ">= 6.0.0", + "npm": ">= 3.0.0" + } + }, + "node_modules/socks": { + "version": "2.8.9", + "resolved": "https://registry.npmjs.org/socks/-/socks-2.8.9.tgz", + "integrity": "sha512-LJhUYUvItdQ0LkJTmPeaEObWXAqFyfmP85x0tch/ez9cahmhlBBLbIqDFnvBnUJGagb0JbIQrkBs1wJ+yRYpEw==", + "license": "MIT", + "dependencies": { + "ip-address": "^10.1.1", + "smart-buffer": "^4.2.0" + }, + "engines": { + "node": ">= 10.0.0", + "npm": ">= 3.0.0" + } + }, + "node_modules/socks-proxy-agent": { + "version": "8.0.5", + "resolved": "https://registry.npmjs.org/socks-proxy-agent/-/socks-proxy-agent-8.0.5.tgz", + "integrity": "sha512-HehCEsotFqbPW9sJ8WVYB6UbmIMv7kUUORIF2Nncq4VQvBfNBLibW9YZR5dlYCSUhwcD628pRllm7n+E+YTzJw==", + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "^4.3.4", + "socks": "^2.8.3" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/sshpk": { + "version": "1.18.0", + "resolved": "https://registry.npmjs.org/sshpk/-/sshpk-1.18.0.tgz", + "integrity": "sha512-2p2KJZTSqQ/I3+HX42EpYOa2l3f8Erv8MWKsy2I9uf4wA7yFIkXRffYdsx86y6z4vHtV8u7g+pPlr8/4ouAxsQ==", + "license": "MIT", + "dependencies": { + "asn1": "~0.2.3", + "assert-plus": "^1.0.0", + "bcrypt-pbkdf": "^1.0.0", + "dashdash": "^1.12.0", + "ecc-jsbn": "~0.1.1", + "getpass": "^0.1.1", + "jsbn": "~0.1.0", + "safer-buffer": "^2.0.2", + "tweetnacl": "~0.14.0" + }, + "bin": { + "sshpk-conv": "bin/sshpk-conv", + "sshpk-sign": "bin/sshpk-sign", + "sshpk-verify": "bin/sshpk-verify" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/stream-length": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/stream-length/-/stream-length-1.0.2.tgz", + "integrity": "sha512-aI+qKFiwoDV4rsXiS7WRoCt+v2RX1nUj17+KJC5r2gfh5xoSJIfP6Y3Do/HtvesFcTSWthIuJ3l1cvKQY/+nZg==", + "license": "WTFPL", + "dependencies": { + "bluebird": "^2.6.2" + } + }, + "node_modules/tweetnacl": { + "version": "0.14.5", + "resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-0.14.5.tgz", + "integrity": "sha512-KXXFFdAbFXY4geFIwoyNK+f5Z1b7swfXABfL7HXCmoIWMKU3dmS26672A4EeQtDzLKy7SXmfBu51JolvEKwtGA==", + "license": "Unlicense" + }, + "node_modules/universalify": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.2.0.tgz", + "integrity": "sha512-CJ1QgKmNg3CwvAv/kOFmtnEN05f0D/cn9QntgNOQlQF9dgvVTHj3t+8JPdjqawCHk7V/KA+fbUqzZ9XWhcqPUg==", + "license": "MIT", + "engines": { + "node": ">= 4.0.0" + } + }, + "node_modules/url-parse": { + "version": "1.5.10", + "resolved": "https://registry.npmjs.org/url-parse/-/url-parse-1.5.10.tgz", + "integrity": "sha512-WypcfiRhfeUP9vvF0j6rw0J3hrWrw6iZv3+22h6iRMJ/8z1Tj6XfLP4DsUix5MhMPnXpiHDoKyoZ/bdCkwBCiQ==", + "license": "MIT", + "dependencies": { + "querystringify": "^2.1.1", + "requires-port": "^1.0.0" + } + }, + "node_modules/uuid": { + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", + "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", + "deprecated": "uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028).", + "license": "MIT", + "bin": { + "uuid": "dist/bin/uuid" + } + }, + "node_modules/verror": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/verror/-/verror-1.10.0.tgz", + "integrity": "sha512-ZZKSmDAEFOijERBLkmYfJ+vmk3w+7hOLYDNkRCuRuMJGEmqYNCNLyBBFwWKVMhfwaEF3WOd0Zlw86U/WC/+nYw==", + "engines": [ + "node >=0.6.0" + ], + "license": "MIT", + "dependencies": { + "assert-plus": "^1.0.0", + "core-util-is": "1.0.2", + "extsprintf": "^1.2.0" + } + } + } +} diff --git a/reducers/details.json b/reducers/details.json new file mode 100644 index 0000000..6cd4955 --- /dev/null +++ b/reducers/details.json @@ -0,0 +1,21 @@ +{ + "root": { + "path": "", + "on_null": "return_empty" + }, + "pipeline": [ + { "op": "copy", "from": "clients[].name", "to": "reduced.client_names" }, + { "op": "copy", "from": "clients[].ipAddress", "to": "reduced.client_ips" }, + { "op": "copy", "from": "clients[].macAddress", "to": "reduced.client_macs" }, + { "op": "copy", "from": "clients[].status", "to": "reduced.client_statuses" }, + { "op": "copy", "from": "clients[].siteName", "to": "reduced.client_sites" }, + { "op": "copy", "from": "devices[].name", "to": "reduced.device_names" }, + { "op": "copy", "from": "devices[].model", "to": "reduced.device_models" }, + { "op": "copy", "from": "devices[].state", "to": "reduced.device_states" }, + { "op": "copy", "from": "devices[].siteName", "to": "reduced.device_sites" }, + { "op": "prune", "strategy": "empty_values", "target": "reduced" } + ], + "output": { + "result": "$working.reduced" + } +} diff --git a/styles/styles.less b/styles/styles.less new file mode 100644 index 0000000..ab69e67 --- /dev/null +++ b/styles/styles.less @@ -0,0 +1,97 @@ +/* UniFi Network Integration — Scoped Styles */ + +/* ── Section toggle (collapsible headers) ───────────────────────────────── */ +.unifi-section-toggle { + display: flex; + align-items: center; + justify-content: space-between; + cursor: pointer; + padding: 5px 0 4px; + margin-top: 6px; + border-top: 1px solid rgba(128, 128, 128, 0.2); + user-select: none; + + &:hover { + opacity: 0.8; + } + + .p-title { + display: flex; + align-items: center; + gap: 5px; + font-size: 11px; + margin: 0; + } + + .unifi-count { + font-weight: normal; + opacity: 0.5; + } + + .unifi-chevron { + font-size: 11px; + color: #aaa; + } +} + +.unifi-section-body { + padding: 4px 0 6px 0; +} + +/* ── Per-item entry rows ─────────────────────────────────────────────────── */ +.unifi-item-entry { + padding: 6px 0; + border-bottom: 1px solid rgba(128, 128, 128, 0.1); + + &:last-child { + border-bottom: none; + } +} + +/* ── Status colours ──────────────────────────────────────────────────────── */ +.unifi-status-connected { + color: #52c41a; + font-weight: 600; +} + +.unifi-status-blocked { + color: #ff4d4f; + font-weight: 600; +} + +.unifi-status-updating { + color: #faad14; + font-weight: 600; +} + +/* ── Action buttons area ─────────────────────────────────────────────────── */ +.unifi-actions { + margin-top: 6px; + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 4px; +} + +.unifi-action-btn { + font-size: 11px; +} + +.unifi-action-msg { + font-size: 11px; + opacity: 0.8; + width: 100%; + margin-top: 3px; +} + +.unifi-working { + font-size: 11px; + opacity: 0.7; +} + +/* ── Error banner ────────────────────────────────────────────────────────── */ +.unifi-error { + color: #ff4d4f; + font-size: 11px; + margin-bottom: 6px; +} diff --git a/templates/block.hbs b/templates/block.hbs index a717304..41bf10c 100644 --- a/templates/block.hbs +++ b/templates/block.hbs @@ -1,213 +1,236 @@ {{! UniFi Network — Block (Detail) Template }} {{#if details.error}} -
- - {{details.error}} +
+ {{fa-icon "exclamation-triangle" fixedWidth=true}} {{details.error}}
{{/if}} {{! ── Connected Clients ──────────────────────────────────────────────────── }} -{{#each details.clients as |client|}} - {{#block-section - title=(concat "🖥 Connected Client — " client.siteName) - isOpen=(eq @index 0) - }} - - {{! Identity Table }} -
- {{#if client.name}} -
- Host Name - {{client.name}} -
- {{/if}} - {{#if client.macAddress}} -
- MAC Address - {{client.macAddress}} -
- {{/if}} - {{#if client.ipAddress}} -
- IP Address - {{client.ipAddress}} -
- {{/if}} - {{#if client.type}} -
- Type - {{client.type}} -
- {{/if}} - {{#if client.status}} -
- Status - {{client.status}} -
- {{/if}} -
+{{#if hasClients}} +
+ + {{fa-icon "desktop" fixedWidth=true}} + Connected Clients + ({{details.clients.length}}) + + + {{#if block._state.showClients}} + {{fa-icon "chevron-up"}} + {{else}} + {{fa-icon "chevron-down"}} + {{/if}} + +
- {{! Network Table }} -
-
- Site - {{client.siteName}} -
- {{#if (and client.ssid (eq client.type 'WIRELESS'))}} -
- SSID - {{client.ssid}} -
- {{/if}} - {{#if client.networkId}} -
- Network ID - {{client.networkId}} -
- {{/if}} - {{#if client.uplinkDeviceMac}} -
- Uplink Device MAC - {{client.uplinkDeviceMac}} -
- {{/if}} -
+ {{#if block._state.showClients}} +
+ {{#each details.clients as |client|}} +
- {{! Traffic / Signal Table }} -
- {{#if client.connectedAt}} -
- Connected At - {{client.connectedAt}} -
- {{/if}} - {{#if client.uptime}} -
- Uptime - {{format-uptime client.uptime}} -
- {{/if}} - {{#if client.rxBytes}} -
- Rx / Tx - {{format-bytes client.rxBytes}} / {{format-bytes client.txBytes}} -
- {{/if}} - {{#if (and client.signalDbm (eq client.type 'WIRELESS'))}} -
- Signal - {{client.signalDbm}} dBm -
- {{/if}} -
+ {{! Identity }} + {{#if client.name}} +
+ Host Name + {{client.name}} +
+ {{/if}} + {{#if client.ipAddress}} +
+ IP Address + {{client.ipAddress}} +
+ {{/if}} + {{#if client.macAddress}} +
+ MAC Address + {{client.macAddress}} +
+ {{/if}} + {{#if client.type}} +
+ Type + {{client.type}} +
+ {{/if}} - {{! Action Buttons (only shown when not blocked) }} - {{#if (not-eq client.status 'BLOCKED')}} -
- {{#if (get isBlockingClient client.clientId)}} - Working... - {{else}} - - - {{/if}} - - {{#if (get blockActionMessage client.clientId)}} -
- {{get blockActionMessage client.clientId}} -
- {{/if}} -
- {{/if}} - - {{/block-section}} -{{/each}} + {{! Status — inline {{if}} helper (not block helper) is valid inside class attribute }} + {{#if client.status}} +
+ Status + + {{client.status}} + +
+ {{/if}} -{{! ── Infrastructure Devices ─────────────────────────────────────────────── }} -{{#each details.devices as |device|}} - {{#block-section - title=(concat "📡 Infrastructure Device — " device.siteName) - isOpen=(and (eq @index 0) (eq details.clients.length 0)) - }} - - {{! Identity Table }} -
- {{#if device.name}} -
- Name - {{device.name}} -
- {{/if}} - {{#if device.macAddress}} -
- MAC Address - {{device.macAddress}} -
- {{/if}} - {{#if device.ipAddress}} -
- IP Address - {{device.ipAddress}} -
- {{/if}} - {{#if device.model}} -
- Model - {{device.model}} -
- {{/if}} - {{#if device.productLine}} -
- Product Line - {{device.productLine}} -
- {{/if}} -
+ {{! Network }} + {{#if client.siteName}} +
+ Site + {{client.siteName}} +
+ {{/if}} + {{#if (and client.ssid (eq client.type 'WIRELESS'))}} +
+ SSID + {{client.ssid}} +
+ {{/if}} + {{#if client.networkId}} +
+ Network ID + {{client.networkId}} +
+ {{/if}} + {{#if client.uplinkDeviceMac}} +
+ Uplink Device + {{client.uplinkDeviceMac}} +
+ {{/if}} + + {{! Timing / Signal }} + {{#if client.connectedAt}} +
+ Connected At + {{client.connectedAt}} +
+ {{/if}} + {{#if client.uptime}} +
+ Uptime (s) + {{client.uptime}} +
+ {{/if}} + {{#if (and client.signalDbm (eq client.type 'WIRELESS'))}} +
+ Signal + {{client.signalDbm}} dBm +
+ {{/if}} + + {{! Action Buttons — only shown when client is not already blocked }} + {{#if (not (eq client.status 'BLOCKED'))}} +
+ {{#if (get block._state.isBlocking client.clientId)}} + {{fa-icon "spinner" spin=true}} Working... + {{else}} + + + {{/if}} + {{#if (get block._state.actionMessage client.clientId)}} +
+ {{get block._state.actionMessage client.clientId}} +
+ {{/if}} +
+ {{/if}} - {{! Status Table }} -
- {{#if device.state}} -
- State - {{device.state}} -
- {{/if}} - {{#if device.firmwareVersion}} -
- Firmware - {{device.firmwareVersion}} -
- {{/if}} - {{#if device.uptime}} -
- Uptime - {{format-uptime device.uptime}}
- {{/if}} -
- Site - {{device.siteName}} -
+ {{/each}}
+ {{/if}} +{{/if}} + +{{! ── Infrastructure Devices ─────────────────────────────────────────────── }} +{{#if hasDevices}} +
+ + {{fa-icon "wifi" fixedWidth=true}} + Infrastructure Devices + ({{details.devices.length}}) + + + {{#if block._state.showDevices}} + {{fa-icon "chevron-up"}} + {{else}} + {{fa-icon "chevron-down"}} + {{/if}} + +
- {{! No action buttons for infrastructure devices in V1 }} + {{#if block._state.showDevices}} +
+ {{#each details.devices as |device|}} +
- {{/block-section}} -{{/each}} + {{! Identity }} + {{#if device.name}} +
+ Name + {{device.name}} +
+ {{/if}} + {{#if device.macAddress}} +
+ MAC Address + {{device.macAddress}} +
+ {{/if}} + {{#if device.ipAddress}} +
+ IP Address + {{device.ipAddress}} +
+ {{/if}} + {{#if device.model}} +
+ Model + {{device.model}} +
+ {{/if}} + {{#if device.productLine}} +
+ Product Line + {{device.productLine}} +
+ {{/if}} + + {{! State — inline {{if}} is valid inside class attribute }} + {{#if device.state}} +
+ State + + {{device.state}} + +
+ {{/if}} + + {{! Firmware / Uptime }} + {{#if device.firmwareVersion}} +
+ Firmware + {{device.firmwareVersion}} +
+ {{/if}} + {{#if device.uptime}} +
+ Uptime (s) + {{device.uptime}} +
+ {{/if}} + + {{! Site }} + {{#if device.siteName}} +
+ Site + {{device.siteName}} +
+ {{/if}} + +
+ {{/each}} +
+ {{/if}} +{{/if}} From 30a7e7afb82b334e4e222b45e99bdf80cbaf8908 Mon Sep 17 00:00:00 2001 From: Polarity Integration Dev Date: Thu, 21 May 2026 15:29:05 -0400 Subject: [PATCH 3/3] fix(INT-1912): use site.id instead of site.siteId for UniFi API field MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The UniFi Network Integration API v1 returns the site identifier in the 'id' field (UUID), not 'siteId'. Reading site.siteId produced undefined, which was interpolated as the literal string 'undefined' into the URL: GET /v1/sites/undefined/clients?filter=... This triggered the API error: 'undefined' is not a valid 'siteId' value. Fix: read site.id in doLookup when extracting the siteId for _searchClients and _searchDevices. All downstream uses (enriched client/device objects, onMessage BLOCK_CLIENT/RECONNECT_CLIENT payloads via client.siteId) are correct — they all read the enriched property set from this same extraction point. --- integration.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/integration.js b/integration.js index ef46734..e8c1814 100644 --- a/integration.js +++ b/integration.js @@ -362,7 +362,7 @@ function doLookup(entities, options, cb) { // Step 2: for each site, run clients + devices in parallel const perSiteTasks = sites.flatMap((site) => { - const siteId = site.siteId; + const siteId = site.id; const siteName = site.name || site.meta?.desc || siteId; return [