diff --git a/README.md b/README.md index b08ecc5..c9dd690 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 93bf77b..2066a79 100644 --- a/backend/package.json +++ b/backend/package.json @@ -1,6 +1,6 @@ { "name": "mikrotik-manager-backend", - "version": "0.23.12-beta", + "version": "0.23.13-beta", "description": "Mikrotik Network Management Platform - Backend API", "main": "dist/index.js", "scripts": { diff --git a/backend/src/utils/__tests__/rfSpectrum.test.ts b/backend/src/utils/__tests__/rfSpectrum.test.ts index f0651d3..a0d4439 100644 --- a/backend/src/utils/__tests__/rfSpectrum.test.ts +++ b/backend/src/utils/__tests__/rfSpectrum.test.ts @@ -187,7 +187,8 @@ describe('freeSpans', () => { }); it('returns the whole band when nothing is deployed', () => { - expect(freeSpans([], '2.4')).toEqual([{ startMhz: 2400, endMhz: 2500 }]); + // The band starts below 2400 so a 40 MHz channel 1 (2392–2432) fits inside it. + expect(freeSpans([], '2.4')).toEqual([{ startMhz: 2390, endMhz: 2500 }]); }); }); diff --git a/backend/src/utils/rfSpectrum.ts b/backend/src/utils/rfSpectrum.ts index 7d7dd45..0daffcd 100644 --- a/backend/src/utils/rfSpectrum.ts +++ b/backend/src/utils/rfSpectrum.ts @@ -31,9 +31,17 @@ export interface BandRange { endMhz: number; } -/** Spectrum drawn for each band. Wide enough to contain every legal channel. */ +/** + * Spectrum drawn for each band. Wide enough to contain every legal channel — + * including the ones that spill past the round numbers. + * + * 2.4 GHz starts at 2390 rather than 2400 because a 40 MHz channel centred on + * channel 1 occupies 2392–2432. Starting the axis at 2400 puts that carrier at a + * negative offset, which is not merely a cosmetic problem: it renders outside the + * chart and misstates how far the radio actually reaches below the band. + */ export const BAND_RANGES: BandRange[] = [ - { band: '2.4', label: '2.4 GHz', startMhz: 2400, endMhz: 2500 }, + { band: '2.4', label: '2.4 GHz', startMhz: 2390, endMhz: 2500 }, { band: '5', label: '5 GHz', startMhz: 5150, endMhz: 5895 }, { band: '6', label: '6 GHz', startMhz: 5925, endMhz: 7125 }, ]; diff --git a/frontend/package.json b/frontend/package.json index f03d72e..9b8aba8 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "mikrotik-manager-frontend", - "version": "0.23.12-beta", + "version": "0.23.13-beta", "private": true, "type": "module", "scripts": { diff --git a/frontend/src/components/wireless/RfHealth.tsx b/frontend/src/components/wireless/RfHealth.tsx index 7da2c84..38fac29 100644 --- a/frontend/src/components/wireless/RfHealth.tsx +++ b/frontend/src/components/wireless/RfHealth.tsx @@ -12,6 +12,7 @@ import { BAND_LABEL, RfBand, RSSI_ZONES, rssiColor, RETRY_BUCKETS, retryBucketIndex, retryColor, } from '../../utils/wifiChannels'; +import { barGeometry } from '../../utils/spectrumGeometry'; const ALL_BANDS: RfBand[] = ['2.4', '5', '6']; @@ -60,7 +61,11 @@ function SpectrumBandRow({ band, radios }: { band: RfBandRange; radios: RfChanne

- {lanes.map((lane, li) => lane.map((r) => ( + {lanes.map((lane, li) => lane.map((r) => { + const { leftPct, widthPct } = barGeometry( + r.low_mhz, r.high_mhz, band.startMhz, band.endMhz, + ); + return (
- )))} + ); + }))}
diff --git a/frontend/src/utils/spectrumGeometry.test.ts b/frontend/src/utils/spectrumGeometry.test.ts new file mode 100644 index 0000000..4f5aa67 --- /dev/null +++ b/frontend/src/utils/spectrumGeometry.test.ts @@ -0,0 +1,59 @@ +import { describe, it, expect } from 'vitest'; +import { barGeometry } from './spectrumGeometry'; + +// The 2.4 GHz band as drawn, wide enough to contain a 40 MHz channel 1. +const B24 = [2390, 2500] as const; + +describe('barGeometry', () => { + it('places an ordinary carrier proportionally', () => { + // Channel 6 at 20 MHz: 2427–2447 within 2390–2500. + const g = barGeometry(2427, 2447, ...B24); + expect(g.leftPct).toBeCloseTo(33.6, 1); + expect(g.widthPct).toBeCloseTo(18.2, 1); + }); + + it('keeps a 40 MHz channel 1 inside the band', () => { + // The regression: 2392 sits below a 2400 start and produced left: -8%, + // which painted the bar outside the card. + const g = barGeometry(2392, 2432, ...B24); + expect(g.leftPct).toBeGreaterThanOrEqual(0); + expect(g.leftPct).toBeCloseTo(1.8, 1); + expect(g.leftPct + g.widthPct).toBeLessThanOrEqual(100); + }); + + it('never returns a negative offset, whatever the band', () => { + // Even against the old, too-narrow band the bar stays inside the card. + const g = barGeometry(2392, 2432, 2400, 2500); + expect(g.leftPct).toBe(0); + expect(g.widthPct).toBeCloseTo(32, 1); + }); + + it('never overhangs the right edge', () => { + const g = barGeometry(2480, 2540, ...B24); + expect(g.leftPct + g.widthPct).toBeLessThanOrEqual(100); + }); + + it('keeps a hairline carrier visible', () => { + const g = barGeometry(2410, 2410, ...B24); + expect(g.widthPct).toBeGreaterThan(0); + }); + + it('keeps a carrier at the extreme right edge on the card', () => { + const g = barGeometry(2500, 2500, ...B24); + expect(g.leftPct + g.widthPct).toBeLessThanOrEqual(100); + expect(g.leftPct).toBeGreaterThanOrEqual(0); + }); + + it('survives a degenerate band range', () => { + const g = barGeometry(2400, 2420, 2400, 2400); + expect(g.leftPct).toBe(0); + expect(g.widthPct).toBeGreaterThan(0); + }); + + it('handles a 160 MHz 5 GHz carrier', () => { + // wifi2 on channel 100: 5420–5580 within 5150–5895. + const g = barGeometry(5420, 5580, 5150, 5895); + expect(g.leftPct).toBeCloseTo(36.2, 1); + expect(g.widthPct).toBeCloseTo(21.5, 1); + }); +}); diff --git a/frontend/src/utils/spectrumGeometry.ts b/frontend/src/utils/spectrumGeometry.ts new file mode 100644 index 0000000..3712b32 --- /dev/null +++ b/frontend/src/utils/spectrumGeometry.ts @@ -0,0 +1,42 @@ +/** + * Placement of a radio's carrier within a drawn spectrum band. + * + * Split out of the channel map because the arithmetic had a bug that was + * invisible in the component: a 40 MHz channel 1 occupies 2392–2432 MHz, and + * while the 2.4 GHz axis started at 2400 that carrier resolved to a *negative* + * offset. An absolutely positioned bar at `left: -8%` renders outside its + * container, so the 2.4 GHz row painted over the edge of the card. + * + * The band table has since been widened to contain channel 1 honestly, but the + * clamp stays: band ranges are static assumptions, and region-specific channels + * and widths they do not anticipate will keep turning up. + */ +export interface BarGeometry { + /** Offset from the left of the band, as a percentage. Never negative. */ + leftPct: number; + /** Width as a percentage. Never extends past the right edge. */ + widthPct: number; +} + +/** Narrowest bar that stays visible; a hairline carrier must not vanish. */ +const MIN_WIDTH_PCT = 0.6; + +export function barGeometry( + lowMhz: number, + highMhz: number, + bandStartMhz: number, + bandEndMhz: number, +): BarGeometry { + const span = bandEndMhz - bandStartMhz; + if (!(span > 0)) return { leftPct: 0, widthPct: MIN_WIDTH_PCT }; + + const clamp = (mhz: number) => + Math.max(0, Math.min(100, ((mhz - bandStartMhz) / span) * 100)); + + const start = clamp(lowMhz); + const end = clamp(highMhz); + const widthPct = Math.max(MIN_WIDTH_PCT, end - start); + + // Pull a bar that would overhang back inside rather than letting it bleed. + return { leftPct: Math.max(0, Math.min(start, 100 - widthPct)), widthPct }; +} diff --git a/frontend/src/version.ts b/frontend/src/version.ts index 93bc92a..67575a1 100644 --- a/frontend/src/version.ts +++ b/frontend/src/version.ts @@ -1 +1 @@ -export const APP_VERSION = 'v0.23.12 Beta'; +export const APP_VERSION = 'v0.23.13 Beta';