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/detail_tree.py ADDED
@@ -0,0 +1,242 @@
1
+ import json
2
+ import os
3
+ import subprocess
4
+ import tempfile
5
+ from typing import TYPE_CHECKING, ClassVar, TypedDict
6
+
7
+ from rich.text import Text
8
+ from textual.binding import Binding, BindingType
9
+ from textual.events import Key
10
+ from textual.widgets import Tree
11
+ from textual.widgets.tree import TreeNode
12
+
13
+ from .filter_provider import FilterProvider
14
+ from .filtering import get_nested, jq_value_literal, resolve_selected_paths
15
+ from .key_sequences import KeySequence, KeySequenceMixin
16
+ from .log_entry_item import format_timestamp
17
+ from .parsing import ParsedEntry
18
+ from .role_mapper import RoleMapper
19
+ from .search_engine import SearchEngine
20
+ from .selector_provider import SelectorProvider
21
+ from .tree_rendering import TreeBuildVisitor, walk_tree
22
+
23
+
24
+ class TreeNodeData(TypedDict):
25
+ path: str
26
+ value: object
27
+
28
+
29
+ def _detail_add_branch(
30
+ parent: TreeNode[TreeNodeData],
31
+ label: Text,
32
+ path: str,
33
+ value: object,
34
+ ) -> TreeNode[TreeNodeData]:
35
+ return parent.add(label, data={"path": path, "value": value})
36
+
37
+
38
+ def _detail_add_leaf(
39
+ parent: TreeNode[TreeNodeData],
40
+ label: Text,
41
+ path: str,
42
+ value: object,
43
+ ) -> None:
44
+ parent.add_leaf(label, data={"path": path, "value": value})
45
+
46
+
47
+ if TYPE_CHECKING:
48
+ from textual import getters
49
+ from textual.app import App
50
+
51
+
52
+ class DetailTree(KeySequenceMixin, Tree[TreeNodeData]):
53
+ COMPONENT_CLASSES: ClassVar[set[str]] = {
54
+ "tree--key",
55
+ "tree--key-selected",
56
+ "tree--value",
57
+ "tree--value-null",
58
+ "tree--json-string",
59
+ "tree--search-highlight",
60
+ }
61
+
62
+ DEFAULT_CSS = ""
63
+
64
+ if TYPE_CHECKING:
65
+ app = getters.app(App[None])
66
+
67
+ BINDINGS: ClassVar[list[BindingType]] = [
68
+ Binding("j", "cursor_down", show=False),
69
+ Binding("k", "cursor_up", show=False),
70
+ Binding("g", "scroll_home", show=False),
71
+ Binding("G", "scroll_end", show=False),
72
+ Binding("ctrl+d", "page_down", show=False),
73
+ Binding("ctrl+u", "page_up", show=False),
74
+ Binding("s", "add_select", "Select"),
75
+ ]
76
+
77
+ SEQUENCES: ClassVar[list[KeySequence]] = [
78
+ KeySequence("ff", "filter_value", "by value"),
79
+ KeySequence("fn", "filter_has", "has field"),
80
+ KeySequence("vo", "toggle_filter_tree", "show selected only"),
81
+ KeySequence("ve", "view_value", "open in editor"),
82
+ ]
83
+ SEQUENCE_GROUPS: ClassVar[dict[str, str]] = {"f": "filter ▸", "v": "view ▸"}
84
+
85
+ show_selected_only: bool = False
86
+ _entry: ParsedEntry | None = None
87
+ _entry_index: int = 0
88
+
89
+ def __init__(
90
+ self,
91
+ label: str,
92
+ *,
93
+ selectors: SelectorProvider,
94
+ filters: FilterProvider,
95
+ search: SearchEngine,
96
+ role_mapper: RoleMapper,
97
+ id: str | None = None,
98
+ ) -> None:
99
+ super().__init__(label, id=id)
100
+ self._selectors = selectors
101
+ self._filters = filters
102
+ self._search = search
103
+ self._role_mapper = role_mapper
104
+
105
+ def on_focus(self) -> None:
106
+ if self.parent is not None:
107
+ self.parent.add_class("focused")
108
+
109
+ def on_blur(self) -> None:
110
+ if self.parent is not None:
111
+ self.parent.remove_class("focused")
112
+
113
+ async def on_mount(self) -> None: # pyright: ignore[reportIncompatibleMethodOverride, reportImplicitOverride]
114
+ await self._selectors.on_change.subscribe_async(self._rerender)
115
+ await self._search.on_change.subscribe_async(self._rerender)
116
+ await self._role_mapper.on_change.subscribe_async(self._rerender)
117
+ self.app.theme_changed_signal.subscribe(self, lambda _: self._rebuild_tree())
118
+
119
+ async def _rerender(self, _: None) -> None:
120
+ self._rebuild_tree()
121
+
122
+ @property
123
+ def entry(self) -> ParsedEntry | None:
124
+ return self._entry
125
+
126
+ def show_entry(self, parsed: ParsedEntry, index: int) -> None:
127
+ self._entry = parsed
128
+ self._entry_index = index
129
+ self._rebuild_tree()
130
+
131
+ def _rebuild_tree(self) -> None:
132
+ if self._entry is None:
133
+ return
134
+ entry = self._entry.expanded
135
+ selected = resolve_selected_paths(self._selectors.active_selectors, entry)
136
+
137
+ label = f"#{self._entry_index + 1}"
138
+ ts_field = self._role_mapper.mapping.timestamp
139
+ if ts_field is not None:
140
+ ts_val = entry.get(ts_field.path)
141
+ if ts_val not in (None, ""):
142
+ label += f" ({format_timestamp(ts_val, ts_field.format)})"
143
+ if self.show_selected_only:
144
+ label += " (selected)"
145
+
146
+ self.clear()
147
+ self.root.set_label(label)
148
+ data = (
149
+ {s: get_nested(entry, s) for s in self._selectors.active_selectors}
150
+ if self.show_selected_only
151
+ else entry
152
+ )
153
+ root_data: TreeNodeData = {"path": "", "value": data}
154
+ self.root.data = root_data
155
+ visitor = TreeBuildVisitor(
156
+ root=self.root,
157
+ add_branch=_detail_add_branch,
158
+ add_leaf=_detail_add_leaf,
159
+ selected=selected,
160
+ key_style=self.get_component_rich_style("tree--key", partial=True),
161
+ selected_style=self.get_component_rich_style(
162
+ "tree--key-selected", partial=True
163
+ ),
164
+ value_style=self.get_component_rich_style("tree--value", partial=True),
165
+ value_null_style=self.get_component_rich_style(
166
+ "tree--value-null", partial=True
167
+ ),
168
+ json_string_style=self.get_component_rich_style(
169
+ "tree--json-string", partial=True
170
+ ),
171
+ search_highlight_style=self.get_component_rich_style(
172
+ "tree--search-highlight", partial=True
173
+ ),
174
+ search_term=self._search.term,
175
+ )
176
+ walk_tree(
177
+ value=data, path="", visitor=visitor, json_paths=self._entry.expanded_paths
178
+ )
179
+ self.root.expand_all()
180
+
181
+ async def on_key(self, event: Key) -> None:
182
+ if await self._handle_sequence_key(event):
183
+ return
184
+
185
+ async def action_filter_value(self) -> None:
186
+ node = self.cursor_node
187
+ if node is None or node.data is None:
188
+ return
189
+ path = node.data["path"]
190
+ value = node.data["value"]
191
+ if isinstance(value, (dict, list)):
192
+ return
193
+ expr = f".{path} == {jq_value_literal(value)}"
194
+ await self._filters.add_filter(expr)
195
+
196
+ async def action_filter_has(self) -> None:
197
+ node = self.cursor_node
198
+ if node is None or node.data is None:
199
+ return
200
+ path = node.data["path"]
201
+ await self._filters.add_filter(f".{path} != null")
202
+
203
+ def action_toggle_filter_tree(self) -> None:
204
+ self.show_selected_only = not self.show_selected_only
205
+ self._rebuild_tree()
206
+
207
+ async def action_add_select(self) -> None:
208
+ node = self.cursor_node
209
+ if node is None or node.data is None:
210
+ return
211
+ selector = "." + node.data["path"]
212
+ if self._selectors.has_selector(selector):
213
+ await self._selectors.remove_selector_by_path(selector)
214
+ else:
215
+ await self._selectors.add_selector(selector)
216
+
217
+ def action_view_value(self) -> None:
218
+ node = self.cursor_node
219
+ if node is None or node.data is None:
220
+ return
221
+ value = node.data["value"]
222
+ if isinstance(value, (dict, list)):
223
+ content = json.dumps(value, indent=2, default=str)
224
+ suffix = ".json"
225
+ else:
226
+ content = str(value)
227
+ suffix = ".txt"
228
+ editor = os.environ.get("EDITOR", "less")
229
+ tmpdir = "/tmp/jnav"
230
+ os.makedirs(tmpdir, exist_ok=True)
231
+ with tempfile.NamedTemporaryFile(
232
+ mode="w",
233
+ suffix=suffix,
234
+ prefix="jnav_",
235
+ dir=tmpdir,
236
+ delete=False,
237
+ ) as f:
238
+ f.write(content)
239
+ path = f.name
240
+ with self.app.suspend():
241
+ subprocess.run([editor, path], check=False)
242
+ os.unlink(path)
jnav/field_mapping.py ADDED
@@ -0,0 +1,136 @@
1
+ from collections.abc import Callable
2
+ from dataclasses import dataclass
3
+ from datetime import datetime
4
+ from typing import Literal
5
+
6
+ from pydantic import BaseModel
7
+
8
+ TimestampFormat = Literal["iso8601", "epoch_s", "epoch_ms", "epoch_us", "epoch_ns"]
9
+
10
+
11
+ class TimestampField(BaseModel):
12
+ path: str
13
+ format: TimestampFormat
14
+
15
+
16
+ def _build_timestamp(path: str, value: object) -> TimestampField | None:
17
+ fmt = _detect_timestamp_format(value)
18
+ return TimestampField(path=path, format=fmt) if fmt is not None else None
19
+
20
+
21
+ def _build_string_role(path: str, value: object) -> str | None:
22
+ return path if value not in (None, "") else None
23
+
24
+
25
+ @dataclass(frozen=True)
26
+ class RoleSpec:
27
+ name: str
28
+ candidates: list[str]
29
+ build: Callable[[str, object], object | None]
30
+
31
+
32
+ ROLES: list[RoleSpec] = [
33
+ RoleSpec(
34
+ name="timestamp",
35
+ candidates=[
36
+ "@timestamp",
37
+ "timestamp",
38
+ "ts",
39
+ "time",
40
+ "@t",
41
+ "asctime",
42
+ "eventTime",
43
+ "Timestamp",
44
+ ],
45
+ build=_build_timestamp,
46
+ ),
47
+ RoleSpec(
48
+ name="level",
49
+ candidates=[
50
+ "level",
51
+ "severity",
52
+ "levelname",
53
+ "@l",
54
+ "log_level",
55
+ "loglevel",
56
+ "SeverityText",
57
+ ],
58
+ build=_build_string_role,
59
+ ),
60
+ RoleSpec(
61
+ name="message",
62
+ candidates=[
63
+ "message",
64
+ "msg",
65
+ "@m",
66
+ "event",
67
+ "Body",
68
+ "log",
69
+ ],
70
+ build=_build_string_role,
71
+ ),
72
+ ]
73
+
74
+
75
+ class FieldMapping(BaseModel):
76
+ timestamp: TimestampField | None = None
77
+ level: str | None = None
78
+ message: str | None = None
79
+
80
+ def assignments(self) -> dict[str, object | None]:
81
+ return {
82
+ "timestamp": self.timestamp,
83
+ "level": self.level,
84
+ "message": self.message,
85
+ }
86
+
87
+ def missing_roles(self) -> list[str]:
88
+ return [name for name, value in self.assignments().items() if value is None]
89
+
90
+
91
+ def detect_role_updates(
92
+ mapping: FieldMapping,
93
+ entry: dict[str, object],
94
+ new_fields: set[str],
95
+ ) -> dict[str, object]:
96
+ """Detect updates to the field mapping based on a new entry.
97
+ For each role, if it's not already set in the mapping, check the candidate fields.
98
+ If a candidate field is present in the new fields, attempt to build the role value.
99
+ If successful, add it to the updates dict.
100
+ """
101
+ updates: dict[str, object] = {}
102
+ current = mapping.assignments()
103
+ for role in ROLES:
104
+ if current[role.name] is not None:
105
+ continue
106
+ for candidate in role.candidates:
107
+ if candidate not in new_fields:
108
+ continue
109
+ built = role.build(candidate, entry.get(candidate))
110
+ if built is not None:
111
+ updates[role.name] = built
112
+ break
113
+ return updates
114
+
115
+
116
+ def _detect_timestamp_format(value: object) -> TimestampFormat | None:
117
+ if isinstance(value, str) and value:
118
+ try:
119
+ _ = datetime.fromisoformat(value)
120
+ return "iso8601"
121
+ except ValueError:
122
+ return None
123
+ if isinstance(value, (int, float)) and not isinstance(value, bool):
124
+ return _epoch_format_from_magnitude(float(value))
125
+ return None
126
+
127
+
128
+ def _epoch_format_from_magnitude(value: float) -> TimestampFormat:
129
+ magnitude = abs(value)
130
+ if magnitude < 1e11:
131
+ return "epoch_s"
132
+ if magnitude < 1e14:
133
+ return "epoch_ms"
134
+ if magnitude < 1e17:
135
+ return "epoch_us"
136
+ return "epoch_ns"
@@ -0,0 +1,100 @@
1
+ from typing import ClassVar, override
2
+
3
+ from textual.app import ComposeResult
4
+ from textual.binding import Binding, BindingType
5
+ from textual.containers import Vertical
6
+ from textual.screen import ModalScreen
7
+ from textual.widgets import Static
8
+
9
+ from jnav.filter_provider import FilterProvider
10
+ from jnav.filter_tree import FilterTree
11
+ from jnav.filtering import build_expression
12
+ from jnav.manager_screen_common import WrappingFooter
13
+
14
+
15
+ class FilterManagerScreen(ModalScreen[bool]):
16
+ DEFAULT_CSS = """
17
+ FilterManagerScreen {
18
+ align: center middle;
19
+ }
20
+ #filter-modal {
21
+ width: 70;
22
+ max-width: 90%;
23
+ height: auto;
24
+ max-height: 70%;
25
+ border: round $primary;
26
+ background: $background;
27
+ }
28
+ #filter-wrapper {
29
+ padding: 1 2;
30
+ height: auto;
31
+ }
32
+ #filter-expression {
33
+ color: $accent;
34
+ background: transparent;
35
+ border: round $background-lighten-2;
36
+ margin: 1 0 0 0;
37
+ padding: 0 1;
38
+ height: auto;
39
+ max-height: 5;
40
+ }
41
+ #filter-expression.empty {
42
+ color: $text-muted;
43
+ }
44
+ #filter-hints {
45
+ color: $text-muted;
46
+ margin: 1 0 0 0;
47
+ }
48
+ #filter-modal Footer {
49
+ background: transparent;
50
+ layout: grid;
51
+ grid-size: 4;
52
+ height: auto;
53
+ }
54
+ #filter-modal FooterKey .footer-key--key {
55
+ color: $primary;
56
+ }
57
+ """
58
+
59
+ BINDINGS: ClassVar[list[BindingType]] = [
60
+ Binding("escape", "maybe_close", "Close", priority=True),
61
+ Binding("q", "maybe_close", show=False),
62
+ Binding("ctrl+c", "maybe_close", show=False),
63
+ ]
64
+
65
+ def __init__(self, filter_provider: FilterProvider) -> None:
66
+ super().__init__()
67
+ self._fp = filter_provider
68
+
69
+ @override
70
+ def compose(self) -> ComposeResult:
71
+ yield Vertical(
72
+ Vertical(
73
+ FilterTree(self._fp, id="filter-tree"),
74
+ Static(id="filter-expression"),
75
+ id="filter-wrapper",
76
+ ),
77
+ WrappingFooter(columns=4),
78
+ id="filter-modal",
79
+ )
80
+
81
+ def on_mount(self) -> None:
82
+ self.query_one("#filter-modal").border_title = "Filters"
83
+ self.query_one("#filter-tree", FilterTree).focus()
84
+ self._update_preview()
85
+
86
+ def on_filter_tree_changed(self) -> None:
87
+ self._update_preview()
88
+
89
+ def _update_preview(self) -> None:
90
+ expr_widget = self.query_one("#filter-expression", Static)
91
+ expr = build_expression(self._fp.root)
92
+ if expr:
93
+ expr_widget.update(f"{expr}")
94
+ expr_widget.remove_class("empty")
95
+ else:
96
+ expr_widget.update("(no active filters)")
97
+ expr_widget.add_class("empty")
98
+
99
+ def action_maybe_close(self) -> None:
100
+ self.dismiss(True)
@@ -0,0 +1,103 @@
1
+ from typing import Any, Literal
2
+
3
+ from aioreactive import AsyncSubject
4
+
5
+ from jnav.filtering import Filter, FilterGroup, FilterNode, build_expression
6
+
7
+
8
+ class FilterProvider:
9
+ on_change: AsyncSubject[None]
10
+
11
+ def __init__(self) -> None:
12
+ self._root = FilterGroup()
13
+ self.on_change = AsyncSubject[None]()
14
+
15
+ @property
16
+ def root(self) -> FilterGroup:
17
+ return self._root
18
+
19
+ async def add_filter(
20
+ self,
21
+ expr: str,
22
+ label: str | None = None,
23
+ combine: Literal["and", "or"] = "and",
24
+ ) -> None:
25
+ """Add a new filter leaf to the root group."""
26
+ existing = {
27
+ leaf.expr for leaf in self._root.children if isinstance(leaf, Filter)
28
+ }
29
+ if expr not in existing:
30
+ leaf = Filter(expr=expr, label=label)
31
+ if combine == "or":
32
+ self._root.children.append(
33
+ FilterGroup(
34
+ operator="or",
35
+ children=[leaf],
36
+ )
37
+ )
38
+ else:
39
+ self._root.children.append(leaf)
40
+ await self.on_change.asend(None)
41
+
42
+ async def toggle_node(self, node: FilterNode) -> None:
43
+ node.enabled = not node.enabled
44
+ await self.on_change.asend(None)
45
+
46
+ async def toggle_negated(self, node: FilterNode) -> None:
47
+ node.negated = not node.negated
48
+ await self.on_change.asend(None)
49
+
50
+ async def add_group(
51
+ self,
52
+ parent: FilterGroup,
53
+ index: int | None = None,
54
+ ) -> None:
55
+ op = "or" if parent.operator == "and" else "and"
56
+ group = FilterGroup(operator=op)
57
+ if index is not None:
58
+ parent.children.insert(index + 1, group)
59
+ else:
60
+ parent.children.append(group)
61
+ await self.on_change.asend(None)
62
+
63
+ async def remove_node(self, node: FilterNode, parent: FilterGroup) -> None:
64
+ parent.children.remove(node)
65
+ await self.on_change.asend(None)
66
+
67
+ async def set_node_operator(
68
+ self,
69
+ node: FilterNode,
70
+ parent: FilterGroup,
71
+ ) -> None:
72
+ if isinstance(node, FilterGroup):
73
+ node.operator = "or" if node.operator == "and" else "and"
74
+ else:
75
+ idx = parent.children.index(node)
76
+ parent.children[idx] = FilterGroup(operator="or", children=[node])
77
+ await self.on_change.asend(None)
78
+
79
+ async def flatten_group(self, group: FilterGroup, parent: FilterGroup) -> None:
80
+ """Replace a group with a single leaf containing its compiled expression."""
81
+ expr = build_expression(
82
+ FilterGroup(operator=group.operator, children=group.children)
83
+ )
84
+ if expr is None:
85
+ return
86
+ idx = parent.children.index(group)
87
+ parent.children[idx] = Filter(expr=expr)
88
+ await self.on_change.asend(None)
89
+
90
+ async def edit_leaf(self, leaf: Filter, expr: str) -> None:
91
+ leaf.expr = expr
92
+ await self.on_change.asend(None)
93
+
94
+ async def clear_filters(self) -> None:
95
+ self._root.children.clear()
96
+ await self.on_change.asend(None)
97
+
98
+ def dump_root(self) -> dict[str, Any]:
99
+ return self._root.model_dump()
100
+
101
+ async def load_root(self, data: dict[str, Any]) -> None:
102
+ self._root = FilterGroup.model_validate(data)
103
+ await self.on_change.asend(None)