diff --git a/README.md b/README.md index 44b58e2..e243fe6 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@

- Version + Version License Docker TypeScript diff --git a/backend/package.json b/backend/package.json index 1d9a5ab..39b78a9 100644 --- a/backend/package.json +++ b/backend/package.json @@ -1,6 +1,6 @@ { "name": "mikrotik-manager-backend", - "version": "0.23.6-beta", + "version": "0.23.7-beta", "description": "Mikrotik Network Management Platform - Backend API", "main": "dist/index.js", "scripts": { diff --git a/backend/src/routes/wireless.ts b/backend/src/routes/wireless.ts index c81e98b..0800fdd 100644 --- a/backend/src/routes/wireless.ts +++ b/backend/src/routes/wireless.ts @@ -3,7 +3,7 @@ import { query } from '../config/database'; import { requireAuth, requireWrite } from '../middleware/auth'; import { DeviceCollector, DeviceRow } from '../services/mikrotik/DeviceCollector'; import { lookupVendor } from '../utils/oui'; -import { analyzeSpectrum, summarize, BAND_RANGES } from '../utils/rfSpectrum'; +import { analyzeSpectrum, summarize, BAND_RANGES, parseChannelSpec } from '../utils/rfSpectrum'; const router = Router(); router.use(requireAuth); @@ -874,16 +874,24 @@ function deviceScope(req: Request): number | null { */ router.get('/rf/channels', async (req: Request, res: Response) => { const deviceId = deviceScope(req); + + // Two sources, because a CAPsMAN-managed AP stores no frequency of its own — the + // controller owns the channel. Reading only wireless_interfaces silently dropped + // every managed radio, which left them out of the analysis entirely and pushed + // whatever remained into "standalone" (issue #97). const rows = await query<{ device_id: number; device_name: string; name: string; ssid: string | null; - band: string | null; frequency: number; channel_width: string | null; + frequency: number | null; channel_width: string | null; registered_clients: number | null; controller_device_id: number | null; controller_name: string | null; + capsman_channel: string | null; capsman_peers: number | null; }>(` SELECT wi.device_id, d.name AS device_name, wi.name, wi.ssid, - wi.band, wi.frequency, wi.channel_width, wi.registered_clients, + wi.frequency, wi.channel_width, wi.registered_clients, cr.controller_device_id, - cd.name AS controller_name + cd.name AS controller_name, + cr.current_channel AS capsman_channel, + cr.registered_peers AS capsman_peers FROM wireless_interfaces wi JOIN devices d ON d.id = wi.device_id LEFT JOIN capsman_radios cr ON cr.matched_device_id = wi.device_id @@ -891,17 +899,33 @@ router.get('/rf/channels', async (req: Request, res: Response) => { LEFT JOIN devices cd ON cd.id = cr.controller_device_id WHERE (d.device_type = 'wireless_ap' OR d.wifi_role IN ('cap','controller','controller_cap')) AND wi.disabled = FALSE - AND wi.frequency IS NOT NULL AND wi.frequency > 0 AND (wi.config_json->>'master-interface') IS NULL + AND ( + (wi.frequency IS NOT NULL AND wi.frequency > 0) + OR cr.current_channel IS NOT NULL + ) ${deviceId ? 'AND wi.device_id = $1' : ''} - ORDER BY wi.frequency ASC, d.name ASC + ORDER BY d.name ASC, wi.name ASC `, deviceId ? [deviceId] : []); + // Resolve each radio's operating channel, preferring what the controller reports. + const resolved = rows.map((r) => { + const fromController = parseChannelSpec(r.capsman_channel); + const frequency = fromController?.frequency ?? r.frequency ?? 0; + const channel_width = fromController ? `${fromController.widthMhz}mhz` : r.channel_width; + return { + ...r, + frequency, + channel_width, + registered_clients: r.capsman_peers ?? r.registered_clients ?? 0, + }; + }).filter((r) => r.frequency > 0); + // Interference is only meaningful between radios that can hear each other, so // the analysis runs per location: each controller separately, and everything // unmanaged as one group. - const groups = new Map(); - for (const r of rows) { + const groups = new Map(); + for (const r of resolved) { const key = r.controller_device_id != null ? `c:${r.controller_device_id}` : 'standalone'; groups.set(key, [...(groups.get(key) ?? []), r]); } diff --git a/backend/src/utils/__tests__/rfSpectrum.test.ts b/backend/src/utils/__tests__/rfSpectrum.test.ts index 16905d0..f0651d3 100644 --- a/backend/src/utils/__tests__/rfSpectrum.test.ts +++ b/backend/src/utils/__tests__/rfSpectrum.test.ts @@ -1,6 +1,6 @@ import { spectrumFor, classifyPair, overlapMhz, analyzeSpectrum, summarize, freeSpans, - widthMhz, channelForFreq, bandForFreq, + widthMhz, channelForFreq, bandForFreq, parseChannelSpec, } from '../rfSpectrum'; const ch = (n: number) => 2407 + n * 5; // 2.4 GHz channel → centre MHz @@ -190,3 +190,23 @@ describe('freeSpans', () => { expect(freeSpans([], '2.4')).toEqual([{ startMhz: 2400, endMhz: 2500 }]); }); }); + +describe('parseChannelSpec — a CAPsMAN AP has no frequency of its own (#97)', () => { + it('reads frequency and derives width from the control positions', () => { + // Real strings from a CAPsMAN controller. + expect(parseChannelSpec('2412/ax/Ce')).toEqual({ frequency: 2412, widthMhz: 40 }); + expect(parseChannelSpec('5680/ax/eCee/D')).toEqual({ frequency: 5680, widthMhz: 80 }); + expect(parseChannelSpec('5500/ax/Ceeeeeee/D')).toEqual({ frequency: 5500, widthMhz: 160 }); + }); + + it('defaults to 20 MHz when no control positions are given', () => { + expect(parseChannelSpec('2437/ax')).toEqual({ frequency: 2437, widthMhz: 20 }); + expect(parseChannelSpec('2437')).toEqual({ frequency: 2437, widthMhz: 20 }); + }); + + it('returns null for anything unparseable', () => { + expect(parseChannelSpec(null)).toBeNull(); + expect(parseChannelSpec('')).toBeNull(); + expect(parseChannelSpec('auto')).toBeNull(); + }); +}); diff --git a/backend/src/utils/rfSpectrum.ts b/backend/src/utils/rfSpectrum.ts index 550e187..7d7dd45 100644 --- a/backend/src/utils/rfSpectrum.ts +++ b/backend/src/utils/rfSpectrum.ts @@ -203,3 +203,25 @@ export function freeSpans( if (range.endMhz - cursor >= minWidth) spans.push({ startMhz: cursor, endMhz: range.endMhz }); return spans; } + +/** + * Parse a RouterOS channel specification into frequency and width. + * + * Format is `/[/]`, e.g. `2412/ax/Ce` or + * `5680/ax/eCee/D`. The control-position letters count the 20 MHz subchannels the + * radio is bonding, so `Ce` is 40 MHz and `Ceee` is 80 MHz — a precise operating + * width rather than the capability list a width field reports. + * + * Needed because a CAPsMAN-managed AP stores no frequency of its own: the + * controller owns the channel, and reports it only in this form (issue #97). + */ +export function parseChannelSpec(spec: string | null | undefined): { frequency: number; widthMhz: number } | null { + if (!spec) return null; + const segs = spec.split('/'); + const frequency = parseInt(segs[0], 10); + if (!frequency || isNaN(frequency)) return null; + + const letters = (segs[2] || '').replace(/[^a-zA-Z]/g, '').length; + const widthMhz = letters >= 8 ? 160 : letters >= 4 ? 80 : letters >= 2 ? 40 : 20; + return { frequency, widthMhz }; +} diff --git a/frontend/package.json b/frontend/package.json index 87caaf7..b61276f 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "mikrotik-manager-frontend", - "version": "0.23.6-beta", + "version": "0.23.7-beta", "private": true, "type": "module", "scripts": { diff --git a/frontend/src/components/device-detail/DeviceLocationSection.tsx b/frontend/src/components/device-detail/DeviceLocationSection.tsx index e115ed5..5381ec1 100644 --- a/frontend/src/components/device-detail/DeviceLocationSection.tsx +++ b/frontend/src/components/device-detail/DeviceLocationSection.tsx @@ -281,17 +281,6 @@ export default function DeviceLocationSection({ device }: Props) { address={device.location_address!} /> )} - {hasMap && !editing && !mapsEnabled && ( -

- -
- Map display is off, so no request is made to a third-party tile or geocoding - service. Stored position:{' '} - {Number(device.location_lat).toFixed(5)}, {Number(device.location_lng).toFixed(5)}. - Re-enable under Settings. -
-
- )} ); } diff --git a/frontend/src/pages/SettingsPage.tsx b/frontend/src/pages/SettingsPage.tsx index 8d2519f..aea0edb 100644 --- a/frontend/src/pages/SettingsPage.tsx +++ b/frontend/src/pages/SettingsPage.tsx @@ -750,6 +750,35 @@ export default function SettingsPage() { + {/* Maps & geocoding (issue #106) */} +
+

Maps & Geocoding

+

+ Device location maps use OpenStreetMap for tiles and Nominatim to turn an address + into coordinates. Both are outbound requests to a third party, and the address you + enter is sent to them. Turn this off on isolated networks, or wherever device + locations should not leave your infrastructure — everything else keeps working, and + addresses are still recorded as text. +

+
+
Enable maps and address lookup
+ +
+
+

Data Retention

diff --git a/frontend/src/version.ts b/frontend/src/version.ts index 73d2e4a..f8c30ed 100644 --- a/frontend/src/version.ts +++ b/frontend/src/version.ts @@ -1 +1 @@ -export const APP_VERSION = 'v0.23.6 Beta'; +export const APP_VERSION = 'v0.23.7 Beta';