jstdata 0.2.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.
- jstdata/__init__.py +13 -0
- jstdata/agent_guide.py +530 -0
- jstdata/cli.py +682 -0
- jstdata/client.py +543 -0
- jstdata/models.py +161 -0
- jstdata/session.py +251 -0
- jstdata/utils.py +103 -0
- jstdata/workflows/__init__.py +88 -0
- jstdata/workflows/base.py +356 -0
- jstdata/workflows/bundled/__init__.py +1 -0
- jstdata/workflows/bundled/tutorial.yaml +14 -0
- jstdata/workflows/bundled/tutorial_script.yaml +45 -0
- jstdata/workflows/chart.py +357 -0
- jstdata/workflows/console.py +560 -0
- jstdata/workflows/discover.py +502 -0
- jstdata/workflows/export.py +171 -0
- jstdata/workflows/find.py +386 -0
- jstdata/workflows/host.py +255 -0
- jstdata/workflows/rank.py +658 -0
- jstdata/workflows/session_manager.py +277 -0
- jstdata/workflows/store.py +308 -0
- jstdata/workflows/tutorial.py +163 -0
- jstdata/workflows/tutorial_host.py +178 -0
- jstdata-0.2.0.dist-info/METADATA +227 -0
- jstdata-0.2.0.dist-info/RECORD +28 -0
- jstdata-0.2.0.dist-info/WHEEL +4 -0
- jstdata-0.2.0.dist-info/entry_points.txt +3 -0
- jstdata-0.2.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,502 @@
|
|
|
1
|
+
"""Discover step — metric candidates for session entities, with a preview chart."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
from typing import Optional
|
|
7
|
+
|
|
8
|
+
from textual import on, work
|
|
9
|
+
from textual.app import ComposeResult
|
|
10
|
+
from textual.binding import Binding
|
|
11
|
+
from textual.containers import Horizontal, ScrollableContainer, Vertical
|
|
12
|
+
from textual.screen import Screen
|
|
13
|
+
from textual.widgets import Input, Label, ListItem, ListView, Static
|
|
14
|
+
|
|
15
|
+
from ..client import JSTDataClient
|
|
16
|
+
from ..models import Metric, TimeSeries
|
|
17
|
+
from ..session import Session
|
|
18
|
+
from .base import StepArgument, StepBinding, StepSpec, label_for, register
|
|
19
|
+
from .chart import LABEL_WIDTH, pick_frequency, pick_series_per_entity, render_preview
|
|
20
|
+
|
|
21
|
+
PAGE_SIZE = 20
|
|
22
|
+
PREVIEW_TAIL = 20
|
|
23
|
+
MODES = ("union", "intersect")
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class EntityRow(ListItem):
|
|
27
|
+
can_focus = False
|
|
28
|
+
|
|
29
|
+
def __init__(self, entity_id: str, caption: str) -> None:
|
|
30
|
+
super().__init__()
|
|
31
|
+
self.entity_id = entity_id
|
|
32
|
+
self.caption = caption
|
|
33
|
+
|
|
34
|
+
def compose(self) -> ComposeResult:
|
|
35
|
+
yield Label(self.caption)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class MetricRow(ListItem):
|
|
39
|
+
def __init__(self, metric: Metric, staged: bool = False) -> None:
|
|
40
|
+
super().__init__()
|
|
41
|
+
self.metric = metric
|
|
42
|
+
self.staged = staged
|
|
43
|
+
|
|
44
|
+
def compose(self) -> ComposeResult:
|
|
45
|
+
mark = "● " if self.staged else " "
|
|
46
|
+
yield Label(f"{mark}{self.metric.name} [{self.metric.id}]")
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class DiscoverScreen(Screen):
|
|
50
|
+
"""Entities roster, candidate metrics, and a recent-observation bar preview."""
|
|
51
|
+
|
|
52
|
+
BINDINGS = [
|
|
53
|
+
Binding("slash", "focus_filter", "Filter metrics", key_display="/"),
|
|
54
|
+
Binding("escape", "leave_filter", "Leave filter", show=False),
|
|
55
|
+
Binding("tab", "cycle_panes", "Switch list / preview", priority=True),
|
|
56
|
+
Binding(
|
|
57
|
+
"shift+tab",
|
|
58
|
+
"cycle_panes",
|
|
59
|
+
"Switch panes reverse",
|
|
60
|
+
show=False,
|
|
61
|
+
priority=True,
|
|
62
|
+
),
|
|
63
|
+
Binding("enter", "preview_metric", "Preview metric", priority=True),
|
|
64
|
+
Binding("space", "add_metric", "Add metric to session", priority=True),
|
|
65
|
+
Binding("j", "cursor_down", "Move highlight down", show=False),
|
|
66
|
+
Binding("k", "cursor_up", "Move highlight up", show=False),
|
|
67
|
+
]
|
|
68
|
+
|
|
69
|
+
DEFAULT_CSS = """
|
|
70
|
+
#discover-body { height: 1fr; }
|
|
71
|
+
|
|
72
|
+
.pane-container {
|
|
73
|
+
border: solid #222;
|
|
74
|
+
background: #0f0f0f;
|
|
75
|
+
}
|
|
76
|
+
#top-row { height: 55%; }
|
|
77
|
+
#entities-pane { width: 32%; }
|
|
78
|
+
#metrics-pane { width: 1fr; }
|
|
79
|
+
#preview-pane { height: 1fr; border-top: solid #333; }
|
|
80
|
+
|
|
81
|
+
.pane-header {
|
|
82
|
+
background: #1a1a1a;
|
|
83
|
+
color: #4ade80;
|
|
84
|
+
padding: 0 1;
|
|
85
|
+
text-style: bold;
|
|
86
|
+
height: 1;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
#entities-list, #metrics-list {
|
|
90
|
+
height: 1fr;
|
|
91
|
+
background: #0f0f0f;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
ListView > ListItem,
|
|
95
|
+
EntityRow,
|
|
96
|
+
MetricRow {
|
|
97
|
+
height: 1;
|
|
98
|
+
min-height: 1;
|
|
99
|
+
max-height: 1;
|
|
100
|
+
width: 100%;
|
|
101
|
+
padding: 0 1;
|
|
102
|
+
overflow: hidden;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
MetricRow:focus,
|
|
106
|
+
MetricRow.-highlighted {
|
|
107
|
+
background: #1a3a1a;
|
|
108
|
+
color: #4ade80;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
#empty-prompt {
|
|
112
|
+
height: 1fr;
|
|
113
|
+
padding: 2 2;
|
|
114
|
+
color: #4ade80;
|
|
115
|
+
text-style: bold;
|
|
116
|
+
background: #111111;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
#metrics-filter {
|
|
120
|
+
height: 3;
|
|
121
|
+
background: transparent;
|
|
122
|
+
border: none;
|
|
123
|
+
border-bottom: solid #333;
|
|
124
|
+
padding: 0 1;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
#metrics-status {
|
|
128
|
+
height: 1;
|
|
129
|
+
color: #888;
|
|
130
|
+
padding: 0 1;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
#preview-scroll { height: 1fr; background: #0f0f0f; }
|
|
134
|
+
#preview-chart {
|
|
135
|
+
width: 100%;
|
|
136
|
+
height: auto;
|
|
137
|
+
padding: 0 1;
|
|
138
|
+
color: #e0e0e0;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
#cmd-bar {
|
|
142
|
+
height: 3;
|
|
143
|
+
background: #0f0f0f;
|
|
144
|
+
border-top: solid #4ade80;
|
|
145
|
+
align: left middle;
|
|
146
|
+
padding: 0 1;
|
|
147
|
+
}
|
|
148
|
+
#help-hint { color: #888; }
|
|
149
|
+
"""
|
|
150
|
+
|
|
151
|
+
def __init__(self, client: JSTDataClient, session: Session, mode: str = "union"):
|
|
152
|
+
super().__init__()
|
|
153
|
+
self.client = client
|
|
154
|
+
self.session = session
|
|
155
|
+
self.mode = mode if mode in MODES else "union"
|
|
156
|
+
self.filter_query = ""
|
|
157
|
+
self.page_offset = 0
|
|
158
|
+
self.has_more = False
|
|
159
|
+
self.loading_more = False
|
|
160
|
+
self.search_task: asyncio.Task[None] | None = None
|
|
161
|
+
self._entity_snapshot: tuple[str, ...] = ()
|
|
162
|
+
self._metrics: list[Metric] = []
|
|
163
|
+
|
|
164
|
+
def compose(self) -> ComposeResult:
|
|
165
|
+
with Vertical(id="discover-body"):
|
|
166
|
+
with Horizontal(id="top-row"):
|
|
167
|
+
with Vertical(classes="pane-container", id="entities-pane"):
|
|
168
|
+
yield Label("ENTITIES // SESSION", classes="pane-header")
|
|
169
|
+
yield ListView(id="entities-list")
|
|
170
|
+
yield Static(
|
|
171
|
+
"No entities in session.\n\n"
|
|
172
|
+
"Press [bold]f[/bold] to find entities and add them.\n"
|
|
173
|
+
"Metrics in this step are generated from that set.",
|
|
174
|
+
id="empty-prompt",
|
|
175
|
+
markup=True,
|
|
176
|
+
)
|
|
177
|
+
with Vertical(classes="pane-container", id="metrics-pane"):
|
|
178
|
+
yield Label(
|
|
179
|
+
"METRICS // CANDIDATES",
|
|
180
|
+
classes="pane-header",
|
|
181
|
+
id="metrics-header",
|
|
182
|
+
)
|
|
183
|
+
yield Input(
|
|
184
|
+
placeholder="/ filter metrics in this set…",
|
|
185
|
+
id="metrics-filter",
|
|
186
|
+
)
|
|
187
|
+
yield ListView(id="metrics-list")
|
|
188
|
+
yield Label("", id="metrics-status")
|
|
189
|
+
with Vertical(classes="pane-container", id="preview-pane"):
|
|
190
|
+
yield Label(
|
|
191
|
+
"PREVIEW // LAST 20 OBS",
|
|
192
|
+
classes="pane-header",
|
|
193
|
+
id="preview-header",
|
|
194
|
+
)
|
|
195
|
+
with ScrollableContainer(id="preview-scroll"):
|
|
196
|
+
yield Static(
|
|
197
|
+
"Highlight a metric and press Enter to preview.",
|
|
198
|
+
id="preview-chart",
|
|
199
|
+
)
|
|
200
|
+
with Horizontal(id="cmd-bar"):
|
|
201
|
+
yield Label("", id="help-hint")
|
|
202
|
+
|
|
203
|
+
def on_mount(self) -> None:
|
|
204
|
+
self.query_one("#entities-list", ListView).can_focus = False
|
|
205
|
+
self._refresh_help_hint()
|
|
206
|
+
self._sync_entities(force=True)
|
|
207
|
+
self.query_one("#metrics-list", ListView).focus()
|
|
208
|
+
|
|
209
|
+
def on_screen_resume(self) -> None:
|
|
210
|
+
self._sync_entities()
|
|
211
|
+
|
|
212
|
+
def _labels(self) -> dict[str, str]:
|
|
213
|
+
return getattr(self.app, "labels", {})
|
|
214
|
+
|
|
215
|
+
def _refresh_help_hint(self) -> None:
|
|
216
|
+
n = getattr(self.app, "step_count", 1)
|
|
217
|
+
i = getattr(self.app, "step_index", 0) + 1
|
|
218
|
+
parts = [
|
|
219
|
+
f"discover {i}/{n}",
|
|
220
|
+
f"mode {self.mode}",
|
|
221
|
+
"[bold]/[/bold] filter",
|
|
222
|
+
"[bold]enter[/bold] preview",
|
|
223
|
+
"[bold]space[/bold] add",
|
|
224
|
+
"[bold]tab[/bold] panes",
|
|
225
|
+
"[bold]j/k[/bold] move",
|
|
226
|
+
"[bold]s[/bold] session",
|
|
227
|
+
"[bold]f[/bold] find",
|
|
228
|
+
"[bold]e[/bold] export",
|
|
229
|
+
"[bold]n[/bold]/[bold]p[/bold]",
|
|
230
|
+
"[bold]q[/bold] quit",
|
|
231
|
+
"[bold]?[/bold]",
|
|
232
|
+
]
|
|
233
|
+
self.query_one("#help-hint").update(" // ".join(parts))
|
|
234
|
+
|
|
235
|
+
def _sync_entities(self, force: bool = False) -> None:
|
|
236
|
+
snapshot = tuple(self.session.entity)
|
|
237
|
+
if not force and snapshot == self._entity_snapshot:
|
|
238
|
+
return
|
|
239
|
+
self._entity_snapshot = snapshot
|
|
240
|
+
empty = self.query_one("#empty-prompt")
|
|
241
|
+
roster = self.query_one("#entities-list", ListView)
|
|
242
|
+
roster.clear()
|
|
243
|
+
if not snapshot:
|
|
244
|
+
empty.display = True
|
|
245
|
+
roster.display = False
|
|
246
|
+
self.query_one("#metrics-list", ListView).clear()
|
|
247
|
+
self._metrics = []
|
|
248
|
+
self.query_one("#metrics-status").update(
|
|
249
|
+
"Add entities to generate metrics."
|
|
250
|
+
)
|
|
251
|
+
return
|
|
252
|
+
empty.display = False
|
|
253
|
+
roster.display = True
|
|
254
|
+
labels = self._labels()
|
|
255
|
+
for eid in snapshot:
|
|
256
|
+
roster.append(EntityRow(eid, label_for(labels, eid)))
|
|
257
|
+
self.page_offset = 0
|
|
258
|
+
self._metrics = []
|
|
259
|
+
self.query_one("#metrics-list", ListView).clear()
|
|
260
|
+
self.load_metrics(reset=True)
|
|
261
|
+
|
|
262
|
+
@work(exclusive=True, group="metrics")
|
|
263
|
+
async def load_metrics(self, reset: bool = False) -> None:
|
|
264
|
+
if not self.session.entity:
|
|
265
|
+
self.loading_more = False
|
|
266
|
+
return
|
|
267
|
+
if reset:
|
|
268
|
+
self.page_offset = 0
|
|
269
|
+
self.has_more = False
|
|
270
|
+
status = self.query_one("#metrics-status")
|
|
271
|
+
status.update("Loading metrics…")
|
|
272
|
+
query = self.filter_query.strip() or None
|
|
273
|
+
try:
|
|
274
|
+
metrics = await asyncio.to_thread(
|
|
275
|
+
self.client.search_metrics,
|
|
276
|
+
query,
|
|
277
|
+
entity=list(self.session.entity),
|
|
278
|
+
mode=self.mode,
|
|
279
|
+
limit=PAGE_SIZE,
|
|
280
|
+
offset=self.page_offset,
|
|
281
|
+
)
|
|
282
|
+
except Exception as e:
|
|
283
|
+
status.update(f"Error: {e}")
|
|
284
|
+
self.loading_more = False
|
|
285
|
+
return
|
|
286
|
+
if reset:
|
|
287
|
+
self._metrics = []
|
|
288
|
+
self.query_one("#metrics-list", ListView).clear()
|
|
289
|
+
self._metrics.extend(metrics)
|
|
290
|
+
self.has_more = len(metrics) == PAGE_SIZE
|
|
291
|
+
list_view = self.query_one("#metrics-list", ListView)
|
|
292
|
+
staged = set(self.session.metric)
|
|
293
|
+
for metric in metrics:
|
|
294
|
+
list_view.append(MetricRow(metric, staged=metric.id in staged))
|
|
295
|
+
shown = len(self._metrics)
|
|
296
|
+
extra = "+" if self.has_more else ""
|
|
297
|
+
qlabel = f"“{query}”" if query else "all"
|
|
298
|
+
status.update(f"{shown}{extra} · {qlabel} · {self.mode}")
|
|
299
|
+
self.query_one("#metrics-header").update(f"METRICS // {self.mode.upper()}")
|
|
300
|
+
self.loading_more = False
|
|
301
|
+
|
|
302
|
+
def action_focus_filter(self) -> None:
|
|
303
|
+
filt = self.query_one("#metrics-filter", Input)
|
|
304
|
+
filt.focus()
|
|
305
|
+
|
|
306
|
+
def action_leave_filter(self) -> None:
|
|
307
|
+
filt = self.query_one("#metrics-filter", Input)
|
|
308
|
+
if self.focused is filt:
|
|
309
|
+
self.query_one("#metrics-list", ListView).focus()
|
|
310
|
+
|
|
311
|
+
def action_cycle_panes(self) -> None:
|
|
312
|
+
if isinstance(self.focused, Input):
|
|
313
|
+
self.query_one("#metrics-list", ListView).focus()
|
|
314
|
+
return
|
|
315
|
+
if self.focused is self.query_one("#metrics-list"):
|
|
316
|
+
self.query_one("#preview-scroll").focus()
|
|
317
|
+
else:
|
|
318
|
+
self.query_one("#metrics-list", ListView).focus()
|
|
319
|
+
|
|
320
|
+
def _preview_is_focused(self) -> bool:
|
|
321
|
+
node = self.focused
|
|
322
|
+
while node is not None:
|
|
323
|
+
if getattr(node, "id", None) == "preview-scroll":
|
|
324
|
+
return True
|
|
325
|
+
node = getattr(node, "parent", None)
|
|
326
|
+
return False
|
|
327
|
+
|
|
328
|
+
def action_cursor_down(self) -> None:
|
|
329
|
+
if isinstance(self.focused, Input):
|
|
330
|
+
return
|
|
331
|
+
if self._preview_is_focused():
|
|
332
|
+
self.query_one("#preview-scroll", ScrollableContainer).scroll_relative(
|
|
333
|
+
y=1, animate=False
|
|
334
|
+
)
|
|
335
|
+
return
|
|
336
|
+
self.query_one("#metrics-list", ListView).action_cursor_down()
|
|
337
|
+
|
|
338
|
+
def action_cursor_up(self) -> None:
|
|
339
|
+
if isinstance(self.focused, Input):
|
|
340
|
+
return
|
|
341
|
+
if self._preview_is_focused():
|
|
342
|
+
self.query_one("#preview-scroll", ScrollableContainer).scroll_relative(
|
|
343
|
+
y=-1, animate=False
|
|
344
|
+
)
|
|
345
|
+
return
|
|
346
|
+
self.query_one("#metrics-list", ListView).action_cursor_up()
|
|
347
|
+
|
|
348
|
+
def check_action(self, action: str, parameters: tuple[object, ...]) -> bool | None:
|
|
349
|
+
if action == "add_metric" and isinstance(self.focused, Input):
|
|
350
|
+
return False
|
|
351
|
+
return True
|
|
352
|
+
|
|
353
|
+
def _highlighted_metric(self) -> Optional[Metric]:
|
|
354
|
+
child = self.query_one("#metrics-list", ListView).highlighted_child
|
|
355
|
+
if isinstance(child, MetricRow):
|
|
356
|
+
return child.metric
|
|
357
|
+
return None
|
|
358
|
+
|
|
359
|
+
def action_preview_metric(self) -> None:
|
|
360
|
+
if isinstance(self.focused, Input):
|
|
361
|
+
return
|
|
362
|
+
metric = self._highlighted_metric()
|
|
363
|
+
if metric is not None:
|
|
364
|
+
self.run_preview(metric)
|
|
365
|
+
|
|
366
|
+
def action_add_metric(self) -> None:
|
|
367
|
+
if isinstance(self.focused, Input):
|
|
368
|
+
return
|
|
369
|
+
list_view = self.query_one("#metrics-list", ListView)
|
|
370
|
+
if list_view.index is None and self._metrics:
|
|
371
|
+
list_view.index = 0
|
|
372
|
+
metric = self._highlighted_metric()
|
|
373
|
+
if metric is None:
|
|
374
|
+
self.notify("Highlight a metric first", severity="warning")
|
|
375
|
+
return
|
|
376
|
+
if not self.session.add_metric(metric.id):
|
|
377
|
+
self.notify(f"{metric.id} is already in session", severity="warning")
|
|
378
|
+
return
|
|
379
|
+
if hasattr(self.app, "remember_label"):
|
|
380
|
+
self.app.remember_label(metric.id, metric.name or metric.id)
|
|
381
|
+
self.notify(f"Added {metric.id} to session")
|
|
382
|
+
child = self.query_one("#metrics-list", ListView).highlighted_child
|
|
383
|
+
if isinstance(child, MetricRow):
|
|
384
|
+
child.staged = True
|
|
385
|
+
try:
|
|
386
|
+
child.query_one(Label).update(f"● {metric.name} [{metric.id}]")
|
|
387
|
+
except Exception:
|
|
388
|
+
pass
|
|
389
|
+
|
|
390
|
+
@on(Input.Changed, "#metrics-filter")
|
|
391
|
+
def on_filter_changed(self, event: Input.Changed) -> None:
|
|
392
|
+
if self.search_task:
|
|
393
|
+
self.search_task.cancel()
|
|
394
|
+
self.search_task = asyncio.create_task(self._debounced_filter(event.value))
|
|
395
|
+
|
|
396
|
+
async def _debounced_filter(self, value: str) -> None:
|
|
397
|
+
try:
|
|
398
|
+
await asyncio.sleep(0.3)
|
|
399
|
+
self.filter_query = value
|
|
400
|
+
self.load_metrics(reset=True)
|
|
401
|
+
except asyncio.CancelledError:
|
|
402
|
+
pass
|
|
403
|
+
|
|
404
|
+
@on(Input.Submitted, "#metrics-filter")
|
|
405
|
+
def on_filter_submit(self) -> None:
|
|
406
|
+
self.query_one("#metrics-list", ListView).focus()
|
|
407
|
+
|
|
408
|
+
@on(ListView.Highlighted, "#metrics-list")
|
|
409
|
+
def on_metric_highlighted(self, event: ListView.Highlighted) -> None:
|
|
410
|
+
if self.loading_more or not self.has_more:
|
|
411
|
+
return
|
|
412
|
+
list_view = self.query_one("#metrics-list", ListView)
|
|
413
|
+
if event.item is None:
|
|
414
|
+
return
|
|
415
|
+
index = list_view.index
|
|
416
|
+
if index is None or index < len(self._metrics) - 1:
|
|
417
|
+
return
|
|
418
|
+
self.loading_more = True
|
|
419
|
+
self.page_offset = len(self._metrics)
|
|
420
|
+
self.load_metrics(reset=False)
|
|
421
|
+
|
|
422
|
+
@work(exclusive=True, group="preview")
|
|
423
|
+
async def run_preview(self, metric: Metric) -> None:
|
|
424
|
+
chart = self.query_one("#preview-chart", Static)
|
|
425
|
+
header = self.query_one("#preview-header")
|
|
426
|
+
if not self.session.entity:
|
|
427
|
+
chart.update("Add entities before previewing.")
|
|
428
|
+
return
|
|
429
|
+
header.update(f"PREVIEW // {metric.name}")
|
|
430
|
+
chart.update("Loading observations…")
|
|
431
|
+
try:
|
|
432
|
+
series_list: list[TimeSeries] = await asyncio.to_thread(
|
|
433
|
+
self.client.query,
|
|
434
|
+
metric=metric.id,
|
|
435
|
+
entity=list(self.session.entity),
|
|
436
|
+
tail=PREVIEW_TAIL,
|
|
437
|
+
)
|
|
438
|
+
except Exception as e:
|
|
439
|
+
chart.update(f"Preview failed: {e}")
|
|
440
|
+
return
|
|
441
|
+
frequency = pick_frequency(series_list)
|
|
442
|
+
if not frequency:
|
|
443
|
+
chart.update("No observations in the preview window.")
|
|
444
|
+
return
|
|
445
|
+
picked = pick_series_per_entity(series_list, self.session.entity, frequency)
|
|
446
|
+
units = next(
|
|
447
|
+
(ts.series.units for ts in picked.values() if ts is not None),
|
|
448
|
+
"",
|
|
449
|
+
)
|
|
450
|
+
width = max(24, (self.size.width or 80) - LABEL_WIDTH - 2)
|
|
451
|
+
text = render_preview(
|
|
452
|
+
metric.name,
|
|
453
|
+
frequency,
|
|
454
|
+
units,
|
|
455
|
+
{eid: label_for(self._labels(), eid) for eid in self.session.entity},
|
|
456
|
+
picked,
|
|
457
|
+
bar_width=width,
|
|
458
|
+
)
|
|
459
|
+
chart.update(text)
|
|
460
|
+
|
|
461
|
+
|
|
462
|
+
def create_discover_screen(client, session, mode="union", **kwargs):
|
|
463
|
+
return DiscoverScreen(client, session, mode=mode)
|
|
464
|
+
|
|
465
|
+
|
|
466
|
+
DISCOVER = register(
|
|
467
|
+
StepSpec(
|
|
468
|
+
id="discover",
|
|
469
|
+
name="Discover",
|
|
470
|
+
description=(
|
|
471
|
+
"Find metrics related to session entities, preview recent coverage, "
|
|
472
|
+
"and stage promising metrics."
|
|
473
|
+
),
|
|
474
|
+
create_screen=create_discover_screen,
|
|
475
|
+
arguments=(
|
|
476
|
+
StepArgument(
|
|
477
|
+
name="mode",
|
|
478
|
+
type="string",
|
|
479
|
+
description="union (any entity) or intersect (every entity)",
|
|
480
|
+
default="union",
|
|
481
|
+
choices=MODES,
|
|
482
|
+
),
|
|
483
|
+
),
|
|
484
|
+
bindings=(
|
|
485
|
+
StepBinding("/", "focus_filter", "Filter the generated metric list"),
|
|
486
|
+
StepBinding(
|
|
487
|
+
"enter",
|
|
488
|
+
"preview_metric",
|
|
489
|
+
"Preview the latest observations for the highlighted metric",
|
|
490
|
+
),
|
|
491
|
+
StepBinding(
|
|
492
|
+
"space", "add_metric", "Add highlighted metric to the session"
|
|
493
|
+
),
|
|
494
|
+
StepBinding(
|
|
495
|
+
"tab", "cycle_panes", "Switch focus between metrics and preview"
|
|
496
|
+
),
|
|
497
|
+
StepBinding("j / k", "cursor", "Move metric highlight or scroll preview"),
|
|
498
|
+
StepBinding("escape", "leave_filter", "Leave the metric filter"),
|
|
499
|
+
),
|
|
500
|
+
example="jst run console --taxonomy country : discover --mode union",
|
|
501
|
+
)
|
|
502
|
+
)
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
"""Shared export modal for all workflow steps."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import subprocess
|
|
6
|
+
import sys
|
|
7
|
+
|
|
8
|
+
from textual import on
|
|
9
|
+
from textual.app import ComposeResult
|
|
10
|
+
from textual.binding import Binding
|
|
11
|
+
from textual.containers import Horizontal, Vertical
|
|
12
|
+
from textual.screen import ModalScreen
|
|
13
|
+
from textual.widgets import Button, Input, Label, Static
|
|
14
|
+
|
|
15
|
+
from ..session import Session
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def copy_to_clipboard(app, text: str) -> bool:
|
|
19
|
+
"""Best-effort clipboard write. Returns True on apparent success."""
|
|
20
|
+
if sys.platform == "darwin":
|
|
21
|
+
try:
|
|
22
|
+
subprocess.run(["pbcopy"], input=text, text=True, check=True)
|
|
23
|
+
return True
|
|
24
|
+
except Exception:
|
|
25
|
+
pass
|
|
26
|
+
try:
|
|
27
|
+
app.copy_to_clipboard(text)
|
|
28
|
+
return True
|
|
29
|
+
except Exception:
|
|
30
|
+
return False
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class ExportModal(ModalScreen[None]):
|
|
34
|
+
"""Host export surface: Python snippet, CLI command, write session file."""
|
|
35
|
+
|
|
36
|
+
DEFAULT_CSS = """
|
|
37
|
+
ExportModal {
|
|
38
|
+
align: center middle;
|
|
39
|
+
background: rgba(0, 0, 0, 0.7);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
#export-container {
|
|
43
|
+
width: 90;
|
|
44
|
+
max-width: 100%;
|
|
45
|
+
height: auto;
|
|
46
|
+
max-height: 90%;
|
|
47
|
+
border: thick #4ade80;
|
|
48
|
+
background: #111111;
|
|
49
|
+
padding: 1 2;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
#export-header {
|
|
53
|
+
height: 1;
|
|
54
|
+
margin-bottom: 1;
|
|
55
|
+
width: 100%;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
#export-title {
|
|
59
|
+
text-style: bold;
|
|
60
|
+
color: #4ade80;
|
|
61
|
+
width: 1fr;
|
|
62
|
+
text-align: center;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
.export-section {
|
|
66
|
+
color: #4ade80;
|
|
67
|
+
text-style: bold;
|
|
68
|
+
margin-top: 1;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
.export-code {
|
|
72
|
+
background: #0a0a0a;
|
|
73
|
+
color: #e0e0e0;
|
|
74
|
+
padding: 1;
|
|
75
|
+
height: auto;
|
|
76
|
+
max-height: 12;
|
|
77
|
+
border: solid #333;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
.export-row {
|
|
81
|
+
height: auto;
|
|
82
|
+
margin-top: 1;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
#export-path {
|
|
86
|
+
width: 1fr;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
#export-hint {
|
|
90
|
+
color: #888;
|
|
91
|
+
height: 1;
|
|
92
|
+
margin-top: 1;
|
|
93
|
+
}
|
|
94
|
+
"""
|
|
95
|
+
|
|
96
|
+
BINDINGS = [
|
|
97
|
+
Binding("escape", "dismiss", "Close"),
|
|
98
|
+
]
|
|
99
|
+
|
|
100
|
+
def __init__(self, session: Session, default_path: str, on_write=None) -> None:
|
|
101
|
+
super().__init__()
|
|
102
|
+
self.session = session
|
|
103
|
+
self.default_path = default_path
|
|
104
|
+
self.on_write = on_write
|
|
105
|
+
self.python_code = session.to_python()
|
|
106
|
+
self.cli_command = session.to_cli()
|
|
107
|
+
|
|
108
|
+
def compose(self) -> ComposeResult:
|
|
109
|
+
with Vertical(id="export-container"):
|
|
110
|
+
with Horizontal(id="export-header"):
|
|
111
|
+
yield Label("EXPORT // TAKE IT WITH YOU", id="export-title")
|
|
112
|
+
|
|
113
|
+
yield Label("PYTHON", classes="export-section")
|
|
114
|
+
yield Static(self.python_code, classes="export-code", id="export-python")
|
|
115
|
+
yield Button("COPY PYTHON", id="export-copy-python", variant="primary")
|
|
116
|
+
|
|
117
|
+
yield Label("CLI", classes="export-section")
|
|
118
|
+
yield Static(self.cli_command, classes="export-code", id="export-cli")
|
|
119
|
+
yield Button("COPY COMMAND", id="export-copy-cli", variant="primary")
|
|
120
|
+
|
|
121
|
+
yield Label("WRITE SESSION", classes="export-section")
|
|
122
|
+
with Horizontal(classes="export-row"):
|
|
123
|
+
yield Input(
|
|
124
|
+
value=self.default_path,
|
|
125
|
+
placeholder="session.json",
|
|
126
|
+
id="export-path",
|
|
127
|
+
)
|
|
128
|
+
yield Button("WRITE", id="export-write", variant="success")
|
|
129
|
+
|
|
130
|
+
yield Label(
|
|
131
|
+
"[bold]tab[/bold] move // [bold]enter[/bold] activate // [bold]esc[/bold] close",
|
|
132
|
+
id="export-hint",
|
|
133
|
+
markup=True,
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
@on(Button.Pressed, "#export-copy-python")
|
|
137
|
+
def copy_python(self) -> None:
|
|
138
|
+
if copy_to_clipboard(self.app, self.python_code):
|
|
139
|
+
self.notify("Python snippet copied")
|
|
140
|
+
else:
|
|
141
|
+
self.notify("Could not copy to clipboard", severity="error")
|
|
142
|
+
|
|
143
|
+
@on(Button.Pressed, "#export-copy-cli")
|
|
144
|
+
def copy_cli(self) -> None:
|
|
145
|
+
if copy_to_clipboard(self.app, self.cli_command):
|
|
146
|
+
self.notify("CLI command copied")
|
|
147
|
+
else:
|
|
148
|
+
self.notify("Could not copy to clipboard", severity="error")
|
|
149
|
+
|
|
150
|
+
@on(Button.Pressed, "#export-write")
|
|
151
|
+
def write_session(self) -> None:
|
|
152
|
+
path = self.query_one("#export-path", Input).value.strip()
|
|
153
|
+
if not path:
|
|
154
|
+
self.notify("Enter a session file path", severity="warning")
|
|
155
|
+
return
|
|
156
|
+
try:
|
|
157
|
+
self.session.save(path)
|
|
158
|
+
if hasattr(self.app, "output_path"):
|
|
159
|
+
self.app.output_path = path
|
|
160
|
+
self.notify(f"Session written to {path}")
|
|
161
|
+
if self.on_write:
|
|
162
|
+
self.on_write(path)
|
|
163
|
+
except Exception as e:
|
|
164
|
+
self.notify(f"Failed to write session: {e}", severity="error")
|
|
165
|
+
|
|
166
|
+
def action_dismiss(self) -> None:
|
|
167
|
+
self.dismiss()
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
# Back-compat alias during rename
|
|
171
|
+
OfframpModal = ExportModal
|