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 @@ -2,6 +2,22 @@

All notable changes to netbox-super-cli are tracked here. Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) loosely. From v1.0.0 onward, releases follow [Semantic Versioning](https://semver.org/) and the version in `pyproject.toml` matches the git tag. Pre-1.0 milestones (Phase 1-5) were pinned by tag while `pyproject.toml` stayed at `0.0.1`.

## [Unreleased]

### Changed

- **Saved searches are now NetBox-native** ([#129]). Filter sets saved from the
TUI (`Ctrl+W`) and applied via `--saved` are stored as NetBox saved filters
(`extras.saved-filters`), scoped to the resource's object type, so they are
fully interchangeable with the web UI's filter dropdown — a search saved in
`nsc` appears in the web UI and vice versa. The object type is resolved from
NetBox's own object-type registry (`/api/core/object-types/`), so the mapping
is exact rather than guessed. When NetBox is unreachable or the user lacks
permission on `extras.savedfilter`, saves/loads transparently fall back to the
previous local `config.yaml` store and surface a notice instead of failing.

[#129]: https://github.com/thomaschristory/netbox-super-cli/issues/129

## v1.4.0 — 2026-06-26

Minor release. Three user-facing features for the interactive TUI and table
Expand Down
16 changes: 10 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -85,19 +85,23 @@ uv run nsc ipam prefixes list --filter created__gte=2026-01-01 --output yaml

### Saved searches

Filter sets you save from the TUI (Ctrl+W in the filter builder) are stored
locally under `saved_searches` in `~/.nsc/config.yaml`, keyed by tag/resource.
Re-apply one on the CLI with `--saved`:
Filter sets you save from the TUI (Ctrl+W in the filter builder) are stored as
**NetBox native saved filters** (`extras.saved-filters`), scoped to the resource's
object type. A search you save in `nsc` shows up in the NetBox web UI's filter
dropdown for that object — and a filter saved in the web UI loads in the TUI
(Ctrl+O) — so the two are fully interchangeable. Re-apply one on the CLI with
`--saved`:

```sh
uv run nsc dcim devices list --saved active-switches
# explicit --filter / typed options override the saved values:
uv run nsc dcim devices list --saved active-switches --status offline
```

This is a local, per-config convenience and is unrelated to NetBox's
server-side saved filters (`extras saved-filters`), which store web-UI query
strings.
When NetBox is unreachable (offline, or missing permission on
`extras.savedfilter`), `nsc` transparently falls back to a local store under
`saved_searches` in `~/.nsc/config.yaml`, keyed by tag/resource, and tells you it
did so.

### Object colors

Expand Down
16 changes: 16 additions & 0 deletions docs/guides/interactive-tui.md
Original file line number Diff line number Diff line change
Expand Up @@ -81,11 +81,27 @@ NetBox list endpoint exposes hundreds of query parameters.
| <kbd>↓</kbd> | Move to the next field (from a text field) |
| <kbd>Tab</kbd> / <kbd>Shift</kbd>+<kbd>Tab</kbd> | Move between fields |
| <kbd>Ctrl</kbd>+<kbd>S</kbd> | Apply |
| <kbd>Ctrl</kbd>+<kbd>W</kbd> | Save the current filters as a named search |
| <kbd>Ctrl</kbd>+<kbd>O</kbd> | Load (or delete) a saved search |
| <kbd>Esc</kbd> | Cancel |

Applying re-queries the list. Reopening the builder shows your current filters
so you can refine them.

### Saved searches

<kbd>Ctrl</kbd>+<kbd>W</kbd> stores the current filter set as a NetBox **native
saved filter** (`extras.saved-filters`), scoped to the current resource's object
type. It then appears in the NetBox web UI's filter dropdown for that object, and
filters created in the web UI load here with <kbd>Ctrl</kbd>+<kbd>O</kbd> — the two
surfaces are interchangeable. In the load picker, <kbd>Enter</kbd> applies the
highlighted search and <kbd>d</kbd> deletes it. Re-apply one non-interactively
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.

## Viewing and editing a record

The detail view **is** the edit surface — there is no separate "edit window".
Expand Down
32 changes: 22 additions & 10 deletions nsc/cli/registration.py
Original file line number Diff line number Diff line change
Expand Up @@ -128,22 +128,33 @@ def _resolve_saved_filters(
ctx: RuntimeContext,
tag_name: str,
resource_name: str,
list_path: str,
saved_name: str | None,
explicit_filters: list[tuple[str, str]],
typed_kwargs: dict[str, Any],
) -> list[tuple[str, str]]:
"""Layer a `--saved` filter set under explicit `--filter` and typed options.

Saved params are the base; explicit `--filter` pairs and any non-None typed
query option (already in ``typed_kwargs``) win. Order matters: ``handle_list``
overlays ``ctx.filters`` onto the typed-option params, so saved keys that the
user also set via a typed flag must be dropped here to let the flag win.
The set is resolved against NetBox's native saved filters (interchangeable
with the web UI), falling back to the local `config.yaml` map when the API is
unavailable. Saved params are the base; explicit `--filter` pairs and any
non-None typed query option (already in ``typed_kwargs``) win. Order matters:
``handle_list`` overlays ``ctx.filters`` onto the typed-option params, so
saved keys that the user also set via a typed flag must be dropped here to let
the flag win.
"""
if saved_name is None:
return explicit_filters
from nsc.config.saved_searches import get_saved_search # noqa: PLC0415
from nsc.config.saved_searches import ConfigFileSavedSearchStore # noqa: PLC0415
from nsc.savedfilters.store import NativeSavedFilterStore # noqa: PLC0415

saved = get_saved_search(ctx.config, tag_name, resource_name, saved_name)
store = NativeSavedFilterStore(
ctx.client,
ConfigFileSavedSearchStore(ctx.config),
on_error=lambda message: typer.echo(f"Warning: {message}", err=True),
)
available = store.list(list_path, tag_name, resource_name)
saved = available.get(saved_name)
if saved is None:
raise typer.BadParameter(
f"no saved search named {saved_name!r} for {tag_name}/{resource_name}"
Expand Down Expand Up @@ -191,7 +202,7 @@ def impl(**kwargs: Any) -> None:
"limit": limit,
"fetch_all": fetch_all,
"filters": _resolve_saved_filters(
ctx, tag_name, resource_name, saved_name, explicit_filters, kwargs
ctx, tag_name, resource_name, operation.path, saved_name, explicit_filters, kwargs
),
}
if output:
Expand Down Expand Up @@ -350,9 +361,10 @@ def _build_global_flag_params() -> tuple[inspect.Parameter, ...]:
None,
"--saved",
help=(
"Apply a LOCAL named filter set saved from the TUI (config "
"`saved_searches`). Explicit --filter/typed options override it. "
"Unrelated to NetBox server-side saved filters."
"Apply a named saved filter for this resource. Resolves against "
"NetBox's native saved filters (interchangeable with the web UI), "
"falling back to the local config `saved_searches` when the API is "
"unavailable. Explicit --filter/typed options override it."
),
),
),
Expand Down
54 changes: 7 additions & 47 deletions nsc/cli/tui_commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,44 +41,13 @@ def _save_columns(tag: str, resource: str, columns: list[str]) -> None:
atomic_write(path, dump_round_trip(doc))


def _save_search(tag: str, resource: str, name: str, params: dict[str, str]) -> None:
"""Persist a filter set to ``saved_searches.<tag>.<resource>.<name>``."""
from nsc.config.saved_searches import validate_saved_search_name # noqa: PLC0415
from nsc.config.settings import default_paths # noqa: PLC0415
from nsc.config.writer import ( # noqa: PLC0415
acquire_lock,
atomic_write,
dump_round_trip,
load_round_trip,
set_path,
)

# Defensive: a dotted name would split into a nested map under set_path and
# corrupt the file. Validate before touching disk so the file is untouched.
validate_saved_search_name(name)
path = default_paths().config_file
with acquire_lock(path):
doc = load_round_trip(path)
set_path(doc, f"saved_searches.{tag}.{resource}.{name}", dict(params))
atomic_write(path, dump_round_trip(doc))
def _build_saved_filter_store(runtime: RuntimeContext) -> object:
"""Native NetBox SavedFilter store with a config.yaml offline fallback."""
from nsc.config.saved_searches import ConfigFileSavedSearchStore # noqa: PLC0415
from nsc.savedfilters.store import NativeSavedFilterStore # noqa: PLC0415


def _delete_search(tag: str, resource: str, name: str) -> None:
"""Remove ``saved_searches.<tag>.<resource>.<name>`` and prune empty parents."""
from nsc.config.settings import default_paths # noqa: PLC0415
from nsc.config.writer import ( # noqa: PLC0415
acquire_lock,
atomic_write,
dump_round_trip,
load_round_trip,
unset_path,
)

path = default_paths().config_file
with acquire_lock(path):
doc = load_round_trip(path)
unset_path(doc, f"saved_searches.{tag}.{resource}.{name}")
atomic_write(path, dump_round_trip(doc))
fallback = ConfigFileSavedSearchStore(runtime.config)
return NativeSavedFilterStore(runtime.client, fallback)


def register(app: typer.Typer) -> None:
Expand All @@ -94,23 +63,14 @@ def tui(
from nsc.tui import run_tui # noqa: PLC0415 # deferred: keeps Textual lazy.

column_prefs = {tag: dict(resources) for tag, resources in runtime.config.columns.items()}
saved_searches = {
tag: {
res: {name: dict(params) for name, params in sets.items()}
for res, sets in resources.items()
}
for tag, resources in runtime.config.saved_searches.items()
}
run_tui(
runtime.command_model,
runtime.client,
initial_resource=resource,
save_columns=_save_columns,
column_prefs=column_prefs,
object_colors=runtime.object_colors,
saved_searches=saved_searches,
save_search=_save_search,
delete_search=_delete_search,
saved_filter_store=_build_saved_filter_store(runtime),
)

# `tui` is canonical; `interactive` and `i` are hidden aliases for the same
Expand Down
58 changes: 58 additions & 0 deletions nsc/config/saved_searches.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@

from __future__ import annotations

from pathlib import Path

from nsc.config.models import Config

_MIN_PRINTABLE = 0x20
Expand Down Expand Up @@ -52,3 +54,59 @@ def get_saved_search(config: Config, tag: str, resource: str, name: str) -> dict
def list_saved_searches(config: Config, tag: str, resource: str) -> list[str]:
"""Sorted names of saved searches for `<tag>.<resource>` (empty if none)."""
return sorted(config.saved_searches.get(tag, {}).get(resource, {}))


class ConfigFileSavedSearchStore:
"""Offline fallback: saved searches in `config.yaml`, keyed by tag/resource.

Used when NetBox's native saved filters can't be reached. Reads come from the
in-memory `Config`; writes round-trip `config.yaml` (preserving comments) and
mutate the in-memory config so a later read in the same session is consistent.
"""

def __init__(self, config: Config, *, config_file: Path | None = None) -> None:
self._config = config
if config_file is None:
from nsc.config.settings import default_paths # noqa: PLC0415

config_file = default_paths().config_file
self._config_file = config_file

def list(self, tag: str, resource: str) -> dict[str, dict[str, str]]:
sets = self._config.saved_searches.get(tag, {}).get(resource, {})
return {name: dict(params) for name, params in sets.items()}

def save(self, tag: str, resource: str, name: str, params: dict[str, str]) -> None:
# Validate before touching disk: a dotted name would split into a nested
# map under set_path and corrupt the file.
validate_saved_search_name(name)
from nsc.config.writer import ( # noqa: PLC0415
acquire_lock,
atomic_write,
dump_round_trip,
load_round_trip,
set_path,
)

with acquire_lock(self._config_file):
doc = load_round_trip(self._config_file)
set_path(doc, f"saved_searches.{tag}.{resource}.{name}", dict(params))
atomic_write(self._config_file, dump_round_trip(doc))
self._config.saved_searches.setdefault(tag, {}).setdefault(resource, {})[name] = dict(
params
)

def delete(self, tag: str, resource: str, name: str) -> None:
from nsc.config.writer import ( # noqa: PLC0415
acquire_lock,
atomic_write,
dump_round_trip,
load_round_trip,
unset_path,
)

with acquire_lock(self._config_file):
doc = load_round_trip(self._config_file)
unset_path(doc, f"saved_searches.{tag}.{resource}.{name}")
atomic_write(self._config_file, dump_round_trip(doc))
self._config.saved_searches.get(tag, {}).get(resource, {}).pop(name, None)
4 changes: 4 additions & 0 deletions nsc/savedfilters/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
"""Native NetBox SavedFilter (``extras.saved-filters``) backing for nsc's
saved searches, plus the object-type resolution that makes them interchangeable
with the NetBox web UI. Framework-free: no Typer, no Textual.
"""
103 changes: 103 additions & 0 deletions nsc/savedfilters/objecttypes.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
"""Resolve a NetBox list endpoint to its ``app_label.model`` object type.

NetBox identifies a saved filter's target by object type (e.g. ``dcim.device``).
The only robust, version-stable mapping from one of nsc's list resources to that
string is NetBox's own object-type registry: ``/api/core/object-types/`` returns,
for every model, its ``app_label``, ``model``, and ``rest_api_endpoint`` (the
model's list URL). Matching a resource's list path against ``rest_api_endpoint``
avoids fragile depluralization guesses (``ip-addresses`` -> ``ipaddress``) and the
schema-name noise (devices' schema is ``DeviceWithConfigContext``, not ``Device``).
"""

from __future__ import annotations

from typing import Any, Protocol
from urllib.parse import urlsplit

from nsc.http.errors import NetBoxAPIError, NetBoxClientError

_OBJECT_TYPES_PATH = "/api/core/object-types/"


class _ClientLike(Protocol):
def paginate(
self, path: str, params: dict[str, Any] | None = ..., *, limit: int | None = ...
) -> Any: ...


def normalize_endpoint(path_or_url: str) -> str:
"""A list path/URL reduced to a comparable ``/api/.../`` form.

Strips any scheme+host and any deployment sub-path prefix, lowercases, and
ensures a single trailing slash, so a resource's ``Operation.path`` (always a
bare ``/api/...``) and an object type's server-built ``rest_api_endpoint``
compare equal regardless of absolute-vs-relative, casing, *or* a NetBox
sub-path install (where ``rest_api_endpoint`` is e.g. ``/netbox/api/...``).
"""
path = urlsplit(path_or_url).path.lower()
marker = "/api/"
idx = path.find(marker)
if idx > 0:
path = path[idx:]
if not path.endswith("/"):
path += "/"
return path


def app_label_from_path(list_path: str) -> str | None:
"""The NetBox app label a list path belongs to, or None if not derivable.

``/api/dcim/devices/`` -> ``dcim``; plugin routes
``/api/plugins/<plugin>/...`` -> ``<plugin>``.
"""
parts = [p for p in urlsplit(list_path).path.split("/") if p]
if not parts or parts[0] != "api" or len(parts) < 2: # noqa: PLR2004
return None
if parts[1] == "plugins":
return parts[2] if len(parts) >= 3 else None # noqa: PLR2004
return parts[1]


def object_type_index(object_types: Any) -> dict[str, str]:
"""Map normalized ``rest_api_endpoint`` -> ``app_label.model`` for each type."""
index: dict[str, str] = {}
for ot in object_types:
endpoint = ot.get("rest_api_endpoint")
app_label = ot.get("app_label")
model = ot.get("model")
if endpoint and app_label and model:
index[normalize_endpoint(endpoint)] = f"{app_label}.{model}"
return index


class ObjectTypeResolver:
"""Looks up object types from the live registry, cached per app label."""

def __init__(self, client: _ClientLike) -> None:
self._client = client
self._cache: dict[str, dict[str, str]] = {}

def resolve(self, list_path: str) -> str | None:
"""``app_label.model`` for a list path, or None if it can't be resolved.

Returns None (rather than raising) when the path is unparseable or the
registry can't be reached, so callers can fall back to local storage.
"""
app_label = app_label_from_path(list_path)
if app_label is None:
return None
index = self._index_for(app_label)
if index is None:
return None
return index.get(normalize_endpoint(list_path))

def _index_for(self, app_label: str) -> dict[str, str] | None:
if app_label in self._cache:
return self._cache[app_label]
try:
records = list(self._client.paginate(_OBJECT_TYPES_PATH, {"app_label": app_label}))
except (NetBoxAPIError, NetBoxClientError):
return None
index = object_type_index(records)
self._cache[app_label] = index
return index
Loading