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 @@ -11,6 +11,21 @@ All notable changes to netbox-super-cli are tracked here. Format follows [Keep a
`X-Netbox-Branch` schema_id header, including under `audit_redaction: full`.
Lines for requests on main are unchanged (no `branch` key).

### Fixed

- **The TUI explains a branch that became unusable mid-session** ([#176]). If
the active branch is merged, archived or deleted while `nsc tui` is open,
NetBox Branching refuses later requests with a plain-text 400. List, detail,
edit, record-picker and bulk-edit errors now read
`branch '<name>' is no longer usable (status: …)` and point back to main,
instead of `API 400 — Selected branch is not ready.`. Global search stops at
the first such failure instead of silently reporting no matches. Saved
searches that fall back to `config.yaml` (in the TUI or with `--saved`) now
say the branch is the reason, including when the object-type lookup is the
request that fails. Only the
Branching middleware's exact messages are matched, and only while a branch is
active, so other 400s such as validation errors look the same as before.

## v1.9.0 — 2026-10-25

Minor release. Plugin endpoints get correct per-resource commands (**plugin command paths change**). Branch conflicts render as a table, and server data can no longer drive your terminal.
Expand Down Expand Up @@ -241,6 +256,7 @@ notices, and a fix for `nsc login` reporting the wrong user.
[#196]: https://github.com/thomaschristory/netbox-super-cli/issues/196
[#202]: https://github.com/thomaschristory/netbox-super-cli/issues/202
[#206]: https://github.com/thomaschristory/netbox-super-cli/issues/206
[#176]: https://github.com/thomaschristory/netbox-super-cli/issues/176
[#171]: https://github.com/thomaschristory/netbox-super-cli/issues/171
[#179]: https://github.com/thomaschristory/netbox-super-cli/issues/179
[#173]: https://github.com/thomaschristory/netbox-super-cli/issues/173
Expand Down
4 changes: 3 additions & 1 deletion docs/guides/branching.md
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,9 @@ When a branch is active, every command prints one line to **stderr**:
```

stdout is untouched, so `-o json` output piped elsewhere stays clean. The TUI
shows the branch in its header in the warning colour. `nsc branch current`
shows the branch in its header in the warning colour, and says so if the
branch is merged or deleted while it is open (see
[Interactive TUI](interactive-tui.md#working-in-a-branch)). `nsc branch current`
prints the active branch and where it came from (`flag`, `env` or `profile`),
or `main`.

Expand Down
16 changes: 14 additions & 2 deletions docs/guides/interactive-tui.md
Original file line number Diff line number Diff line change
Expand Up @@ -107,7 +107,8 @@ with `nsc <app> <resource> list --saved <name>`.

If NetBox is unreachable (offline, or you lack permission on
`extras.savedfilter`), saves and loads transparently fall back to a local store in
`~/.nsc/config.yaml` and a toast tells you the fallback was used.
`~/.nsc/config.yaml` and a toast tells you the fallback was used. If the active
branch has been merged or deleted, the toast names the branch as the cause.

## Viewing and editing a record

Expand Down Expand Up @@ -201,7 +202,18 @@ profile's sticky branch), the header reads `nsc ⎇ <branch>` on the warning
colour, and every read and edit happens inside that branch. The TUI checks
the branch before it opens. If the branch is merged, missing or not ready,
`nsc tui` exits with the same error as any other command instead of showing
main's data. See [NetBox Branching](branching.md).
main's data.

If the branch is merged, archived or deleted while the TUI is open, NetBox
refuses every later request. Instead of a bare `API 400`, the TUI then says
`branch '<name>' is no longer usable (status: …)` and suggests switching back
to main. Global search stops after the first such failure rather than trying
every resource, and a bulk edit lists the same message for each failed record.
Saving, loading or deleting a saved search still falls back to
`~/.nsc/config.yaml`, and the warning says the branch is the reason.
Quit and relaunch on another branch (or main) to carry on. Other 400s, such
as a bad filter value, still show NetBox's own message. See
[NetBox Branching](branching.md).

## Loading and errors

Expand Down
16 changes: 16 additions & 0 deletions nsc/branching/errors.py
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,22 @@ def __init__(self, name: str, status: str) -> None:
self.status = status


class BranchUnusableError(BranchError):
"""The server refused a request because the active branch went away mid-session.

`status` is the branch status when the server names it, else None.
"""

def __init__(self, name: str, *, status: str | None, missing: bool = False) -> None:
state = "deleted or not found" if missing else f"status: {status or 'not ready'}"
super().__init__(
f"branch {name!r} is no longer usable ({state}); {_ESCAPE_HINT}",
branch=name,
)
self.status = status
self.missing = missing


class BranchConflictError(BranchError):
def __init__(self, name: str, conflicts: list[dict[str, Any]]) -> None:
what = (
Expand Down
43 changes: 43 additions & 0 deletions nsc/branching/unusable.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
"""Recognize the NetBox Branching middleware's "this branch is unusable" 400s.

The middleware answers a request whose `X-NetBox-Branch` names a merged,
archived or deleted branch with a plain-text 400 (Django's
`HttpResponseBadRequest`), before any view runs. Only those exact bodies are
matched, so a validation 400 (JSON) is never mistaken for one.
"""

from __future__ import annotations

import re

from nsc.branching.errors import BranchUnusableError
from nsc.http.errors import NetBoxAPIError

_BAD_REQUEST = 400
_MISSING = re.compile(r"Invalid branch identifier")
_NOT_READY = re.compile(r"Selected branch is not ready\.?")
# The plugin's own BranchNotReady text, which names the status; current
# releases replace it with the generic message above, but it is matched in case
# a release passes it through.
_NOT_READY_WITH_STATUS = re.compile(
r"Branch .+ is not ready for use \(status: (?P<status>[\w-]+)\)"
)


def branch_unusable_error(exc: NetBoxAPIError, branch: str | None) -> BranchUnusableError | None:
"""The branch-state error behind `exc`, or None if it is any other failure.

`branch` is the active branch's name; without one no request carried the
branch header, so the middleware cannot have produced the error.
"""
if branch is None or exc.status_code != _BAD_REQUEST:
return None
body = (exc.body or "").strip()
if _MISSING.fullmatch(body):
return BranchUnusableError(branch, status=None, missing=True)
if _NOT_READY.fullmatch(body):
return BranchUnusableError(branch, status=None)
match = _NOT_READY_WITH_STATUS.fullmatch(body)
if match:
return BranchUnusableError(branch, status=match["status"])
return None
1 change: 1 addition & 0 deletions nsc/cli/registration.py
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,7 @@ def _resolve_saved_filters(
ctx.client,
ConfigFileSavedSearchStore(ctx.config),
on_error=lambda message: typer.echo(f"Warning: {message}", err=True),
branch=ctx.branch.name if ctx.branch else None,
)
available = store.list(list_path, tag_name, resource_name)
saved = available.get(saved_name)
Expand Down
13 changes: 11 additions & 2 deletions nsc/savedfilters/objecttypes.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@

from __future__ import annotations

from collections.abc import Callable
from typing import Any, Protocol
from urllib.parse import urlsplit

Expand Down Expand Up @@ -73,9 +74,15 @@ def object_type_index(object_types: Any) -> dict[str, str]:
class ObjectTypeResolver:
"""Looks up object types from the live registry, cached per app label."""

def __init__(self, client: _ClientLike) -> None:
def __init__(
self,
client: _ClientLike,
*,
on_error: Callable[[NetBoxAPIError | NetBoxClientError], None] | None = None,
) -> None:
self._client = client
self._cache: dict[str, dict[str, str]] = {}
self._on_error = on_error

def resolve(self, list_path: str) -> str | None:
"""``app_label.model`` for a list path, or None if it can't be resolved.
Expand All @@ -96,7 +103,9 @@ def _index_for(self, app_label: str) -> dict[str, str] | None:
return self._cache[app_label]
try:
records = list(self._client.paginate(_OBJECT_TYPES_PATH, {"app_label": app_label}))
except (NetBoxAPIError, NetBoxClientError):
except (NetBoxAPIError, NetBoxClientError) as exc:
if self._on_error is not None:
self._on_error(exc)
return None
index = object_type_index(records)
self._cache[app_label] = index
Expand Down
22 changes: 21 additions & 1 deletion nsc/savedfilters/store.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@
from collections.abc import Callable
from typing import Any, Protocol, cast

from nsc.branching.errors import BranchUnusableError
from nsc.branching.unusable import branch_unusable_error
from nsc.http.errors import NetBoxAPIError, NetBoxClientError
from nsc.savedfilters.objecttypes import ObjectTypeResolver
from nsc.savedfilters.params import (
Expand Down Expand Up @@ -43,11 +45,13 @@ def __init__(
*,
resolver: _ResolverLike | None = None,
on_error: Callable[[str], None] | None = None,
branch: str | None = None,
) -> None:
self._client = client
self._fallback = fallback
self._resolver = resolver or ObjectTypeResolver(client)
self._resolver = resolver or ObjectTypeResolver(client, on_error=self._lookup_failed)
self.on_error = on_error
self.branch = branch

def list(self, list_path: str, tag: str, resource: str) -> dict[str, dict[str, str]]:
object_type = self._resolver.resolve(list_path)
Expand Down Expand Up @@ -133,8 +137,24 @@ def _find(self, object_type: str, name: str) -> dict[str, Any] | None:
return None

def _report(self, action: str, exc: Exception) -> None:
unusable = self._branch_state(exc)
if unusable is not None:
self._notify(f"Could not {action} via NetBox: {unusable}; using local config.")
return
self._notify(f"Could not {action} via NetBox ({exc}); using local config.")

def _lookup_failed(self, exc: NetBoxAPIError | NetBoxClientError) -> None:
# Other registry failures stay silent (a missing object type is routine);
# a dead branch would otherwise quietly divert every save to config.yaml.
unusable = self._branch_state(exc)
if unusable is not None:
self._notify(f"Could not use NetBox saved filters: {unusable}; using local config.")

def _branch_state(self, exc: Exception) -> BranchUnusableError | None:
if isinstance(exc, NetBoxAPIError):
return branch_unusable_error(exc, self.branch)
return None

def _notify(self, message: str) -> None:
if self.on_error is not None:
self.on_error(message)
7 changes: 7 additions & 0 deletions nsc/tui/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ def __init__(
branch: str | None = None,
) -> None:
super().__init__()
self.active_branch = branch
if branch is not None:
self.title = f"nsc ⎇ {branch}"
self.add_class("-branch")
Expand All @@ -57,6 +58,12 @@ def __init__(
self._saved_filter_store = saved_filter_store
if saved_filter_store is not None and getattr(saved_filter_store, "on_error", None) is None:
saved_filter_store.on_error = self._notify_saved_filter_issue
if (
saved_filter_store is not None
and branch is not None
and getattr(saved_filter_store, "branch", ...) is None
):
saved_filter_store.branch = branch
from nsc.savedfilters.custom_fields import CustomFieldResolver # noqa: PLC0415
from nsc.savedfilters.tags import TagsResolver # noqa: PLC0415

Expand Down
5 changes: 4 additions & 1 deletion nsc/tui/bulk.py
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,8 @@ def apply_bulk(
changes: list[RecordChange],
patch_fn: Callable[[RecordChange], None],
on_progress: Callable[[int, int], None] | None = None,
*,
describe: Callable[[NetBoxAPIError | NetBoxClientError], str] | None = None,
) -> BulkResult:
"""Apply ``patch_fn`` to each change, aggregating per-record outcomes.

Expand All @@ -112,7 +114,8 @@ def apply_bulk(
try:
patch_fn(change)
except (NetBoxAPIError, NetBoxClientError) as exc:
failures.append(BulkFailure(record_id=change.record_id, error=exc.render_for_cli()))
error = describe(exc) if describe is not None else exc.render_for_cli()
failures.append(BulkFailure(record_id=change.record_id, error=error))
else:
successes.append(change.record_id)
return BulkResult(successes=successes, failures=failures, skipped=skipped)
33 changes: 30 additions & 3 deletions nsc/tui/errors.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,17 +9,44 @@

import json

from nsc.branching.errors import BranchUnusableError
from nsc.branching.unusable import branch_unusable_error
from nsc.http.errors import NetBoxAPIError, NetBoxClientError
from nsc.output.sanitize import safe_markup


def api_error_message(exc: Exception) -> str:
def active_branch(app: object) -> str | None:
"""The branch the TUI session runs on (`NscTuiApp.active_branch`), if any."""
branch = getattr(app, "active_branch", None)
return branch if isinstance(branch, str) else None


def api_error_message(exc: Exception, *, branch: str | None = None) -> str:
"""The message as Textual markup: `notify` parses markup, and the text is
server data, so it is passed through `safe_markup` (#206)."""
return safe_markup(_message(exc))
return safe_markup(_message(exc, branch))


def api_error_text(exc: Exception, *, branch: str | None = None) -> str:
"""Plain (unescaped) one-line text, for callers that sanitize it themselves."""
unusable = branch_state_error(exc, branch)
if unusable is not None:
return str(unusable)
if isinstance(exc, NetBoxAPIError | NetBoxClientError):
return exc.render_for_cli()
return str(exc)


def branch_state_error(exc: Exception, branch: str | None) -> BranchUnusableError | None:
if isinstance(exc, NetBoxAPIError):
return branch_unusable_error(exc, branch)
return None


def _message(exc: Exception) -> str:
def _message(exc: Exception, branch: str | None) -> str:
unusable = branch_state_error(exc, branch)
if unusable is not None:
return str(unusable)
if isinstance(exc, NetBoxAPIError):
detail = _format_body(exc.body_snippet)
return f"API {exc.status_code} — {detail}" if detail else f"API {exc.status_code}"
Expand Down
10 changes: 9 additions & 1 deletion nsc/tui/screens/bulk_edit_form.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@
from nsc.savedfilters.tags import TagDef
from nsc.tui._bindings import textual_bindings
from nsc.tui.bulk import RecordChange, shared_values
from nsc.tui.errors import active_branch, api_error_text
from nsc.tui.fk import is_fk_value, resolve_fk_target
from nsc.tui.forms import (
SET_NULL,
Expand Down Expand Up @@ -408,7 +409,14 @@ def _on_progress(index: int, total: int) -> None:
# apply_bulk runs in a worker thread; bump the bar on the UI thread.
self.app.call_from_thread(_advance, index, total)

result = await asyncio.to_thread(apply_bulk, changes, _patch, _on_progress)
branch = active_branch(self.app)
result = await asyncio.to_thread(
apply_bulk,
changes,
_patch,
_on_progress,
describe=lambda exc: api_error_text(exc, branch=branch),
)
self.app.push_screen(BulkSummaryModal(result), self._on_summary_dismissed)

def _on_summary_dismissed(self, _: None) -> None:
Expand Down
12 changes: 9 additions & 3 deletions nsc/tui/screens/detail.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@
from nsc.output.flatten import flatten
from nsc.output.sanitize import safe_text
from nsc.tui._bindings import textual_bindings
from nsc.tui.errors import api_error_message
from nsc.tui.errors import active_branch, api_error_message
from nsc.tui.fk import is_fk_value, resolve_fk_target
from nsc.tui.forms import SET_NULL, WidgetSpec, compute_patch, diff_rows, field_to_widget
from nsc.tui.relations import RelatedView, related_views
Expand Down Expand Up @@ -305,7 +305,9 @@ def _apply_patch(self, patch: dict[str, Any]) -> None:
sensitive_paths=self._sensitive,
)
except (NetBoxAPIError, NetBoxClientError) as exc:
self.notify(api_error_message(exc), severity="error", timeout=8)
self.notify(
api_error_message(exc, branch=active_branch(self.app)), severity="error", timeout=8
)
return
self._record.update(patch)
self.staged.clear()
Expand Down Expand Up @@ -388,7 +390,11 @@ def _on_confirm(confirmed: bool | None) -> None:
try:
self._client.delete(path, operation_id=delete_op.operation_id)
except (NetBoxAPIError, NetBoxClientError) as exc:
self.notify(api_error_message(exc), severity="error", timeout=8)
self.notify(
api_error_message(exc, branch=active_branch(self.app)),
severity="error",
timeout=8,
)
return
self.app.pop_screen()
self._reload_underlying_list()
Expand Down
6 changes: 4 additions & 2 deletions nsc/tui/screens/edit_form.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@
from nsc.savedfilters.custom_fields import CustomFieldDef
from nsc.savedfilters.tags import TagDef
from nsc.tui._bindings import textual_bindings
from nsc.tui.errors import api_error_message
from nsc.tui.errors import active_branch, api_error_message
from nsc.tui.fk import is_fk_value, resolve_fk_target
from nsc.tui.forms import (
SET_NULL,
Expand Down Expand Up @@ -329,7 +329,9 @@ def _apply_patch(self, patch: dict[str, Any], sensitive_paths: tuple[str, ...])
sensitive_paths=sensitive_paths,
)
except (NetBoxAPIError, NetBoxClientError) as exc:
self.notify(api_error_message(exc), severity="error", timeout=8)
self.notify(
api_error_message(exc, branch=active_branch(self.app)), severity="error", timeout=8
)
return
self.dismiss()

Expand Down
Loading
Loading