diff --git a/CHANGELOG.md b/CHANGELOG.md index 3582653..bcda5fb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,20 @@ versioning. ## [Unreleased] +## [0.1.2] - 2026-08-04 + +### Changed + +- Windows installation documentation now covers installing Python with + WinGet and restarting PowerShell after both Python and pipx update `PATH`. + +### Fixed + +- Empty top-level regions are removed after their empty descendant locations + are pruned from the synchronized inventory tree. +- NetBox devices without a name no longer fail synchronization; their API + display value or object ID is used as a safe fallback label. + ## [0.1.1] - 2026-08-04 ### Added diff --git a/COMPATIBILITY.md b/COMPATIBILITY.md index 7ea098d..3cd3bb4 100644 --- a/COMPATIBILITY.md +++ b/COMPATIBILITY.md @@ -12,7 +12,7 @@ with Python 3.11 and 3.13 on macOS, Linux, and Windows. | macOS | Supported and tested | `~/Library/Caches/netbox-ssh-browser/` | | Linux | Supported | `~/.cache/netbox-ssh-browser/` | | Ubuntu on WSL2 | Supported and manually tested | `~/.cache/netbox-ssh-browser/` | -| Windows | Expected to work; system `ssh` is required | `%LOCALAPPDATA%` via `platformdirs` | +| Windows | Supported and manually tested | `%LOCALAPPDATA%` via `platformdirs` | ## NetBox @@ -26,8 +26,9 @@ before inventory reaches the application. ## Terminals The Textual interface requires a terminal with standard ANSI and alternate -screen support. It is tested in iTerm2 on macOS and is not implemented as an -iTerm2 plugin. +screen support. It is manually tested in iTerm2 on macOS, Ubuntu under WSL2, +and Windows Terminal with PowerShell. It is not implemented as an iTerm2 +plugin. Opening multiple selected devices as tabs is currently supported only when `nssh` runs inside iTerm2 on macOS. Other terminals retain the portable diff --git a/PUBLISHING.md b/PUBLISHING.md index 62cc06a..9866d2c 100644 --- a/PUBLISHING.md +++ b/PUBLISHING.md @@ -65,7 +65,7 @@ Test the wheel rather than the source checkout: ```bash python -m venv /tmp/netbox-ssh-browser-release-test /tmp/netbox-ssh-browser-release-test/bin/python -m pip install \ - dist/netbox_ssh_browser-0.1.1-py3-none-any.whl + dist/netbox_ssh_browser-0.1.2-py3-none-any.whl /tmp/netbox-ssh-browser-release-test/bin/nssh --version ``` @@ -80,7 +80,7 @@ paths in a temporary virtual environment. 2. Move completed entries from `Unreleased` in `CHANGELOG.md` into the new version section. 3. Run the complete test and build checks locally. -4. Push the release commit and a matching tag such as `v0.1.1`. +4. Push the release commit and a matching tag such as `0.1.2`. 5. Create and publish a GitHub Release from that tag, or manually run the `Publish package to PyPI` workflow from GitHub Actions. 6. Approve the protected `pypi` environment when GitHub requests it. diff --git a/README.md b/README.md index a541969..7eff199 100644 --- a/README.md +++ b/README.md @@ -18,6 +18,8 @@ for quick access when NetBox is unavailable. It does not implement an SSH client, store SSH credentials, modify NetBox, or synchronize automatically in the background. +![NetBox SSH Browser demonstration](docs/images/netbox-ssh-browser-demo.gif) + ## Contents - [What It Does](#what-it-does) @@ -104,16 +106,34 @@ new terminal after updating `PATH`. ### Windows -In PowerShell, install `pipx` with Scoop: +Install Python 3.13 from WinGet in PowerShell: ```powershell -scoop install pipx -pipx ensurepath +winget install --exact --id Python.Python.3.13 +``` + +Close every PowerShell window and open PowerShell again so that the `py` +launcher is added to `PATH`. Confirm the installation, then install `pipx`: + +```powershell +py --version +py -m pip install --user pipx +py -m pipx ensurepath +``` + +Close PowerShell and open it again a second time so that `pipx` and its +application directory are added to `PATH`. Then install and verify NetBox SSH +Browser: + +```powershell +pipx --version pipx install netbox-ssh-browser +nssh --version ``` -Alternatively, install pipx through Python with `py -m pip install --user pipx`, -run the resulting `pipx.exe ensurepath`, and restart PowerShell. +The expected version output is similar to `nssh 0.1.2`. Scoop is an optional +alternative, but `scoop install pipx` works only when Scoop has already been +installed separately. ### Verify, upgrade, and uninstall @@ -417,6 +437,15 @@ python -m compileall -q src tests See [COMPATIBILITY.md](COMPATIBILITY.md) for the supported Python, operating system, terminal, and NetBox versions. +The application has been manually tested on: + +- macOS on Apple silicon (MacBook Pro M5 Pro) with iTerm2, +- Ubuntu under WSL2 on Windows, +- native Windows with PowerShell, Windows Terminal, and Windows OpenSSH. + +The automated test suite additionally runs on macOS, Ubuntu, and Windows with +Python 3.11 and 3.13. + ## Acknowledgements Development of NetBox SSH Browser was assisted by OpenAI Codex. diff --git a/docs/images/README.md b/docs/images/README.md new file mode 100644 index 0000000..f76291a --- /dev/null +++ b/docs/images/README.md @@ -0,0 +1,55 @@ +# Screenshots + +The screenshots below show the NetBox SSH Browser workflow in sequence. + +## First launch + +![NetBox SSH Browser screenshot 01](netbox-ssh-browser_01.gif) + +## Saving the configuration + +![NetBox SSH Browser screenshot 02](netbox-ssh-browser_02.gif) + +## Sync from NetBox + +![NetBox SSH Browser screenshot 03](netbox-ssh-browser_03.gif) + +## Adding a manual host + +![NetBox SSH Browser screenshot 04](netbox-ssh-browser_04.gif) + +## Browsing the location tree + +![NetBox SSH Browser screenshot 05](netbox-ssh-browser_05.gif) + +## Device list for a site + +![NetBox SSH Browser screenshot 06](netbox-ssh-browser_06.gif) + +## Searching for a device + +![NetBox SSH Browser screenshot 07](netbox-ssh-browser_07.gif) + +## Viewing search results + +![NetBox SSH Browser screenshot 08](netbox-ssh-browser_08.gif) + +## Selecting devices for multiple SSH sessions + +![NetBox SSH Browser screenshot 09](netbox-ssh-browser_09.gif) + +## Adding a manual device + +![NetBox SSH Browser screenshot 10](netbox-ssh-browser_10.gif) + +## Entering manual device details + +![NetBox SSH Browser screenshot 11](netbox-ssh-browser_11.gif) + +## Manual device added + +![NetBox SSH Browser screenshot 12](netbox-ssh-browser_12.gif) + +## Previewing the manual inventory file + +![NetBox SSH Browser screenshot 13](netbox-ssh-browser_13.gif) diff --git a/docs/images/netbox-ssh-browser-demo.gif b/docs/images/netbox-ssh-browser-demo.gif new file mode 100644 index 0000000..a3d21d7 Binary files /dev/null and b/docs/images/netbox-ssh-browser-demo.gif differ diff --git a/docs/images/netbox-ssh-browser_01.gif b/docs/images/netbox-ssh-browser_01.gif new file mode 100644 index 0000000..eab4cb2 Binary files /dev/null and b/docs/images/netbox-ssh-browser_01.gif differ diff --git a/docs/images/netbox-ssh-browser_02.gif b/docs/images/netbox-ssh-browser_02.gif new file mode 100644 index 0000000..147d560 Binary files /dev/null and b/docs/images/netbox-ssh-browser_02.gif differ diff --git a/docs/images/netbox-ssh-browser_03.gif b/docs/images/netbox-ssh-browser_03.gif new file mode 100644 index 0000000..fe274fe Binary files /dev/null and b/docs/images/netbox-ssh-browser_03.gif differ diff --git a/docs/images/netbox-ssh-browser_04.gif b/docs/images/netbox-ssh-browser_04.gif new file mode 100644 index 0000000..5bbda1f Binary files /dev/null and b/docs/images/netbox-ssh-browser_04.gif differ diff --git a/docs/images/netbox-ssh-browser_05.gif b/docs/images/netbox-ssh-browser_05.gif new file mode 100644 index 0000000..d346a4c Binary files /dev/null and b/docs/images/netbox-ssh-browser_05.gif differ diff --git a/docs/images/netbox-ssh-browser_06.gif b/docs/images/netbox-ssh-browser_06.gif new file mode 100644 index 0000000..dd55703 Binary files /dev/null and b/docs/images/netbox-ssh-browser_06.gif differ diff --git a/docs/images/netbox-ssh-browser_07.gif b/docs/images/netbox-ssh-browser_07.gif new file mode 100644 index 0000000..d902f15 Binary files /dev/null and b/docs/images/netbox-ssh-browser_07.gif differ diff --git a/docs/images/netbox-ssh-browser_08.gif b/docs/images/netbox-ssh-browser_08.gif new file mode 100644 index 0000000..6fe165a Binary files /dev/null and b/docs/images/netbox-ssh-browser_08.gif differ diff --git a/docs/images/netbox-ssh-browser_09.gif b/docs/images/netbox-ssh-browser_09.gif new file mode 100644 index 0000000..7054463 Binary files /dev/null and b/docs/images/netbox-ssh-browser_09.gif differ diff --git a/docs/images/netbox-ssh-browser_10.gif b/docs/images/netbox-ssh-browser_10.gif new file mode 100644 index 0000000..b63d771 Binary files /dev/null and b/docs/images/netbox-ssh-browser_10.gif differ diff --git a/docs/images/netbox-ssh-browser_11.gif b/docs/images/netbox-ssh-browser_11.gif new file mode 100644 index 0000000..d095d5d Binary files /dev/null and b/docs/images/netbox-ssh-browser_11.gif differ diff --git a/docs/images/netbox-ssh-browser_12.gif b/docs/images/netbox-ssh-browser_12.gif new file mode 100644 index 0000000..fa7a5a8 Binary files /dev/null and b/docs/images/netbox-ssh-browser_12.gif differ diff --git a/docs/images/netbox-ssh-browser_13.gif b/docs/images/netbox-ssh-browser_13.gif new file mode 100644 index 0000000..63d7824 Binary files /dev/null and b/docs/images/netbox-ssh-browser_13.gif differ diff --git a/src/netbox_ssh/__init__.py b/src/netbox_ssh/__init__.py index ef94a1f..e050085 100644 --- a/src/netbox_ssh/__init__.py +++ b/src/netbox_ssh/__init__.py @@ -1,3 +1,3 @@ """NetBox SSH Browser.""" -__version__ = "0.1.1" +__version__ = "0.1.2" diff --git a/src/netbox_ssh/model.py b/src/netbox_ssh/model.py index c7633ad..bc57011 100644 --- a/src/netbox_ssh/model.py +++ b/src/netbox_ssh/model.py @@ -97,10 +97,19 @@ def build_tree( target_node = _child_named(region_node, site_name) role = raw.get("role") or raw.get("device_role") or {} role_name = role.get("name") or role.get("display") or "Other" + # NetBox zezwala na urządzenia bez nazwy. W takim przypadku odpowiedź + # API zawiera zwykle czytelne `display`; ID pozostaje ostatnim, + # stabilnym fallbackiem i gwarantuje tekst wymagany przez TUI. + device_name = raw.get("name") or raw.get("display") + if not device_name: + device_id = raw.get("id") + device_name = ( + f"Device {device_id}" if device_id is not None else "Unnamed device" + ) ip = raw.get("primary_ip4") or raw.get("primary_ip6") if isinstance(ip, dict): ip = ip.get("address") or ip.get("display") - target_node.devices.append(Device(raw["name"], role_name, ip)) + target_node.devices.append(Device(str(device_name), str(role_name), ip)) _prune_and_sort(roots) return roots @@ -124,8 +133,10 @@ def _object_id(value: Any) -> int | None: def _prune_and_sort(nodes: list[Node]) -> None: # Puste gałęzie nie pomagają w nawigacji, więc nie zapisujemy ich w cache. - nodes[:] = [node for node in nodes if node.children or node.devices] - nodes.sort(key=lambda item: item.name.casefold()) + # Najpierw czyścimy potomków, bo rodzic zawierający wyłącznie puste gałęzie + # również powinien zostać usunięty. for node in nodes: _prune_and_sort(node.children) node.devices.sort(key=lambda item: (item.role.casefold(), item.name.casefold())) + nodes[:] = [node for node in nodes if node.children or node.devices] + nodes.sort(key=lambda item: item.name.casefold()) diff --git a/tests/test_cli.py b/tests/test_cli.py index 3682c46..d725d47 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -44,6 +44,21 @@ def test_empty_ignore_list_keeps_all(self) -> None: devices = [{"name": "one"}] self.assertIs(filter_ignored_manufacturers(devices, ()), devices) + def test_missing_device_type_is_kept_when_filtering_manufacturers(self) -> None: + devices = [ + {"name": "without-device-type", "device_type": None}, + { + "name": "ignored-cisco-device", + "device_type": {"manufacturer": {"name": "Cisco"}}, + }, + ] + + result = filter_ignored_manufacturers(devices, ("Cisco",)) + + self.assertEqual( + [device["name"] for device in result], ["without-device-type"] + ) + if __name__ == "__main__": unittest.main() diff --git a/tests/test_entrypoint.py b/tests/test_entrypoint.py index 5e328d4..f8b5038 100644 --- a/tests/test_entrypoint.py +++ b/tests/test_entrypoint.py @@ -11,7 +11,7 @@ def test_version_does_not_start_tui(self) -> None: with contextlib.redirect_stdout(output), self.assertRaises(SystemExit) as exit_result: main(["--version"]) self.assertEqual(exit_result.exception.code, 0) - self.assertEqual(output.getvalue().strip(), "nssh 0.1.1") + self.assertEqual(output.getvalue().strip(), "nssh 0.1.2") if __name__ == "__main__": diff --git a/tests/test_model.py b/tests/test_model.py index b4408b6..2e278f2 100644 --- a/tests/test_model.py +++ b/tests/test_model.py @@ -42,6 +42,43 @@ def test_adds_differently_named_site_and_falls_back_to_hostname(self) -> None: self.assertEqual(branch.name, "Warehouse 1") self.assertEqual(branch.devices[0].ssh_target, "switch-a-01") + def test_uses_display_for_device_without_a_name(self) -> None: + sites = [{"id": 10, "name": "City A", "region": {"id": 354}}] + devices = [ + { + "id": 74, + "name": None, + "display": "unnamed-device-74", + "site": {"id": 10}, + "role": {"name": "Switch"}, + "primary_ip4": {"address": "192.0.2.74/24"}, + } + ] + + regions = build_tree(self.regions, sites, devices) + + device = regions[0].children[0].children[0].devices[0] + self.assertEqual(device.name, "unnamed-device-74") + self.assertEqual(device.ssh_target, "192.0.2.74") + + def test_uses_device_id_when_name_and_display_are_missing(self) -> None: + sites = [{"id": 10, "name": "City A", "region": {"id": 354}}] + devices = [ + { + "id": 75, + "name": None, + "display": None, + "site": {"id": 10}, + "role": None, + } + ] + + regions = build_tree(self.regions, sites, devices) + + device = regions[0].children[0].children[0].devices[0] + self.assertEqual(device.name, "Device 75") + self.assertEqual(device.role, "Other") + def test_ignores_first_level_of_every_region_tree(self) -> None: regions = self.regions + [ {"id": 500, "name": "Region Group B", "parent": None}, @@ -64,6 +101,28 @@ def test_ignores_first_level_of_every_region_tree(self) -> None: [region.children[0].name for region in trees], ["Country A", "Country B"] ) + def test_removes_regions_containing_only_empty_descendants(self) -> None: + regions = [ + {"id": 1, "name": "Asia", "parent": None}, + {"id": 2, "name": "Empty Country", "parent": {"id": 1}}, + {"id": 3, "name": "Europe", "parent": None}, + {"id": 4, "name": "Empty City", "parent": {"id": 3}}, + {"id": 5, "name": "North America", "parent": None}, + {"id": 6, "name": "United States", "parent": {"id": 5}}, + ] + sites = [{"id": 10, "name": "United States", "region": {"id": 6}}] + devices = [ + { + "name": "router-one", + "site": {"id": 10}, + "role": {"name": "Router"}, + } + ] + + trees = build_tree(regions, sites, devices) + + self.assertEqual([region.name for region in trees], ["North America"]) + if __name__ == "__main__": unittest.main()