Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions app/public/locales/en/translation.json
Original file line number Diff line number Diff line change
Expand Up @@ -385,6 +385,9 @@
},
"copyIp": "Copy IP",
"detail": {
"minSignalAny": "Any signal",
"minSignal": "Min signal",
"linkedMacs": "Linked MACs",
"dhcpLease": "DHCP lease",
"firstSeen": "First seen",
"lastSeen": "Last seen",
Expand Down
3 changes: 3 additions & 0 deletions app/public/locales/es/translation.json
Original file line number Diff line number Diff line change
Expand Up @@ -385,6 +385,9 @@
},
"copyIp": "Copiar IP",
"detail": {
"minSignalAny": "Cualquier señal",
"minSignal": "Señal mínima",
"linkedMacs": "MACs enlazadas",
"dhcpLease": "Concesión DHCP",
"firstSeen": "Primera vez vista",
"lastSeen": "Última vez vista",
Expand Down
64 changes: 42 additions & 22 deletions app/src/components/channels/CurrentChannels.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -145,14 +145,32 @@ function ChannelAnalysisChart({ bandName, channels, scans }: { bandName: string;
)
}

export function CurrentChannels({ routerId, routerName }: { routerId: string; routerName?: string }) {
export function CurrentChannels({
routerId,
routerName,
band: bandProp,
onBandChange,
}: {
routerId: string
routerName?: string
/** #1283: banda controlada por el selector superior de Canales. Cuando
* llega, el selector interno se oculta (el de arriba manda). */
band?: '2.4 GHz' | '5 GHz'
onBandChange?: (b: '2.4 GHz' | '5 GHz') => void
}) {
const { t } = useTranslation()
const reduce = useReducedMotion()
const [overview, setOverview] = useState<SurveyOverview | null>(null)
const [loading, setLoading] = useState(false)
const [error, setError] = useState(false)
const [noApi, setNoApi] = useState(false)
const [band, setBand] = useState<SurveyBand>('all')
const controlled = bandProp !== undefined
const [internalBand, setInternalBand] = useState<SurveyBand>('all')
const band: SurveyBand = controlled ? (bandProp as SurveyBand) : internalBand
const setBand = (b: SurveyBand) => {
if (controlled) onBandChange?.(b as '2.4 GHz' | '5 GHz')
else setInternalBand(b)
}
// Vecinos recientes del channel-plan de la unidad (capa opcional sobre el
// survey, #538).
const [scans, setScans] = useState<Scan[]>([])
Expand Down Expand Up @@ -252,28 +270,30 @@ export function CurrentChannels({ routerId, routerName }: { routerId: string; ro
>
{/* Header + filtro banda */}
<div className="rounded-2xl border border-border bg-surface p-5 md:p-6">
<div className="flex flex-wrap items-start justify-between gap-3">
<div className="flex items-start gap-2">
<Wifi className="mt-0.5 h-4 w-4 shrink-0 text-accent" strokeWidth={1.75} />
<div>
<h2 className="font-display text-h2 text-text-primary">{t('roaming.survey.title')}</h2>
{!controlled && (
<div className="flex flex-wrap items-start justify-between gap-3">
<div className="flex items-start gap-2">
<Wifi className="mt-0.5 h-4 w-4 shrink-0 text-accent" strokeWidth={1.75} />
<div>
<h2 className="font-display text-h2 text-text-primary">{t('roaming.survey.title')}</h2>
</div>
</div>
<div className="inline-flex items-center gap-1 rounded-lg border border-border bg-elevated p-1" role="group" aria-label={t('roaming.matrix.filterBand')}>
{bandOptions.map((b) => (
<button
key={b}
onClick={() => setBand(b)}
className={cn(
'rounded-md px-2.5 py-1 text-caption font-medium transition-colors',
band === b ? 'bg-accent/15 text-accent' : 'text-text-muted hover:text-text-secondary',
)}
>
{b}
</button>
))}
</div>
</div>
<div className="inline-flex items-center gap-1 rounded-lg border border-border bg-elevated p-1" role="group" aria-label={t('roaming.matrix.filterBand')}>
{bandOptions.map((b) => (
<button
key={b}
onClick={() => setBand(b)}
className={cn(
'rounded-md px-2.5 py-1 text-caption font-medium transition-colors',
band === b ? 'bg-accent/15 text-accent' : 'text-text-muted hover:text-text-secondary',
)}
>
{b}
</button>
))}
</div>
</div>
)}
</div>

{/* La tarjeta de la unidad seleccionada */}
Expand Down
27 changes: 25 additions & 2 deletions app/src/pages/ChannelPlan.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -161,6 +161,10 @@ export default function ChannelPlan() {
// vivo de la unidad, ex-tab de Itinerancia).
const [lens, setLens] = useState<'24h' | 'now'>('24h')
const [activeRadio, setActiveRadio] = useState('')
// #1283: banda preferida del usuario ('2.4 GHz'...). Al cambiar de unidad
// NO se resetea: si el nuevo AP tiene esa banda, se selecciona - poder
// comparar la misma banda entre APs sin que salte a la primera radio.
const [bandPref, setBandPref] = useState('')
const [hidden, setHidden] = useState<Set<string>>(new Set())
const [selected, setSelected] = useState<string | null>(null)
const [hover, setHover] = useState<{ net: SpectrumNet; x: number; y: number } | null>(null)
Expand Down Expand Up @@ -276,7 +280,10 @@ export default function ChannelPlan() {

const radioKey = (r: RadioRec) => `${r.section || r.iface}-${r.name}-${r.channel}`
const radios: RadioRec[] = data?.radios ?? []
const active: RadioRec | undefined = radios.find((r) => radioKey(r) === activeRadio) ?? radios[0]
const active: RadioRec | undefined =
radios.find((r) => radioKey(r) === activeRadio) ??
(bandPref ? radios.find((r) => r.name === bandPref) : undefined) ??
radios[0]
const routerName = sortedRouters.find((r) => r.id === routerId)?.name ?? routerId

// Vecinos de la radio activa: por banda; si el iface del radio casa con
Expand Down Expand Up @@ -506,6 +513,7 @@ export default function ChannelPlan() {
aria-selected={isActive}
onClick={() => {
setActiveRadio(key)
setBandPref(r.name)
setHidden(new Set())
setSelected(null)
setFocus(null)
Expand Down Expand Up @@ -603,7 +611,22 @@ export default function ChannelPlan() {
)}

{lens === 'now' && routerId && (
<CurrentChannels routerId={routerId} routerName={(routers ?? []).find((r) => r.id === routerId)?.name} />
<CurrentChannels
routerId={routerId}
routerName={(routers ?? []).find((r) => r.id === routerId)?.name}
band={active?.name === '5 GHz' ? '5 GHz' : '2.4 GHz'}
onBandChange={(b) => {
// #1283: el selector superior manda - elegir la radio de esa banda.
const r = radios.find((rr) => rr.name === b)
if (r) {
setActiveRadio(radioKey(r))
setBandPref(b)
setHidden(new Set())
setSelected(null)
setFocus(null)
}
}}
/>
)}

{lens === '24h' && !loading && data && active && (
Expand Down
38 changes: 37 additions & 1 deletion app/src/pages/Devices.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -578,6 +578,9 @@ interface FilterBarProps {
onlyWeak: boolean
setOnlyWeak: (v: boolean) => void
weakCount: number
/** #1283: señal mínima para la tabla (null = sin filtro). */
minSignal: number | null
setMinSignal: (v: number | null) => void
/** #1003: umbral configurado, se muestra en la etiqueta del chip */
weakDbm: number
view: 'list' | 'grid'
Expand Down Expand Up @@ -660,6 +663,18 @@ function FilterBar(p: FilterBarProps) {
))}
</DropdownMenuContent>
</DropdownMenu>
{/* Señal mínima (#1283): oculta los vecinos débiles de la tabla */}
<select
value={p.minSignal ?? ''}
onChange={(e) => p.setMinSignal(e.target.value === '' ? null : Number(e.target.value))}
aria-label={t('devices.minSignal')}
className="h-9 rounded-lg border border-border bg-elevated px-2.5 text-[13px] font-medium text-text-secondary transition-colors hover:border-accent/40 focus:outline-none"
>
<option value="">{t('devices.minSignalAny')}</option>
<option value="-70">≥ -70 dBm</option>
<option value="-80">≥ -80 dBm</option>
<option value="-90">≥ -90 dBm</option>
</select>
{/* Estado online: All / Online / Offline (#923) */}
<SegmentedControl<OnlineFilter>
options={[
Expand Down Expand Up @@ -836,6 +851,12 @@ function DeviceDetail({
<DetailItem label="Hostname" mono>
{device.hostname}
</DetailItem>
{device.aliasMacs && device.aliasMacs.length > 0 && (
// #1298: las MACs enlazadas (#1151) visibles en el detalle.
<DetailItem label={t('devices.detail.linkedMacs')} mono>
<span className="break-all">{device.aliasMacs.join(', ')}</span>
</DetailItem>
)}
<DetailItem label={t('devices.detail.manufacturer')}>{manufacturerLabel(device.manufacturer)}</DetailItem>
<DetailItem label={t('devices.detail.lastSeen')}>{fmtSeenAgo(device.lastSeenMs)}</DetailItem>
{hasAdGuard && (
Expand Down Expand Up @@ -1458,6 +1479,9 @@ export default function Devices() {
setOnlineState(v)
}, [])
const [onlyWeak, setOnlyWeak] = useState(false)
// #1283: señal mínima de la tabla (oculta vecinos débiles); null = sin filtro.
const [minSignal, setMinSignal] = useState<number | null>(null)

// #1003: umbral de señal débil desde el ajuste global (como Roaming #906).
const weakDbm = useWeakSignalDbm()
// Filtro "sin proteger por AdGuard" (#959): se activa/desactiva desde la
Expand Down Expand Up @@ -1570,12 +1594,15 @@ export default function Devices() {
if (online === 'online' && !d.online) return false
if (online === 'offline' && d.online) return false
if (onlyWeak && !(d.online && d.signalDbm !== null && d.signalDbm < weakDbm)) return false
if (minSignal !== null && d.signalDbm !== null && d.signalDbm < minSignal) return false
if (onlyUnprotected && d.adguard) return false
if (routerIds.length > 0 && !routerIds.includes(d.routerId)) return false
if (bandsEffective.length > 0 && !bandsEffective.includes(d.band as BandValue)) return false
if (groups.length > 0 && !groups.includes(d.group)) return false
if (q) {
const hay = `${d.name} ${d.ip} ${d.mac} ${d.manufacturer} ${d.hostname}`.toLowerCase()
// #1298: las MACs enlazadas (#1151) también buscables - la MAC de
// otra banda/red del mismo cliente encuentra al canónico.
const hay = `${d.name} ${d.ip} ${d.mac} ${d.manufacturer} ${d.hostname} ${(d.aliasMacs ?? []).join(' ')}`.toLowerCase()
if (!hay.includes(q)) return false
}
return true
Expand Down Expand Up @@ -1618,6 +1645,13 @@ export default function Devices() {
break
}
if (c !== 0) return dir * c
// #1283: al ordenar por banda o SSID, desempate por señal DESC
// (primero lo que más impacta: vecinos cercanos/fuertes agrupados).
if (sort.key === 'band' || sort.key === 'name') {
const sa = a.signalDbm ?? -999
const sb = b.signalDbm ?? -999
if (sa !== sb) return sb - sa
}
// Desempate: online primero, luego nombre
if (a.online !== b.online) return a.online ? -1 : 1
return a.name.localeCompare(b.name, numLocale())
Expand Down Expand Up @@ -1910,6 +1944,8 @@ export default function Devices() {

{/* ③ Filter bar */}
<FilterBar
minSignal={minSignal}
setMinSignal={setMinSignal}
routerIds={routerIds}
toggleRouterId={toggleRouterId}
routerCounts={routerCounts}
Expand Down
Loading