jnav 1.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
jnav/role_mapper.py ADDED
@@ -0,0 +1,53 @@
1
+ from typing import Any
2
+
3
+ from aioreactive import AsyncSubject
4
+
5
+ from jnav.field_mapping import FieldMapping, detect_role_updates
6
+ from jnav.store import IndexedEntry
7
+
8
+
9
+ class RoleMapper:
10
+ """Tracks fields discovered in the data and the timestamp/level/message role mapping."""
11
+
12
+ on_change: AsyncSubject[None]
13
+
14
+ def __init__(self) -> None:
15
+ self._all_fields: set[str] = set()
16
+ self._mapping: FieldMapping = FieldMapping()
17
+ self.on_change = AsyncSubject[None]()
18
+
19
+ @property
20
+ def all_fields(self) -> set[str]:
21
+ return self._all_fields
22
+
23
+ @property
24
+ def mapping(self) -> FieldMapping:
25
+ return self._mapping
26
+
27
+ async def discover(self, entries: list[IndexedEntry]) -> None:
28
+ """Discover fields from a list of entries. Updates the mapping if new roles are detected."""
29
+ for ie in entries:
30
+ await self.discover_from_entry(ie.entry.expanded)
31
+
32
+ async def discover_from_entry(self, entry: dict[str, Any]) -> None:
33
+ """Discover fields from a single entry. Updates the mapping if new roles are detected."""
34
+ new_fields = entry.keys() - self._all_fields
35
+ self._all_fields.update(new_fields)
36
+
37
+ updates = detect_role_updates(self._mapping, entry, new_fields)
38
+
39
+ if not updates:
40
+ return
41
+
42
+ self._mapping = self._mapping.model_copy(update=updates)
43
+ await self.on_change.asend(None)
44
+
45
+ async def set_mapping(
46
+ self,
47
+ mapping_data: dict[str, Any] | FieldMapping | None,
48
+ ) -> None:
49
+ if mapping_data:
50
+ self._mapping = FieldMapping.model_validate(mapping_data)
51
+ else:
52
+ self._mapping = FieldMapping()
53
+ await self.on_change.asend(None)
@@ -0,0 +1,79 @@
1
+ from collections.abc import Generator
2
+
3
+ from rich.color import Color
4
+ from rich.console import Console, ConsoleOptions, RenderableType
5
+ from rich.segment import Segment
6
+ from rich.style import Style
7
+
8
+
9
+ def _get_bg_at(line: list[Segment], position: int) -> Color | None:
10
+ pos = 0
11
+ for seg in line:
12
+ pos += seg.cell_length
13
+ if pos > position:
14
+ return seg.style.bgcolor if seg.style else None
15
+ return None
16
+
17
+
18
+ class ScrollbarOverlay:
19
+ """A Rich renderable that overlays a scrollbar thumb on inner content.
20
+
21
+ Renders the inner content normally, but on lines within the thumb region,
22
+ truncates the line by one character and appends a scrollbar indicator.
23
+ The thumb character inherits the background color from the content beneath it.
24
+ """
25
+
26
+ def __init__(
27
+ self,
28
+ inner: RenderableType,
29
+ *,
30
+ total_items: int,
31
+ visible_count: int,
32
+ scroll_position: int,
33
+ thumb_style: Style,
34
+ ) -> None:
35
+ self._inner = inner
36
+ self._total_items = total_items
37
+ self._visible_count = visible_count
38
+ self._scroll_position = scroll_position
39
+ self._thumb_style = thumb_style
40
+
41
+ def _compute_thumb(self, viewport_height: int) -> tuple[int, int]:
42
+ max_scroll = self._total_items - self._visible_count
43
+ if max_scroll <= 0:
44
+ return 0, 0
45
+ thumb_size = max(
46
+ 1, round(viewport_height * self._visible_count / self._total_items)
47
+ )
48
+ fraction = self._scroll_position / max_scroll
49
+ thumb_top = round(fraction * (viewport_height - thumb_size))
50
+ thumb_top = max(0, min(thumb_top, viewport_height - thumb_size))
51
+ return thumb_top, thumb_top + thumb_size
52
+
53
+ def __rich_console__(
54
+ self,
55
+ console: Console,
56
+ options: ConsoleOptions,
57
+ ) -> Generator[Segment]:
58
+ width = options.max_width
59
+ height = options.height or options.max_height
60
+
61
+ thumb_top, thumb_end = self._compute_thumb(height)
62
+
63
+ segments = console.render(self._inner, options)
64
+ for y, line in enumerate(Segment.split_lines(segments)):
65
+ # Yield the line as-is if it's outside the thumb region
66
+ if not (thumb_top <= y < thumb_end):
67
+ yield from line
68
+ yield Segment.line()
69
+ continue
70
+
71
+ # Truncate the line to make room for the scrollbar
72
+ truncated = Segment.adjust_line_length(list(line), width - 1)
73
+ yield from truncated
74
+
75
+ # Get the background color of the wrapped renderable
76
+ bg = _get_bg_at(line, width - 1)
77
+
78
+ yield Segment("┃", Style(color=self._thumb_style.color, bgcolor=bg))
79
+ yield Segment.line()
jnav/search_engine.py ADDED
@@ -0,0 +1,87 @@
1
+ from typing import Any, cast
2
+
3
+ from aioreactive import AsyncSubject
4
+
5
+ from jnav.log_model import LogModel
6
+ from jnav.store import IndexedEntry
7
+
8
+
9
+ def entry_matches_search(entry: dict[str, Any], term_lower: str) -> bool:
10
+ def _check(obj: object) -> bool:
11
+ if isinstance(obj, str):
12
+ return term_lower in obj.lower()
13
+ if isinstance(obj, dict):
14
+ obj = cast(dict[str, Any], obj)
15
+ return any(_check(v) for v in obj.values())
16
+ if isinstance(obj, list):
17
+ obj = cast(list[Any], obj)
18
+ return any(_check(item) for item in obj)
19
+ return term_lower in str(obj).lower()
20
+
21
+ return _check(entry)
22
+
23
+
24
+ class SearchEngine:
25
+ on_change: AsyncSubject[None]
26
+
27
+ def __init__(self, model: LogModel) -> None:
28
+ self._model = model
29
+ self._term: str = ""
30
+ self._matches: list[int] = []
31
+ self.on_change = AsyncSubject[None]()
32
+
33
+ @property
34
+ def term(self) -> str:
35
+ return self._term
36
+
37
+ @property
38
+ def matches(self) -> list[int]:
39
+ return self._matches
40
+
41
+ @property
42
+ def active(self) -> bool:
43
+ return bool(self._term)
44
+
45
+ async def start(self) -> None:
46
+ await self._model.on_rebuild.subscribe_async(self._on_rebuild)
47
+ await self._model.on_append.subscribe_async(self._on_append)
48
+
49
+ async def set_term(self, term: str) -> None:
50
+ self._term = term
51
+ self._recompute_all()
52
+ await self.on_change.asend(None)
53
+
54
+ async def clear(self) -> None:
55
+ self._term = ""
56
+ self._matches = []
57
+ await self.on_change.asend(None)
58
+
59
+ def _recompute_all(self) -> None:
60
+ if not self._term:
61
+ self._matches = []
62
+ return
63
+ term_lower = self._term.lower()
64
+ self._matches = [
65
+ ie.index
66
+ for ie in self._model.all()
67
+ if entry_matches_search(ie.entry.expanded, term_lower)
68
+ ]
69
+
70
+ async def _on_rebuild(self, _: None) -> None:
71
+ if not self._term:
72
+ return
73
+ self._recompute_all()
74
+ await self.on_change.asend(None)
75
+
76
+ async def _on_append(self, new_entries: list[IndexedEntry]) -> None:
77
+ if not self._term:
78
+ return
79
+ term_lower = self._term.lower()
80
+ new_matches = [
81
+ ie.index
82
+ for ie in new_entries
83
+ if entry_matches_search(ie.entry.expanded, term_lower)
84
+ ]
85
+ if new_matches:
86
+ self._matches.extend(new_matches)
87
+ await self.on_change.asend(None)
@@ -0,0 +1,187 @@
1
+ from typing import TYPE_CHECKING, ClassVar, override
2
+
3
+ from rich.text import Text
4
+ from textual.app import ComposeResult
5
+ from textual.binding import Binding, BindingType
6
+ from textual.containers import Vertical
7
+ from textual.screen import ModalScreen
8
+ from textual.widgets import Footer, OptionList
9
+ from textual.widgets.option_list import Option
10
+
11
+ from jnav.manager_screen_common import list_option_prompt
12
+ from jnav.selector_provider import SelectorProvider
13
+ from jnav.text_input_screen import TextInputScreen
14
+
15
+ if TYPE_CHECKING:
16
+ from textual import getters
17
+ from textual.app import App
18
+
19
+
20
+ class SelectorManagerScreen(ModalScreen[bool]):
21
+ if TYPE_CHECKING:
22
+ app = getters.app(App[None])
23
+
24
+ DEFAULT_CSS = """
25
+ SelectorManagerScreen {
26
+ align: center middle;
27
+ }
28
+ #selector-modal {
29
+ width: 60;
30
+ max-width: 90%;
31
+ height: auto;
32
+ max-height: 70%;
33
+ border: round $primary;
34
+ background: $background;
35
+ }
36
+ #selector-wrapper {
37
+ padding: 1 2;
38
+ height: auto;
39
+ }
40
+ #selector-list {
41
+ height: auto;
42
+ max-height: 14;
43
+ border: none;
44
+ }
45
+ #selector-modal Footer {
46
+ background: transparent;
47
+ }
48
+ #selector-modal FooterKey .footer-key--key {
49
+ color: $primary;
50
+ }
51
+ """
52
+
53
+ BINDINGS: ClassVar[list[BindingType]] = [
54
+ Binding("escape", "maybe_close", "Close", priority=True, show=False),
55
+ Binding("q", "maybe_close", show=False),
56
+ Binding("ctrl+c", "maybe_close", show=False),
57
+ Binding("a", "add", "Add"),
58
+ Binding("e", "edit", "Edit"),
59
+ Binding("d", "delete", "Cut"),
60
+ Binding("y", "yank", "Yank"),
61
+ Binding("p", "paste", "Paste"),
62
+ Binding("t", "toggle_item", "Toggle"),
63
+ Binding("j", "cursor_down", show=False),
64
+ Binding("k", "cursor_up", show=False),
65
+ ]
66
+
67
+ def __init__(self, selector_provider: SelectorProvider) -> None:
68
+ super().__init__()
69
+ self._sp = selector_provider
70
+ self._clipboard: str | None = None
71
+
72
+ @override
73
+ def compose(self) -> ComposeResult:
74
+ yield Vertical(
75
+ Vertical(
76
+ OptionList(id="selector-list"),
77
+ id="selector-wrapper",
78
+ ),
79
+ Footer(),
80
+ id="selector-modal",
81
+ )
82
+
83
+ def on_mount(self) -> None:
84
+ self.query_one("#selector-modal").border_title = "Selectors"
85
+ self._refresh_list(highlight=0)
86
+ self.query_one("#selector-list", OptionList).focus()
87
+
88
+ def _refresh_list(self, highlight: int | None = None) -> None:
89
+ selectors = self._sp.selectors
90
+ ol = self.query_one("#selector-list", OptionList)
91
+ ol.clear_options()
92
+ if not selectors:
93
+ ol.add_option(Option(Text(" (no selectors)", style="dim"), disabled=True))
94
+ else:
95
+ for s in selectors:
96
+ ol.add_option(list_option_prompt(s["path"], s["enabled"]))
97
+ if highlight is not None and selectors:
98
+ ol.highlighted = min(highlight, len(selectors) - 1)
99
+
100
+ def action_cursor_down(self) -> None:
101
+ self.query_one("#selector-list", OptionList).action_cursor_down()
102
+
103
+ def action_cursor_up(self) -> None:
104
+ self.query_one("#selector-list", OptionList).action_cursor_up()
105
+
106
+ async def action_toggle_item(self) -> None:
107
+ ol = self.query_one("#selector-list", OptionList)
108
+ idx = ol.highlighted
109
+ if idx is not None and idx < len(self._sp.selectors):
110
+ await self._sp.toggle_selector(idx)
111
+ self._refresh_list(idx)
112
+
113
+ async def action_delete(self) -> None:
114
+ ol = self.query_one("#selector-list", OptionList)
115
+ idx = ol.highlighted
116
+ selectors = self._sp.selectors
117
+ if idx is None or idx >= len(selectors):
118
+ return
119
+ self._clipboard = selectors[idx]["path"]
120
+ await self._sp.remove_selector(idx)
121
+ self._refresh_list(idx)
122
+
123
+ def action_yank(self) -> None:
124
+ ol = self.query_one("#selector-list", OptionList)
125
+ idx = ol.highlighted
126
+ selectors = self._sp.selectors
127
+ if idx is None or idx >= len(selectors):
128
+ return
129
+ self._clipboard = selectors[idx]["path"]
130
+
131
+ async def action_paste(self) -> None:
132
+ if self._clipboard is None:
133
+ return
134
+ ol = self.query_one("#selector-list", OptionList)
135
+ idx = ol.highlighted
136
+ target = (idx + 1) if idx is not None else len(self._sp.selectors)
137
+ await self._sp.insert_selector(target, self._clipboard)
138
+ self._clipboard = None
139
+ self._refresh_list(target)
140
+
141
+ def action_add(self) -> None:
142
+ ol = self.query_one("#selector-list", OptionList)
143
+ idx = ol.highlighted
144
+ target = (idx + 1) if idx is not None else len(self._sp.selectors)
145
+
146
+ async def on_dismiss(value: str | None) -> None:
147
+ if not value:
148
+ return
149
+ path = value.strip()
150
+ if not path:
151
+ return
152
+ await self._sp.insert_selector(target, path)
153
+ self._refresh_list(target)
154
+
155
+ self.app.push_screen(
156
+ TextInputScreen("Add selector", placeholder="jq selector..."),
157
+ on_dismiss,
158
+ )
159
+
160
+ def action_edit(self) -> None:
161
+ ol = self.query_one("#selector-list", OptionList)
162
+ idx = ol.highlighted
163
+ selectors = self._sp.selectors
164
+ if idx is None or idx >= len(selectors):
165
+ return
166
+ current = selectors[idx]["path"]
167
+
168
+ async def on_dismiss(value: str | None) -> None:
169
+ if not value:
170
+ return
171
+ path = value.strip()
172
+ if not path:
173
+ return
174
+ await self._sp.edit_selector(idx, path)
175
+ self._refresh_list(idx)
176
+
177
+ self.app.push_screen(
178
+ TextInputScreen(
179
+ "Edit selector",
180
+ placeholder="jq selector...",
181
+ initial_value=current,
182
+ ),
183
+ on_dismiss,
184
+ )
185
+
186
+ def action_maybe_close(self) -> None:
187
+ self.dismiss(True)
@@ -0,0 +1,64 @@
1
+ from typing import TypedDict
2
+
3
+ from aioreactive import AsyncSubject
4
+
5
+
6
+ class Selector(TypedDict):
7
+ path: str
8
+ enabled: bool
9
+
10
+
11
+ class SelectorProvider:
12
+ """Tracks user-defined jq selectors for extracting values from entries."""
13
+
14
+ on_change: AsyncSubject[None]
15
+
16
+ def __init__(self) -> None:
17
+ self._selectors: list[Selector] = []
18
+ self.on_change = AsyncSubject[None]()
19
+
20
+ @property
21
+ def selectors(self) -> list[Selector]:
22
+ return list(self._selectors)
23
+
24
+ @property
25
+ def active_selectors(self) -> set[str]:
26
+ return {s["path"] for s in self._selectors if s["enabled"]}
27
+
28
+ def has_selector(self, path: str) -> bool:
29
+ return any(s["path"] == path for s in self._selectors)
30
+
31
+ async def add_selector(self, path: str) -> None:
32
+ self._selectors.append({"path": path, "enabled": True})
33
+ await self.on_change.asend(None)
34
+
35
+ async def insert_selector(self, index: int, path: str) -> None:
36
+ self._selectors.insert(index, {"path": path, "enabled": True})
37
+ await self.on_change.asend(None)
38
+
39
+ async def remove_selector(self, index: int) -> None:
40
+ self._selectors.pop(index)
41
+ await self.on_change.asend(None)
42
+
43
+ async def remove_selector_by_path(self, path: str) -> None:
44
+ for i, s in enumerate(self._selectors):
45
+ if s["path"] == path:
46
+ self._selectors.pop(i)
47
+ await self.on_change.asend(None)
48
+ return
49
+
50
+ async def toggle_selector(self, index: int) -> None:
51
+ self._selectors[index]["enabled"] = not self._selectors[index]["enabled"]
52
+ await self.on_change.asend(None)
53
+
54
+ async def edit_selector(self, index: int, path: str) -> None:
55
+ self._selectors[index]["path"] = path
56
+ await self.on_change.asend(None)
57
+
58
+ async def set_selectors(self, selectors: list[Selector]) -> None:
59
+ self._selectors = list(selectors)
60
+ await self.on_change.asend(None)
61
+
62
+ async def clear_selectors(self) -> None:
63
+ self._selectors.clear()
64
+ await self.on_change.asend(None)
jnav/store.py ADDED
@@ -0,0 +1,37 @@
1
+ from dataclasses import dataclass
2
+
3
+ from aioreactive import AsyncSubject
4
+
5
+ from jnav.parsing import ParsedEntry
6
+
7
+ LogEntry = ParsedEntry
8
+
9
+
10
+ @dataclass(frozen=True)
11
+ class IndexedEntry:
12
+ index: int
13
+ entry: LogEntry
14
+
15
+
16
+ class Store:
17
+ entries: list[LogEntry]
18
+ on_append: AsyncSubject[list[IndexedEntry]]
19
+
20
+ def __init__(self) -> None:
21
+ self.entries = []
22
+ self.on_append = AsyncSubject[list[IndexedEntry]]()
23
+
24
+ async def append_entries(self, new_entries: list[LogEntry]) -> None:
25
+ start = len(self.entries)
26
+ self.entries.extend(new_entries)
27
+ indexed = [IndexedEntry(start + i, e) for i, e in enumerate(new_entries)]
28
+ await self.on_append.asend(indexed)
29
+
30
+ def get(self, index: int) -> IndexedEntry:
31
+ return IndexedEntry(index, self.entries[index])
32
+
33
+ def all(self) -> list[IndexedEntry]:
34
+ return [IndexedEntry(i, e) for i, e in enumerate(self.entries)]
35
+
36
+ def __len__(self) -> int:
37
+ return len(self.entries)
@@ -0,0 +1,63 @@
1
+ from typing import ClassVar, override
2
+
3
+ from textual import on
4
+ from textual.app import ComposeResult
5
+ from textual.binding import Binding, BindingType
6
+ from textual.screen import ModalScreen
7
+ from textual.widgets import Input
8
+
9
+
10
+ class TextInputScreen(ModalScreen[str | None]):
11
+ DEFAULT_CSS = """
12
+ TextInputScreen {
13
+ align: center middle;
14
+ }
15
+ #text-input {
16
+ width: 50;
17
+ max-width: 90%;
18
+ height: 3;
19
+ border: round $primary;
20
+ background: $background;
21
+ padding: 0 2;
22
+ margin: 0;
23
+
24
+ &:focus {
25
+ background-tint: transparent;
26
+ }
27
+ }
28
+ """
29
+
30
+ BINDINGS: ClassVar[list[BindingType]] = [
31
+ Binding("escape", "close", "Close", priority=True),
32
+ Binding("ctrl+c", "close", show=False),
33
+ ]
34
+
35
+ def __init__(
36
+ self,
37
+ title: str = "Search",
38
+ placeholder: str = "search term...",
39
+ initial_value: str = "",
40
+ ) -> None:
41
+ super().__init__()
42
+ self._title = title
43
+ self._placeholder = placeholder
44
+ self._initial_value = initial_value
45
+
46
+ @override
47
+ def compose(self) -> ComposeResult:
48
+ yield Input(placeholder=self._placeholder, id="text-input")
49
+
50
+ def on_mount(self) -> None:
51
+ self.query_one("#text-input").border_title = self._title
52
+ inp = self.query_one("#text-input", Input)
53
+ if self._initial_value:
54
+ inp.value = self._initial_value
55
+ inp.focus()
56
+
57
+ @on(Input.Submitted, "#text-input")
58
+ def on_submitted(self, event: Input.Submitted) -> None:
59
+ term = event.value.strip()
60
+ self.dismiss(term if term else None)
61
+
62
+ def action_close(self) -> None:
63
+ self.dismiss(None)