ui: wireless landscape card — ring + legend, fix handshake toggle wrap

- Landscape card mirrors Encryption: headline text removed; square 112px
  ring (drawn height 112, CSS fixed square, no more ellipse) + HTML legend
  (dot + label + count) for Access Points / Clients / Unassociated. The
  canvas previously had no legend and the '90' headline never matched the
  dominant arc; the legend makes every count visible and labeled.
- Legend markup generalized: .recon-enc-* -> .recon-chart-* shared by both
  cards; shared chartLegendEntry() builder; landscape skips zero slices.
- .recon-toggle now flex-wraps (was overflowing 'Auto-collect handshakes'
  past the card edge at narrow widths; card min-width inner is ~142px vs a
  233px label).

Verified on device via CDP at 1200px and 860px viewports: landscape ring
112x112 square with Access Points 92 / Clients 291 / Unassociated 760;
encryption card intact; toggle label stays inside the 184px card (wraps to
2 lines, no card overflow); zero JS exceptions.
This commit is contained in:
2026-08-19 08:07:46 -05:00
parent 6927baf51f
commit 32282af1d8
2 changed files with 37 additions and 32 deletions
@@ -325,16 +325,16 @@ html.dark .muted { color: #bdbdbd; }
}
.recon-chart-box { position: relative; flex: 1; min-height: 0; margin-top: auto; }
.recon-chart-box canvas { position: absolute; inset: 0; width: 100%; height: 100%; }
#recon-encryption { position: static; display: block; width: 112px; height: 112px; margin: 0 auto; }
#recon-encryption, #recon-landscape { position: static; display: block; width: 112px; height: 112px; margin: 0 auto; }
.recon-no-data { font-style: italic; color: #787878; display: flex; justify-content: center; align-items: center; padding: 8px; text-align: center; }
.recon-enc-legend { display: flex; flex-wrap: wrap; gap: 2px 10px; margin-top: 4px; align-items: baseline; }
.recon-enc-entry { display: inline-flex; align-items: center; gap: 5px; font-size: 11px; color: var(--text); }
.recon-enc-dot { width: 8px; height: 8px; border-radius: 50%; flex: 0 0 auto; }
.recon-enc-label { color: var(--muted); }
.recon-enc-count { font-weight: 600; color: var(--text); font-variant-numeric: tabular-nums; }
.recon-chart-legend { display: flex; flex-wrap: wrap; gap: 2px 10px; margin-top: 4px; align-items: baseline; }
.recon-chart-entry { display: inline-flex; align-items: center; gap: 5px; font-size: 11px; color: var(--text); }
.recon-chart-dot { width: 8px; height: 8px; border-radius: 50%; flex: 0 0 auto; }
.recon-chart-label { color: var(--muted); }
.recon-chart-count { font-weight: 600; color: var(--text); font-variant-numeric: tabular-nums; }
.recon-hs-count { font-size: 32px; font-weight: 700; line-height: 1.1; }
.recon-hs-label { color: var(--muted); font-size: 12px; margin: 1px 0 8px; }
.recon-toggle { display: flex; align-items: center; gap: 6px; font-size: 12px; color: var(--text); margin: 0; cursor: pointer; }
.recon-toggle { display: flex; align-items: center; gap: 6px; font-size: 12px; color: var(--text); margin: 0; cursor: pointer; flex-wrap: wrap; min-width: 0; }
.recon-ps-row { display: flex; align-items: center; width: 100%; gap: 4px; margin-top: 2px; }
.recon-ps-row .sel { width: 100%; font-size: 12px; padding: 4px 6px; }
.recon-ps-actions { display: flex; align-items: center; gap: 2px; margin-top: 4px; }
@@ -1396,6 +1396,16 @@ views.pineap_enterprise = (root) => {
const iv = setInterval(load, 5000);
return { destroy: () => clearInterval(iv) };
};
function chartLegendEntry(s) {
const entry = h('div', { class: 'recon-chart-entry' });
const dot = h('span', { class: 'recon-chart-dot' });
dot.style.background = s.color;
entry.appendChild(dot);
entry.appendChild(h('span', { class: 'recon-chart-label', text: s.label }));
entry.appendChild(h('span', { class: 'recon-chart-count', text: String(s.value) }));
return entry;
}
function reconEncBucket(enc) {
const s = (enc || '').trim();
if (!s || s === 'Open') return 'Open';
@@ -1471,16 +1481,14 @@ views.recon = (root) => {
}
const landBody = titleCard('Wireless Landscape', null);
const landValue = h('div', { class: 'recon-card-value', text: '—' });
const landSub = h('div', { class: 'recon-card-sub', text: '' });
landBody.appendChild(landValue);
landBody.appendChild(landSub);
const landBox = h('div', { class: 'recon-chart-box' });
landBody.appendChild(landBox);
const landCanvas = h('canvas', { id: 'recon-landscape' });
landBox.appendChild(landCanvas);
const landEmpty = h('div', { class: 'recon-no-data', text: 'No landscape data yet — run a scan.' });
landBox.appendChild(landEmpty);
const landLegend = h('div', { class: 'recon-chart-legend', id: 'recon-landscape-legend' });
landBody.appendChild(landLegend);
const chanBody = titleCard('Channel Distribution', null);
const chanValue = h('div', { class: 'recon-card-value', text: '—' });
@@ -1501,7 +1509,7 @@ views.recon = (root) => {
encBox.appendChild(encCanvas);
const encEmpty = h('div', { class: 'recon-no-data', text: 'No encryption data yet — run a scan.' });
encBox.appendChild(encEmpty);
const encLegend = h('div', { class: 'recon-enc-legend', id: 'recon-enc-legend' });
const encLegend = h('div', { class: 'recon-chart-legend', id: 'recon-enc-legend' });
encBody.appendChild(encLegend);
const hsBody = titleCard('Handshakes', '#/recon/handshakes');
@@ -2231,11 +2239,13 @@ views.recon = (root) => {
const c = (d.clients || []).length;
const un = d.unassociated || 0;
// Card headline values (compare-aware, matching the charts below).
landValue.textContent = n;
landSub.textContent = n
? c + ' client' + (c === 1 ? '' : 's') + ' · ' + un + ' unassociated'
: '';
const landSegs = state.compare.length
? [{ label: 'Selected APs', value: n, color: RECON_LANDSCAPE_COLORS[0] }]
: [
{ label: 'Access Points', value: n, color: RECON_LANDSCAPE_COLORS[0] },
{ label: 'Clients', value: c, color: RECON_LANDSCAPE_COLORS[1] },
{ label: 'Unassociated', value: un, color: RECON_LANDSCAPE_COLORS[2] }
];
const chCounts = {};
aps.forEach((a) => {
const ch = a.channel == null ? '?' : a.channel;
@@ -2270,14 +2280,7 @@ views.recon = (root) => {
if (land && typeof MiniChart !== 'undefined' && MiniChart.doughnut) {
try {
if (n > 0) {
const segs = state.compare.length
? [{ label: 'Selected APs', value: n, color: RECON_LANDSCAPE_COLORS[0] }]
: [
{ label: 'Access Points', value: n, color: RECON_LANDSCAPE_COLORS[0] },
{ label: 'Clients', value: c, color: RECON_LANDSCAPE_COLORS[1] },
{ label: 'Unassociated', value: un, color: RECON_LANDSCAPE_COLORS[2] }
];
MiniChart.doughnut(land, segs, { legend: false, height: 90 });
MiniChart.doughnut(land, landSegs, { legend: false, height: 112 });
land.classList.remove('hidden');
landEmpty.classList.add('hidden');
} else {
@@ -2286,6 +2289,14 @@ views.recon = (root) => {
}
} catch (e) {}
}
const landLegend = document.getElementById('recon-landscape-legend');
if (landLegend) {
landLegend.innerHTML = '';
landSegs.forEach((s) => {
if (!s.value) return;
landLegend.appendChild(chartLegendEntry(s));
});
}
const ch = document.getElementById('recon-channel');
if (ch && typeof MiniChart !== 'undefined' && MiniChart.bar) {
try {
@@ -2323,13 +2334,7 @@ views.recon = (root) => {
if (encLegend) {
encLegend.innerHTML = '';
encSegs.forEach((s) => {
const entry = h('div', { class: 'recon-enc-entry' });
const dot = h('span', { class: 'recon-enc-dot' });
dot.style.background = s.color;
entry.appendChild(dot);
entry.appendChild(h('span', { class: 'recon-enc-label', text: s.label }));
entry.appendChild(h('span', { class: 'recon-enc-count', text: String(s.value) }));
encLegend.appendChild(entry);
encLegend.appendChild(chartLegendEntry(s));
});
}
}