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
14 changes: 14 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
7 changes: 4 additions & 3 deletions COMPATIBILITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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
Expand Down
4 changes: 2 additions & 2 deletions PUBLISHING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
```

Expand All @@ -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.
Expand Down
39 changes: 34 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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.
55 changes: 55 additions & 0 deletions docs/images/README.md
Original file line number Diff line number Diff line change
@@ -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)
Binary file added docs/images/netbox-ssh-browser-demo.gif
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/images/netbox-ssh-browser_01.gif
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/images/netbox-ssh-browser_02.gif
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/images/netbox-ssh-browser_03.gif
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/images/netbox-ssh-browser_04.gif
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/images/netbox-ssh-browser_05.gif
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/images/netbox-ssh-browser_06.gif
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/images/netbox-ssh-browser_07.gif
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/images/netbox-ssh-browser_08.gif
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/images/netbox-ssh-browser_09.gif
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/images/netbox-ssh-browser_10.gif
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/images/netbox-ssh-browser_11.gif
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/images/netbox-ssh-browser_12.gif
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/images/netbox-ssh-browser_13.gif
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
2 changes: 1 addition & 1 deletion src/netbox_ssh/__init__.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
"""NetBox SSH Browser."""

__version__ = "0.1.1"
__version__ = "0.1.2"
17 changes: 14 additions & 3 deletions src/netbox_ssh/model.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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())
15 changes: 15 additions & 0 deletions tests/test_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
2 changes: 1 addition & 1 deletion tests/test_entrypoint.py
Original file line number Diff line number Diff line change
Expand Up @@ -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__":
Expand Down
59 changes: 59 additions & 0 deletions tests/test_model.py
Original file line number Diff line number Diff line change
Expand Up @@ -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},
Expand All @@ -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()
Loading