From f015a1db4587759b226eeebc44ddeab0da78612a Mon Sep 17 00:00:00 2001 From: Ali Bahaloo Date: Tue, 28 Jul 2026 15:26:35 -0700 Subject: [PATCH] fix(report): link advisories in the by-device table and stop it reflowing (4.2.1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two problems with the bundle report's "By device" appendix, both reported from real use. Expanding a device's "show" disclosure reflowed the whole table. Measured on the reference report: Model lost 47px, Which gained 91px, and every other column shifted. That happens exactly when a reader is comparing rows, so the rows above and below the one being opened slide out from under the cursor. Both appendix tables now use table-layout: fixed with declared column widths. The advisory identifiers were plain text, so a reader working device by device had to scroll back up to the per-advisory rows to reach Cisco. They are now links, opening in a new tab with rel="noreferrer noopener" — the same treatment the per-advisory rows and the whole-fleet report already give them. Fixing the layout exposed a second-order problem: fixed columns squeezed the advisory column to 256px while the longest identifier measures 316px, so all 16 identifiers on the sample device wrapped across three or four lines each. Fixed by giving the tables a 900px floor inside a horizontally scrollable container, and by rebalancing the columns so Which gets 40% (360px at the floor). Identifiers also no longer break mid-token — they are meant to be read and copied whole, unlike the prose cells that still wrap anywhere. Verified in a browser against the 70-row reference report: both tables hold [144, 207, 108, 81, 360] across an expand, each column set sums to exactly 100%, and all 737 identifiers across 55 device rows now sit on one line — up from 1 of 16. Tests 392 -> 402, covering the links, the fixed layout, the min-width floor, the no-mid-token-break rule, and that each column set sums to 100% (a set that does not makes the declared widths advisory rather than fixed). PATCH: presentation only, no verdict or data change. Follows 544629c, which was also a report fix that added new-tab advisory links. Co-Authored-By: Claude Opus 5 --- VERSION | 2 +- caia/bundle_report.py | 65 ++++++++++++++++++++----- tests/test_bundle_report.py | 96 +++++++++++++++++++++++++++++++++++++ 3 files changed, 149 insertions(+), 14 deletions(-) diff --git a/VERSION b/VERSION index 6aba2b2..fae6e3d 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -4.2.0 +4.2.1 diff --git a/caia/bundle_report.py b/caia/bundle_report.py index 4364dde..262ed08 100644 --- a/caia/bundle_report.py +++ b/caia/bundle_report.py @@ -240,9 +240,11 @@ def _undetermined_appendix(devices): '

Each appears above as undetermined against every advisory that covers ' 'its product family, and nowhere as unaffected. Correcting the inventory values ' 'below is what makes the next bundle review conclusive.

' - '' - '' - f'{rows}
DeviceModel recordedRelease recordedWhy
') + '
' + '' + '' + "" + f'{rows}
DeviceModel recordedRelease recordedWhy
') def _scope_appendix(bundle): @@ -255,29 +257,45 @@ def _scope_appendix(bundle): def _device_index(rows, devices): - """Per-device cross-reference: one device to every bundle advisory affecting it (FR-036).""" + """Per-device cross-reference: one device to every bundle advisory affecting it (FR-036). + + The advisory identifiers link to Cisco, matching the per-advisory rows above — a reader + working device by device should not have to scroll back up to reach the advisory. + + Column widths are fixed (`table-fixed`) so expanding a device's disclosure does not + reflow the table: the rows above and below the one being read would otherwise shift + under the cursor, which is exactly when a reader is trying to compare them. + """ by_device = {} for row in rows: for device in row.impacted: - by_device.setdefault(id(device), (device, []))[1].append(row.advisory_id) + entry = by_device.setdefault(id(device), (device, [])) + entry[1].append((row.advisory_id, row.url)) if not by_device: return "" entries = [] - for device, advisory_ids in sorted(by_device.values(), - key=lambda pair: str(pair[0].get("name") or "")): - chips = "".join(f'
  • {esc(a)}
  • ' for a in sorted(advisory_ids)) + for device, advisories in sorted(by_device.values(), + key=lambda pair: str(pair[0].get("name") or "")): + links = [] + for advisory_id, url in sorted(advisories): + label = esc(advisory_id) + links.append( + f'
  • ' + f'{label}
  • ' if url else f"
  • {label}
  • ") entries.append( f'{esc(device.get("name"))}' f'{esc(device.get("model"))}{esc(device.get("version"))}' - f'{len(advisory_ids)}' - f'
    show
      {chips}
    ' + f'{len(advisories)}' + f'
    show
      {"".join(links)}
    ' "
    ") return ('

    By device

    ' '

    The same findings, inverted, for working device by device. Devices that ' 'could not be assessed are listed separately above.

    ' - '' - '' - f'{"".join(entries)}
    DeviceModelReleaseAdvisoriesWhich
    ') + '
    ' + '' + '' + '' + f'{"".join(entries)}
    DeviceModelReleaseAdvisoriesWhich
    ') def _controls(rows): @@ -316,6 +334,27 @@ def _controls(rows): th { background: #1f4e78; color: #fff; font-weight: 600; position: sticky; top: 0; } tbody tr:nth-child(even) { background: #fbfbfc; } table.plain { box-shadow: none; border: 1px solid #e2e4e7; } +/* Fixed layout so expanding a row's disclosure cannot reflow the columns. Without it the + rows a reader is comparing shift sideways at the moment they open one. + `min-width` keeps the columns usable on a narrow window: the table scrolls horizontally + inside .tscroll rather than squeezing the advisory column until identifiers break across + four lines each. */ +/* 900px is not arbitrary: the longest advisory identifier measures 316px in this monospace + at this size, and 40% of 900 leaves 360px — enough for one line plus the list indent. + Narrower and every identifier wraps across three or four lines. */ +table.table-fixed { table-layout: fixed; width: 100%; min-width: 900px; } +.tscroll { overflow-x: auto; } +table.table-fixed th, table.table-fixed td { overflow-wrap: anywhere; } +/* Identifiers are meant to be read and copied whole, so they wrap between words only — + never mid-token like the prose cells above. */ +table.table-fixed .idlist a { overflow-wrap: normal; word-break: keep-all; } +/* Both fixed tables share the first three columns; each set sums to 100%. */ +.col-dev { width: 16%; } +.col-model { width: 23%; } +.col-rel { width: 12%; } +.col-count { width: 9%; } +.col-which { width: 40%; } +.col-why { width: 49%; } .c-adv { width: 30%; } .c-date { width: 12%; white-space: nowrap; } .advid { font-weight: 600; margin-top: .3rem; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: .85rem; } diff --git a/tests/test_bundle_report.py b/tests/test_bundle_report.py index 72f6c47..bde2de5 100644 --- a/tests/test_bundle_report.py +++ b/tests/test_bundle_report.py @@ -270,6 +270,102 @@ def test_per_device_cross_reference_is_present(self): self.assertIn("fmc-0", html) +class DeviceIndexTable(unittest.TestCase): + """The 'By device' appendix — links, and stable geometry while disclosing.""" + + def _index_section(self, html): + start = html.index("

    By device

    ") + return html[start:html.index("", start)] + + def test_advisory_ids_link_to_cisco_in_a_new_tab(self): + section = self._index_section(rendered()[0]) + links = re.findall(r'
  • ]*)>(cisco-sa-[^<]+)
  • ', + section) + self.assertTrue(links, "advisory identifiers must be links") + for url, attrs, label in links: + self.assertIn("cisco.com", url) + self.assertIn(label, url) + self.assertIn('target="_blank"', attrs) + self.assertIn("noopener", attrs) + + def test_every_advisory_for_a_device_is_linked(self): + section = self._index_section(rendered()[0]) + listed = re.findall(r"
  • (?:]*>)?(cisco-sa-[A-Za-z0-9._-]+)", section) + linked = re.findall(r"
  • ]*>(cisco-sa-[A-Za-z0-9._-]+)", section) + self.assertEqual(sorted(listed), sorted(linked), + "an unlinked identifier means a reader has to scroll back up") + + def test_an_advisory_without_a_url_still_renders(self): + """A row whose facts could not be fetched must not lose its identifier.""" + bare = advisory(FMC_ADV) + bare["publicationUrl"] = "" + devices = [device("a", "FMC", advisories=[bare])] + html, _ = rendered(devices=devices, + bundle=a_bundle(advisory_ids=(FMC_ADV,)), records={}) + section = self._index_section(html) + self.assertIn(FMC_ADV, section) + self.assertNotIn('href=""', section) + + def test_columns_do_not_reflow_when_a_disclosure_opens(self): + """Fixed layout: expanding one row must not shift the rows being compared.""" + html, _ = rendered() + section = self._index_section(html) + self.assertIn('class="plain table-fixed"', section) + for css_class in ("col-dev", "col-model", "col-rel", "col-count", "col-which"): + self.assertIn(css_class, section) + self.assertIn("table-layout: fixed", html) + + def test_every_fixed_column_has_a_declared_width(self): + html, _ = rendered() + for css_class in ("col-dev", "col-model", "col-rel", "col-count", + "col-which", "col-why"): + self.assertRegex(html, rf"\.{css_class}\s*\{{[^}}]*width:") + + def test_undetermined_table_is_also_fixed(self): + """Same disclosure-reflow problem, same fix.""" + html, _ = rendered() + start = html.index("could not be assessed") + section = html[start:html.index("", start)] + self.assertIn("table-fixed", section) + self.assertIn("col-why", section) + + def test_long_values_wrap_rather_than_widening_a_column(self): + html, _ = rendered() + self.assertIn("overflow-wrap: anywhere", html) + + def test_identifiers_are_not_broken_mid_token(self): + """An identifier split across four lines is unreadable and uncopyable.""" + html, _ = rendered() + self.assertRegex(html, r"\.idlist a\s*\{[^}]*overflow-wrap:\s*normal") + self.assertRegex(html, r"\.idlist a\s*\{[^}]*word-break:\s*keep-all") + + def test_fixed_tables_have_a_minimum_width_and_scroll_when_narrower(self): + """Fixed columns must not squeeze the advisory column on a narrow window. + + The longest identifier measures ~316px in this monospace at this size, so the + advisory column needs ~334px including the list indent. 40% of the 900px floor + gives 360px; anything much narrower wraps every identifier. + """ + html, _ = rendered() + self.assertRegex(html, r"table\.table-fixed\s*\{[^}]*min-width:\s*900px") + self.assertRegex(html, r"\.tscroll\s*\{[^}]*overflow-x:\s*auto") + section = self._index_section(html) + self.assertIn('
    ', section) + + def test_each_fixed_table_column_set_sums_to_one_hundred_percent(self): + """A set summing to anything else makes the declared widths advisory, not fixed.""" + html, _ = rendered() + width = {} + for name in ("col-dev", "col-model", "col-rel", "col-count", "col-which", "col-why"): + found = re.search(rf"\.{name}\s*\{{[^}}]*width:\s*(\d+)%", html) + self.assertIsNotNone(found, f"{name} has no percentage width") + width[name] = int(found.group(1)) + device_index = ("col-dev", "col-model", "col-rel", "col-count", "col-which") + undetermined = ("col-dev", "col-model", "col-rel", "col-why") + self.assertEqual(sum(width[c] for c in device_index), 100) + self.assertEqual(sum(width[c] for c in undetermined), 100) + + class Output(unittest.TestCase): def test_filename_carries_the_bundle_and_keeps_the_prefix(self): with tempfile.TemporaryDirectory() as tmp: