diff --git a/CHANGELOG.md b/CHANGELOG.md index 72d0dd7..3582653 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/COMPATIBILITY.md b/COMPATIBILITY.md index 044f89b..7ea098d 100644 --- a/COMPATIBILITY.md +++ b/COMPATIBILITY.md @@ -2,8 +2,8 @@ ## 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 @@ -11,6 +11,7 @@ currently run with Python 3.13. |------------------|--------|----------------| | 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 @@ -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. diff --git a/PUBLISHING.md b/PUBLISHING.md index 4e8664c..62cc06a 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.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 ``` @@ -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. diff --git a/README.md b/README.md index e4733db..a541969 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/src/netbox_ssh/__init__.py b/src/netbox_ssh/__init__.py index 720d2af..ef94a1f 100644 --- a/src/netbox_ssh/__init__.py +++ b/src/netbox_ssh/__init__.py @@ -1,3 +1,3 @@ """NetBox SSH Browser.""" -__version__ = "0.1.0" +__version__ = "0.1.1" diff --git a/src/netbox_ssh/editor.py b/src/netbox_ssh/editor.py index cdf2fae..9b52bd5 100644 --- a/src/netbox_ssh/editor.py +++ b/src/netbox_ssh/editor.py @@ -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 = [] """ diff --git a/src/netbox_ssh/terminal.py b/src/netbox_ssh/terminal.py index 884f5ce..12a7869 100644 --- a/src/netbox_ssh/terminal.py +++ b/src/netbox_ssh/terminal.py @@ -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. diff --git a/src/netbox_ssh/tui.py b/src/netbox_ssh/tui.py index ca555d4..734585e 100644 --- a/src/netbox_ssh/tui.py +++ b/src/netbox_ssh/tui.py @@ -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) @@ -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]): @@ -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) @@ -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": @@ -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) @@ -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) diff --git a/tests/test_editor.py b/tests/test_editor.py index 474d63a..f8cfb77 100644 --- a/tests/test_editor.py +++ b/tests/test_editor.py @@ -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) diff --git a/tests/test_entrypoint.py b/tests/test_entrypoint.py index 5e79784..5e328d4 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.0") + self.assertEqual(output.getvalue().strip(), "nssh 0.1.1") if __name__ == "__main__": diff --git a/tests/test_terminal.py b/tests/test_terminal.py index 6c00ff6..c8dc4eb 100644 --- a/tests/test_terminal.py +++ b/tests/test_terminal.py @@ -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): @@ -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() diff --git a/tests/test_tui.py b/tests/test_tui.py index 38137be..2799647 100644 --- a/tests/test_tui.py +++ b/tests/test_tui.py @@ -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 @@ -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]) @@ -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()