context-graph-viewer 0.1.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.
- context_graph_viewer/__init__.py +9 -0
- context_graph_viewer/app.py +146 -0
- context_graph_viewer/base.py +210 -0
- context_graph_viewer/cli.py +47 -0
- context_graph_viewer/dashboard.py +353 -0
- context_graph_viewer/failures.py +83 -0
- context_graph_viewer/fmt.py +107 -0
- context_graph_viewer/knowledge.py +454 -0
- context_graph_viewer/queries.py +555 -0
- context_graph_viewer/session.py +285 -0
- context_graph_viewer/source.py +77 -0
- context_graph_viewer-0.1.0.dist-info/METADATA +87 -0
- context_graph_viewer-0.1.0.dist-info/RECORD +16 -0
- context_graph_viewer-0.1.0.dist-info/WHEEL +4 -0
- context_graph_viewer-0.1.0.dist-info/entry_points.txt +2 -0
- context_graph_viewer-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
"""Read-only terminal viewer for a Context Graph (map #374).
|
|
2
|
+
|
|
3
|
+
Run it with ``memgraph-context-graph ui``. It reads the hooks' config file, polls Memgraph, and never writes.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from .app import ViewerApp
|
|
7
|
+
from .cli import main
|
|
8
|
+
|
|
9
|
+
__all__ = ["ViewerApp", "main"]
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
"""The viewer app: one reader per space, a scope shared by every view, and a polling tick."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import replace
|
|
6
|
+
from datetime import datetime, timezone
|
|
7
|
+
from typing import TYPE_CHECKING, ClassVar
|
|
8
|
+
|
|
9
|
+
from textual.app import App
|
|
10
|
+
from textual.binding import Binding
|
|
11
|
+
|
|
12
|
+
from . import fmt, queries
|
|
13
|
+
from .base import PolledScreen
|
|
14
|
+
from .dashboard import DashboardScreen
|
|
15
|
+
from .source import Identity, Reader, describe
|
|
16
|
+
|
|
17
|
+
if TYPE_CHECKING:
|
|
18
|
+
from collections.abc import Callable
|
|
19
|
+
|
|
20
|
+
POLL_SECONDS = 3.0
|
|
21
|
+
|
|
22
|
+
CSS = """
|
|
23
|
+
#left { width: 2fr; }
|
|
24
|
+
#sessions, #entities, #nb { height: 1fr; }
|
|
25
|
+
#side { width: 1fr; }
|
|
26
|
+
#banner { padding: 0 1; border: round $warning; height: auto; }
|
|
27
|
+
#filter { height: 3; }
|
|
28
|
+
.pane { border: round $primary-darken-2; padding: 0 1; height: 1fr; }
|
|
29
|
+
.pane:focus-within, .pane:focus { border: round $accent; }
|
|
30
|
+
#growth-pane { height: auto; }
|
|
31
|
+
#growth { height: 3; }
|
|
32
|
+
#timeline { height: 3fr; border: round $primary-darken-2; }
|
|
33
|
+
#timeline:focus { border: round $accent; }
|
|
34
|
+
#produced-pane { height: 1fr; max-height: 12; }
|
|
35
|
+
#main { width: 3fr; }
|
|
36
|
+
#details-pane { width: 2fr; }
|
|
37
|
+
#entities { width: 3fr; border: round $primary-darken-2; }
|
|
38
|
+
#entities:focus, #sessions:focus { border: round $accent; }
|
|
39
|
+
#sessions { border: round $primary-darken-2; }
|
|
40
|
+
#prov-pane { width: 2fr; }
|
|
41
|
+
#nbmain { width: 3fr; }
|
|
42
|
+
#nb { border: round $primary-darken-2; }
|
|
43
|
+
#nb:focus { border: round $accent; }
|
|
44
|
+
#evidence-pane { width: 2fr; }
|
|
45
|
+
#view-status { height: 1; padding: 0 1; background: $panel; }
|
|
46
|
+
ProjectPicker { align: center middle; }
|
|
47
|
+
#picker { width: 80%; max-width: 100; height: 70%; border: round $accent; background: $surface; padding: 0 1; }
|
|
48
|
+
#picker .title { text-style: bold; padding: 0 0 1 0; }
|
|
49
|
+
"""
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class ViewerApp(App[None]):
|
|
53
|
+
"""Read-only Context Graph viewer. Opens on the dashboard and polls the active view."""
|
|
54
|
+
|
|
55
|
+
TITLE = "Context Graph"
|
|
56
|
+
CSS = CSS
|
|
57
|
+
BINDINGS: ClassVar = [
|
|
58
|
+
Binding("a", "widen_scope", "scope"),
|
|
59
|
+
Binding("w", "next_space", "space", show=False),
|
|
60
|
+
Binding("question_mark", "help", "keys"),
|
|
61
|
+
Binding("q", "quit", "quit"),
|
|
62
|
+
]
|
|
63
|
+
|
|
64
|
+
def __init__(
|
|
65
|
+
self,
|
|
66
|
+
identity: Identity,
|
|
67
|
+
*,
|
|
68
|
+
poll_seconds: float = POLL_SECONDS,
|
|
69
|
+
clock: Callable[[], datetime] | None = None,
|
|
70
|
+
reader_factory: Callable[..., Reader] = Reader,
|
|
71
|
+
) -> None:
|
|
72
|
+
"""Args:
|
|
73
|
+
identity: Who is looking, and which spaces they may read (personal first).
|
|
74
|
+
poll_seconds: How often the active view reloads; 0 turns polling off (tests drive it by hand).
|
|
75
|
+
clock: The current time, for "3m ago" text; tests pin it.
|
|
76
|
+
reader_factory: Builds the reader for a space.
|
|
77
|
+
"""
|
|
78
|
+
super().__init__()
|
|
79
|
+
self.identity = identity
|
|
80
|
+
self.scope = queries.Scope(user_id=identity.user_id)
|
|
81
|
+
self.poll_seconds = poll_seconds
|
|
82
|
+
self._clock = clock or (lambda: datetime.now(timezone.utc))
|
|
83
|
+
self._reader_factory = reader_factory
|
|
84
|
+
self.space_index = 0
|
|
85
|
+
self.reader = reader_factory(identity.spaces[0])
|
|
86
|
+
|
|
87
|
+
def now(self) -> datetime:
|
|
88
|
+
"""The current time, from the injected clock."""
|
|
89
|
+
return self._clock()
|
|
90
|
+
|
|
91
|
+
def on_mount(self) -> None:
|
|
92
|
+
"""Open the dashboard and start polling."""
|
|
93
|
+
self.push_screen(DashboardScreen())
|
|
94
|
+
if self.poll_seconds > 0:
|
|
95
|
+
self.set_interval(self.poll_seconds, self.poll_active)
|
|
96
|
+
|
|
97
|
+
def on_unmount(self) -> None:
|
|
98
|
+
"""Close the connection."""
|
|
99
|
+
self.reader.close()
|
|
100
|
+
|
|
101
|
+
def poll_active(self) -> None:
|
|
102
|
+
"""Reload the view on top; views underneath catch up when they're shown again."""
|
|
103
|
+
if isinstance(self.screen, PolledScreen):
|
|
104
|
+
self.screen.action_poll()
|
|
105
|
+
|
|
106
|
+
def context_line(self) -> str:
|
|
107
|
+
"""Where, as whom, and what scope: shown under every view's title."""
|
|
108
|
+
space = self.identity.spaces[self.space_index]
|
|
109
|
+
user = self.identity.user_id or "no user configured"
|
|
110
|
+
parts = [f"{describe(space)}", f"{space.name} space", f"user {user}", f"scope {self.scope.mode}"]
|
|
111
|
+
if self.scope.project:
|
|
112
|
+
parts.append(f"project {fmt.project_name(self.scope.project)}")
|
|
113
|
+
return " · ".join(parts)
|
|
114
|
+
|
|
115
|
+
def _rescoped(self) -> None:
|
|
116
|
+
for screen in self.screen_stack:
|
|
117
|
+
if isinstance(screen, PolledScreen):
|
|
118
|
+
screen.refresh_titles()
|
|
119
|
+
self.poll_active()
|
|
120
|
+
|
|
121
|
+
def action_widen_scope(self) -> None:
|
|
122
|
+
"""Cycle the scope: your sessions, plus unattributed ones, everyone's."""
|
|
123
|
+
self.scope = self.scope.widened()
|
|
124
|
+
self._rescoped()
|
|
125
|
+
|
|
126
|
+
def set_project(self, project: str | None) -> None:
|
|
127
|
+
"""Narrow every view to one working directory, or None for all."""
|
|
128
|
+
self.scope = replace(self.scope, project=project)
|
|
129
|
+
self._rescoped()
|
|
130
|
+
|
|
131
|
+
def action_next_space(self) -> None:
|
|
132
|
+
"""Enterprise: switch to the next space this login may read; Community has only one."""
|
|
133
|
+
if len(self.identity.spaces) < 2:
|
|
134
|
+
self.notify("Only one space: shared spaces need the Enterprise edition.", timeout=3)
|
|
135
|
+
return
|
|
136
|
+
self.reader.close()
|
|
137
|
+
self.space_index = (self.space_index + 1) % len(self.identity.spaces)
|
|
138
|
+
self.reader = self._reader_factory(self.identity.spaces[self.space_index])
|
|
139
|
+
self._rescoped()
|
|
140
|
+
|
|
141
|
+
def action_help(self) -> None:
|
|
142
|
+
"""Toggle the key help panel."""
|
|
143
|
+
if self.screen.query("HelpPanel"):
|
|
144
|
+
self.action_hide_help_panel()
|
|
145
|
+
else:
|
|
146
|
+
self.action_show_help_panel()
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
"""What every view shares: polling with its own error state, pane navigation, and in-place table updates."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import TYPE_CHECKING, Any, ClassVar, Generic, TypeVar, cast
|
|
6
|
+
|
|
7
|
+
from rich.text import Text
|
|
8
|
+
from textual.binding import Binding
|
|
9
|
+
from textual.containers import VerticalScroll
|
|
10
|
+
from textual.screen import Screen
|
|
11
|
+
from textual.widgets import DataTable, Footer, Header, Static
|
|
12
|
+
from textual.worker import get_current_worker
|
|
13
|
+
|
|
14
|
+
from . import fmt
|
|
15
|
+
|
|
16
|
+
if TYPE_CHECKING:
|
|
17
|
+
from collections.abc import Iterable, Sequence
|
|
18
|
+
from datetime import datetime
|
|
19
|
+
|
|
20
|
+
from textual.app import ComposeResult
|
|
21
|
+
from textual.widget import Widget
|
|
22
|
+
|
|
23
|
+
from .app import ViewerApp
|
|
24
|
+
from .source import Reader
|
|
25
|
+
|
|
26
|
+
T = TypeVar("T")
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class Pane(VerticalScroll):
|
|
30
|
+
"""A titled, focusable, scrollable pane that ``f`` can fill the screen with."""
|
|
31
|
+
|
|
32
|
+
ALLOW_MAXIMIZE = True
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class ViewStatus(Static):
|
|
36
|
+
"""One line under a view: live, or the error from this view's last poll and how old its data is."""
|
|
37
|
+
|
|
38
|
+
def show(self, *, error: str | None, last_ok: datetime | None, now: datetime, note: str = "") -> None:
|
|
39
|
+
"""Redraw for the view's latest poll outcome."""
|
|
40
|
+
line = Text()
|
|
41
|
+
if error:
|
|
42
|
+
line.append("⚠ ", style="bold red").append(fmt.clip(error, 160), style="red")
|
|
43
|
+
line.append(f" · data from {fmt.ago(last_ok, now)}" if last_ok else " · no data yet", style="dim")
|
|
44
|
+
elif last_ok is None:
|
|
45
|
+
line.append("… loading", style="dim")
|
|
46
|
+
else:
|
|
47
|
+
line.append("● ", style="green").append(f"live · updated {fmt.ago(last_ok, now)}", style="dim")
|
|
48
|
+
if note:
|
|
49
|
+
line.append(f" · {note}", style="dim")
|
|
50
|
+
self.update(line)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class PolledScreen(Screen[None], Generic[T]):
|
|
54
|
+
"""A view that loads its data off the UI thread on every poll and keeps its own error state.
|
|
55
|
+
|
|
56
|
+
Subclasses implement :meth:`fetch` (runs in a worker thread, may raise) and :meth:`show` (runs on the UI
|
|
57
|
+
thread with the fetched data). A failed poll leaves the last good data on screen and says so on this view's
|
|
58
|
+
status line; another view's successful poll can't hide it.
|
|
59
|
+
"""
|
|
60
|
+
|
|
61
|
+
#: Selectors of the panes number keys jump between, in order.
|
|
62
|
+
PANES: ClassVar[Sequence[str]] = ()
|
|
63
|
+
BINDINGS: ClassVar = [
|
|
64
|
+
*(Binding(str(n), f"pane({n})", f"pane {n}", show=False) for n in range(1, 10)),
|
|
65
|
+
Binding("f", "fullscreen", "fullscreen"),
|
|
66
|
+
Binding("r", "poll", "refresh", show=False),
|
|
67
|
+
]
|
|
68
|
+
|
|
69
|
+
def __init__(self) -> None:
|
|
70
|
+
super().__init__()
|
|
71
|
+
self.data: T | None = None
|
|
72
|
+
self.error: str | None = None
|
|
73
|
+
self.last_ok: datetime | None = None
|
|
74
|
+
|
|
75
|
+
@property
|
|
76
|
+
def viewer(self) -> ViewerApp:
|
|
77
|
+
"""The app, typed."""
|
|
78
|
+
return cast("ViewerApp", self.app)
|
|
79
|
+
|
|
80
|
+
def compose(self) -> ComposeResult:
|
|
81
|
+
"""Header, the view's own body, its status line, and the key footer."""
|
|
82
|
+
yield Header()
|
|
83
|
+
yield from self.compose_body()
|
|
84
|
+
yield ViewStatus(id="view-status")
|
|
85
|
+
yield Footer()
|
|
86
|
+
|
|
87
|
+
def compose_body(self) -> Iterable[Widget]:
|
|
88
|
+
"""The view's widgets between the header and the status line."""
|
|
89
|
+
return ()
|
|
90
|
+
|
|
91
|
+
def fetch(self, reader: Reader) -> T:
|
|
92
|
+
"""Load this view's data. Runs in a worker thread."""
|
|
93
|
+
raise NotImplementedError
|
|
94
|
+
|
|
95
|
+
def show(self, data: T) -> None:
|
|
96
|
+
"""Draw *data*. Runs on the UI thread."""
|
|
97
|
+
raise NotImplementedError
|
|
98
|
+
|
|
99
|
+
def status_note(self) -> str:
|
|
100
|
+
"""Extra context for the status line (e.g. counts)."""
|
|
101
|
+
return ""
|
|
102
|
+
|
|
103
|
+
def on_mount(self) -> None:
|
|
104
|
+
"""Load immediately rather than waiting for the first poll tick."""
|
|
105
|
+
self.refresh_titles()
|
|
106
|
+
self.draw_status()
|
|
107
|
+
self.action_poll()
|
|
108
|
+
|
|
109
|
+
def on_screen_resume(self) -> None:
|
|
110
|
+
"""Coming back along the breadcrumb: catch up at once."""
|
|
111
|
+
self.refresh_titles()
|
|
112
|
+
if self.is_mounted:
|
|
113
|
+
self.action_poll()
|
|
114
|
+
|
|
115
|
+
def refresh_titles(self) -> None:
|
|
116
|
+
"""Set the header from :meth:`title_text` and the viewer's context line."""
|
|
117
|
+
self.title = self.title_text()
|
|
118
|
+
self.sub_title = self.viewer.context_line()
|
|
119
|
+
|
|
120
|
+
def title_text(self) -> str:
|
|
121
|
+
"""This view's breadcrumb title."""
|
|
122
|
+
return "Context Graph"
|
|
123
|
+
|
|
124
|
+
def action_poll(self) -> None:
|
|
125
|
+
"""Start a background load; a newer poll replaces one still running."""
|
|
126
|
+
reader = self.viewer.reader
|
|
127
|
+
self.run_worker(lambda: self._load(reader), thread=True, exclusive=True, group="poll")
|
|
128
|
+
|
|
129
|
+
def _load(self, reader: Reader) -> None:
|
|
130
|
+
worker = get_current_worker()
|
|
131
|
+
try:
|
|
132
|
+
data = self.fetch(reader)
|
|
133
|
+
except Exception as exc: # A poll must never reach Textual's handler, which would end the app (#382).
|
|
134
|
+
if not worker.is_cancelled:
|
|
135
|
+
self.app.call_from_thread(self._failed, f"{type(exc).__name__}: {exc}")
|
|
136
|
+
return
|
|
137
|
+
if not worker.is_cancelled:
|
|
138
|
+
self.app.call_from_thread(self._loaded, data)
|
|
139
|
+
|
|
140
|
+
def _loaded(self, data: T) -> None:
|
|
141
|
+
self.data, self.error, self.last_ok = data, None, self.viewer.now()
|
|
142
|
+
self.show(data)
|
|
143
|
+
self.refresh_titles()
|
|
144
|
+
self.draw_status()
|
|
145
|
+
|
|
146
|
+
def _failed(self, error: str) -> None:
|
|
147
|
+
self.error = error
|
|
148
|
+
self.draw_status()
|
|
149
|
+
|
|
150
|
+
def draw_status(self) -> None:
|
|
151
|
+
"""Redraw this view's status line."""
|
|
152
|
+
for status in self.query(ViewStatus):
|
|
153
|
+
status.show(error=self.error, last_ok=self.last_ok, now=self.viewer.now(), note=self.status_note())
|
|
154
|
+
|
|
155
|
+
def action_pane(self, number: int) -> None:
|
|
156
|
+
"""Focus the *number*-th pane."""
|
|
157
|
+
if 0 < number <= len(self.PANES):
|
|
158
|
+
for widget in self.query(self.PANES[number - 1]):
|
|
159
|
+
widget.focus()
|
|
160
|
+
return
|
|
161
|
+
|
|
162
|
+
def action_fullscreen(self) -> None:
|
|
163
|
+
"""Toggle the focused pane filling the screen."""
|
|
164
|
+
if self.maximized is not None:
|
|
165
|
+
self.minimize()
|
|
166
|
+
elif self.focused is not None:
|
|
167
|
+
self.maximize(self.focused)
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def sync_table(table: DataTable, columns: Sequence[tuple[str, str]], rows: Sequence[tuple[str, list[Any]]]) -> None:
|
|
171
|
+
"""Make *table* show *rows* without losing the reader's place.
|
|
172
|
+
|
|
173
|
+
Cells change in place when the same rows arrive in the same order, so the cursor and scroll stay put. When
|
|
174
|
+
rows are added, removed or reordered the table is rebuilt, and the cursor follows the row it was on.
|
|
175
|
+
|
|
176
|
+
Args:
|
|
177
|
+
columns: ``(key, label)`` per column.
|
|
178
|
+
rows: ``(row_key, cells)`` in display order; row keys must be unique.
|
|
179
|
+
"""
|
|
180
|
+
column_keys = [key for key, _ in columns]
|
|
181
|
+
if [column.key.value for column in table.columns.values()] != column_keys:
|
|
182
|
+
table.clear(columns=True)
|
|
183
|
+
for key, label in columns:
|
|
184
|
+
table.add_column(label, key=key)
|
|
185
|
+
current = [row.value for row in table.rows]
|
|
186
|
+
wanted = [key for key, _ in rows]
|
|
187
|
+
if current == wanted:
|
|
188
|
+
for key, cells in rows:
|
|
189
|
+
for column, cell in zip(column_keys, cells, strict=True):
|
|
190
|
+
if table.get_cell(key, column) != cell:
|
|
191
|
+
table.update_cell(key, column, cell)
|
|
192
|
+
return
|
|
193
|
+
selected = cursor_key(table)
|
|
194
|
+
scroll_y = table.scroll_y
|
|
195
|
+
table.clear()
|
|
196
|
+
for key, cells in rows:
|
|
197
|
+
table.add_row(*cells, key=key)
|
|
198
|
+
if selected in wanted:
|
|
199
|
+
table.move_cursor(row=wanted.index(selected), animate=False, scroll=False)
|
|
200
|
+
table.scroll_to(y=scroll_y, animate=False)
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def cursor_key(table: DataTable) -> str | None:
|
|
204
|
+
"""The key of the row under *table*'s cursor, or None."""
|
|
205
|
+
if not table.row_count:
|
|
206
|
+
return None
|
|
207
|
+
try:
|
|
208
|
+
return table.coordinate_to_cell_key(table.cursor_coordinate).row_key.value
|
|
209
|
+
except Exception: # The cursor sits outside the rows while the table is empty or shrinking.
|
|
210
|
+
return None
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
"""``memgraph-context-graph ui`` / ``context-graph-viewer``: open the viewer on the configured graph."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import sys
|
|
7
|
+
from importlib import metadata
|
|
8
|
+
from typing import TYPE_CHECKING
|
|
9
|
+
|
|
10
|
+
if TYPE_CHECKING:
|
|
11
|
+
from collections.abc import Sequence
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def main(argv: Sequence[str] | None = None) -> int:
|
|
15
|
+
"""Parse arguments, resolve identity from the config file, and run the viewer until the user quits.
|
|
16
|
+
|
|
17
|
+
Returns:
|
|
18
|
+
0 on a normal exit, 2 when the config names an edition that can't be resolved.
|
|
19
|
+
"""
|
|
20
|
+
parser = argparse.ArgumentParser(
|
|
21
|
+
prog="memgraph-context-graph ui",
|
|
22
|
+
description=(
|
|
23
|
+
"Read-only terminal viewer for your Context Graph: capture health, pipeline state, sessions and the "
|
|
24
|
+
"knowledge graph. Reads the same config file as the hooks (CONTEXT_GRAPH_CONFIG or "
|
|
25
|
+
"~/.config/context-graph/config.toml)."
|
|
26
|
+
),
|
|
27
|
+
)
|
|
28
|
+
parser.add_argument("--poll", type=float, default=3.0, help="Seconds between refreshes (default 3).")
|
|
29
|
+
parser.add_argument("--version", action="version", version=metadata.version("context-graph-viewer"))
|
|
30
|
+
args = parser.parse_args(argv)
|
|
31
|
+
|
|
32
|
+
from memgraph_context_graph.spaces import SpaceError
|
|
33
|
+
|
|
34
|
+
from .app import ViewerApp
|
|
35
|
+
from .source import resolve_identity
|
|
36
|
+
|
|
37
|
+
try:
|
|
38
|
+
identity = resolve_identity()
|
|
39
|
+
except SpaceError as exc:
|
|
40
|
+
print(f"memgraph-context-graph ui: {exc}", file=sys.stderr)
|
|
41
|
+
return 2
|
|
42
|
+
ViewerApp(identity, poll_seconds=max(args.poll, 0.5)).run()
|
|
43
|
+
return 0
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
if __name__ == "__main__":
|
|
47
|
+
raise SystemExit(main())
|