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
16 changes: 16 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,22 @@ versioning.

## [Unreleased]

## [0.1.1] - 2026-08-04

### Added

- Linux and WSL single-device connections through the system OpenSSH client.
- Commented multi-value examples in newly generated user configuration files.

### Changed

- Navigation now restores the previously highlighted entry when returning from
a site or branch.
- Non-iTerm2 terminals now report that multi-session launches are unavailable
while retaining portable single-device SSH.
- Newly generated configuration files include an empty NetBox URL field and
explain that empty filter lists import all matching inventory.

## [0.1.0] - 2026-08-04

### Added
Expand Down
9 changes: 6 additions & 3 deletions COMPATIBILITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,15 +2,16 @@

## Python

NetBox SSH Browser requires Python 3.11 or newer. The automated test suite is
currently run with Python 3.13.
NetBox SSH Browser requires Python 3.11 or newer. The automated test suite runs
with Python 3.11 and 3.13 on macOS, Linux, and Windows.

## Operating Systems

| Operating System | Status | Cache Location |
|------------------|--------|----------------|
| 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` |

## NetBox
Expand All @@ -30,4 +31,6 @@ iTerm2 plugin.

Opening multiple selected devices as tabs is currently supported only when
`nssh` runs inside iTerm2 on macOS. Other terminals retain the portable
single-device SSH behavior.
single-device SSH behavior through their system `ssh` command. On Linux and
Ubuntu under WSL2, multiple selections display an explanatory message instead
of attempting to invoke the macOS-only integration.
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.0-py3-none-any.whl
dist/netbox_ssh_browser-0.1.1-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.0`.
4. Push the release commit and a matching tag such as `v0.1.1`.
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
6 changes: 5 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -295,13 +295,17 @@ Access Switch

The headings are skipped by arrow-key navigation. Selecting a device
immediately suspends the TUI and starts the system SSH client. When SSH exits,
the previous TUI view is restored.
the previous TUI view is restored. Returning from a site or branch also restores
the previously highlighted entry, which makes sequential device checks easier.

On macOS in iTerm2, select devices with `Ctrl+T` (or `Space`) and press `Enter`
to open every selected SSH connection
in a separate tab of the current iTerm2 window. The `nssh` tab remains open.
The first launch may cause macOS to request permission to automate iTerm2.
`Ctrl+U` clears the selection. Single-device SSH remains terminal-independent.
On Linux, WSL, Windows, and macOS terminals other than iTerm2, only a single
system SSH session is available; attempting a multi-session launch displays a
clear compatibility message.

`C` and `M` temporarily suspend the TUI and launch `$VISUAL`, then `$EDITOR`,
or `nano` when neither variable is configured. The editor process does not
Expand Down
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.0"
__version__ = "0.1.1"
7 changes: 7 additions & 0 deletions src/netbox_ssh/editor.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,13 +9,20 @@


DEFAULT_CONFIG = """[netbox]
url = ""
# Paste only the token value, without the Bearer or Token prefix.
api_token = ""
verify_ssl = true

[sync]
# Empty [] imports devices with any status.
# Example: device_statuses = ["active", "planned", "staged"]
device_statuses = ["active"]
# Empty [] does not exclude any manufacturer.
# Example: ignored_manufacturers = ["Cisco", "Juniper", "Arista"]
ignored_manufacturers = []
# Empty [] imports devices with every role.
# Example: device_roles = ["Router", "Core Switch", "Distribution Switch"]
device_roles = []
"""

Expand Down
14 changes: 14 additions & 0 deletions src/netbox_ssh/terminal.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,20 @@ def is_iterm2() -> bool:
return platform.system() == "Darwin" and os.environ.get("TERM_PROGRAM") == "iTerm.app"


def run_system_ssh(devices: Sequence[Device]) -> list[tuple[Device, int]]:
"""Uruchamia systemowy OpenSSH, przenośnie także na Linuxie i WSL."""
environment = os.environ.copy()
environment.pop("NETBOX_API_TOKEN", None)
environment.pop("NETBOX_URL", None)
results = []
for device in devices:
result = subprocess.run(
["ssh", device.ssh_target], check=False, env=environment
)
results.append((device, result.returncode))
return results


def open_iterm_tabs(devices: Sequence[Device]) -> None:
"""Otwiera osobną kartę iTerm2 dla każdego urządzenia.

Expand Down
44 changes: 35 additions & 9 deletions src/netbox_ssh/tui.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@
)
from .model import Device, Node
from .service import describe_sync_error, synchronize
from .terminal import open_iterm_tabs
from .terminal import is_iterm2, open_iterm_tabs, run_system_ssh


@dataclass(frozen=True)
Expand All @@ -48,6 +48,7 @@ class View:
role_devices: list[Device] | None = None
search_entries: list[Entry] | None = None
path: tuple[str, ...] = ()
cursor_index: int | None = None


class AddDeviceScreen(ModalScreen[ManualDevice | None]):
Expand Down Expand Up @@ -299,9 +300,17 @@ async def _render_entries(self) -> None:
items.append(item)
if items:
await list_view.extend(items)
# Nagłówki są disabled, ale jawnie ustawiamy zaznaczenie na pierwszym
# aktywnym elemencie, aby Enter działał bez wciskania strzałki.
list_view.index = next(
# Każdy poziom pamięta własny kursor, więc powrót wskazuje ostatnio
# otwarty site/oddział zamiast pierwszego elementu listy.
saved_index = self.views[-1].cursor_index
if (
saved_index is not None
and saved_index < len(self.visible_entries)
and self.visible_entries[saved_index].kind != "heading"
):
list_view.index = saved_index
else:
list_view.index = next(
(
index
for index, entry in enumerate(self.visible_entries)
Expand All @@ -327,9 +336,11 @@ async def _selected(self, event: ListView.Selected) -> None:
if entry is None:
return
if entry.kind == "node":
self.views[-1].cursor_index = self.query_one(ListView).index
self.views.append(View(entry.label, node=entry.value, path=entry.path))
await self._reset_and_render()
elif entry.kind == "role":
self.views[-1].cursor_index = self.query_one(ListView).index
self.views.append(View(entry.label, role_devices=entry.value))
await self._reset_and_render()
elif entry.kind == "device":
Expand Down Expand Up @@ -370,16 +381,25 @@ async def action_clear_selection(self) -> None:
self._set_status("Device selection cleared.")

def _connect_selected(self) -> None:
"""Przekazuje zaznaczone urządzenia do integracji z kartami iTerm2."""
"""Otwiera wiele sesji tylko w kartach iTerm2."""
devices = list(self.selected_devices.values())
if not is_iterm2():
self._set_status(
"Multiple SSH sessions require iTerm2 on macOS. "
"Clear the selection to open one system SSH session.",
"error",
)
return
try:
open_iterm_tabs(devices)
except (OSError, RuntimeError) as error:
self._set_status(f"Could not open iTerm2 tabs: {error}", "error")
self._set_status(f"Could not start SSH: {error}", "error")
return
self.selected_devices.clear()
self.run_worker(self._render_entries(), exclusive=True)
self._set_status(f"Opened {len(devices)} SSH sessions in iTerm2 tabs.", "success")
self._set_status(
f"Opened {len(devices)} SSH sessions in iTerm2 tabs.", "success"
)

async def _reset_and_render(self) -> None:
search = self.query_one(Input)
Expand Down Expand Up @@ -575,11 +595,17 @@ def _connect_ssh(self, device: Device) -> None:
# Na czas SSH oddajemy terminal klientowi systemowemu, a po jego
# zakończeniu Textual odtwarza poprzedni ekran.
with self.suspend():
subprocess.run(["ssh", device.ssh_target], check=False, env=environment)
results = run_system_ssh([device])
except OSError as error:
self._set_status(f"Could not start ssh: {error}", "error")
else:
self._set_status(f"SSH session with {device.name} ended.")
return_code = results[0][1]
if return_code == 0:
self._set_status(f"SSH session with {device.name} ended.")
else:
self._set_status(
f"SSH to {device.name} exited with status {return_code}.", "error"
)

def _set_status(self, message: str, style_class: str | None = None) -> None:
status = self.query_one("#status", Static)
Expand Down
13 changes: 12 additions & 1 deletion tests/test_editor.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,18 @@ def test_initializes_missing_files_without_overwriting_existing(self) -> None:
manual_path = root / "manual.json"
ensure_config_file(config_path)
ensure_manual_file(manual_path)
self.assertIn("verify_ssl = true", config_path.read_text(encoding="utf-8"))
config_text = config_path.read_text(encoding="utf-8")
self.assertIn('url = ""', config_text)
self.assertIn('api_token = ""', config_text)
self.assertIn("verify_ssl = true", config_text)
self.assertIn(
'# Example: ignored_manufacturers = ["Cisco", "Juniper", "Arista"]',
config_text,
)
self.assertIn(
'# Example: device_roles = ["Router", "Core Switch", "Distribution Switch"]',
config_text,
)
self.assertEqual(load_manual_devices(manual_path), [])
config_path.write_text("custom = true\n", encoding="utf-8")
ensure_config_file(config_path)
Expand Down
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.0")
self.assertEqual(output.getvalue().strip(), "nssh 0.1.1")


if __name__ == "__main__":
Expand Down
19 changes: 18 additions & 1 deletion tests/test_terminal.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
from unittest.mock import patch

from netbox_ssh.model import Device
from netbox_ssh.terminal import ITERM_TABS_SCRIPT, open_iterm_tabs
from netbox_ssh.terminal import ITERM_TABS_SCRIPT, open_iterm_tabs, run_system_ssh


class ITermTabsTests(unittest.TestCase):
Expand Down Expand Up @@ -38,5 +38,22 @@ def test_rejects_batch_outside_iterm2(self, _is_iterm2) -> None:
open_iterm_tabs([Device("switch-one", "Core")])


class SystemSSHTests(unittest.TestCase):
@patch("netbox_ssh.terminal.subprocess.run")
def test_runs_single_device_without_secrets(self, run) -> None:
run.return_value.returncode = 0
device = Device("switch-one", "Core", "192.0.2.1/24")
with patch.dict(
os.environ,
{"NETBOX_API_TOKEN": "secret", "NETBOX_URL": "https://netbox.example"},
):
results = run_system_ssh([device])

self.assertEqual(run.call_args.args[0], ["ssh", "192.0.2.1"])
self.assertEqual(results, [(device, 0)])
self.assertNotIn("NETBOX_API_TOKEN", run.call_args.kwargs["env"])
self.assertNotIn("NETBOX_URL", run.call_args.kwargs["env"])


if __name__ == "__main__":
unittest.main()
31 changes: 30 additions & 1 deletion tests/test_tui.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@
from pathlib import Path
from unittest.mock import patch

from textual.widgets import ListView

from netbox_ssh.cache import Cache
from netbox_ssh.config import Config
from netbox_ssh.model import Device, Node
Expand Down Expand Up @@ -63,6 +65,31 @@ async def test_navigates_country_and_searches_devices_by_ip(self) -> None:
self.assertEqual([entry.label for entry in app.visible_entries], ["switch-one"])
self.assertEqual(app.views[-1].title, "Device search")

async def test_back_restores_last_selected_branch(self) -> None:
first = Node("branch-a-01", devices=[Device("switch-one", "Switch")])
second = Node("branch-b-01", devices=[Device("switch-two", "Switch")])
country = Node(
"Country A",
children=[
Node("City A", children=[first]),
Node("City B", children=[second]),
],
)
app = self.make_app(
Cache(
"2026-08-02T00:00:00+02:00",
[Node("Region Group A", children=[country])],
)
)
async with app.run_test() as pilot:
await pilot.press("enter", "down", "enter")
await pilot.pause()
self.assertEqual(app.views[-1].title, "branch-b-01")
await pilot.press("escape")
await pilot.pause()
self.assertEqual(app.query_one(ListView).index, 3)
self.assertEqual(app.visible_entries[3].label, "branch-b-01")

async def test_adds_manual_device_in_current_branch(self) -> None:
device = Device("switch-one", "Access Switch", "192.0.2.1/24")
branch = Node("branch-a-01", devices=[device])
Expand Down Expand Up @@ -99,7 +126,9 @@ async def test_selects_devices_and_opens_them_as_batch(self) -> None:
[Node("Region Group A", children=[country])],
)
)
with patch("netbox_ssh.tui.open_iterm_tabs") as open_tabs:
with patch("netbox_ssh.tui.is_iterm2", return_value=True), patch(
"netbox_ssh.tui.open_iterm_tabs"
) as open_tabs:
async with app.run_test() as pilot:
await pilot.press("enter", "enter", "ctrl+t", "down", "ctrl+t")
await pilot.pause()
Expand Down
Loading