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,658 @@
|
|
|
1
|
+
"""Rank step — leaderboard for one session metric across a metric population."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from datetime import datetime
|
|
8
|
+
from typing import Optional
|
|
9
|
+
|
|
10
|
+
from textual import on, work
|
|
11
|
+
from textual.app import ComposeResult
|
|
12
|
+
from textual.binding import Binding
|
|
13
|
+
from textual.containers import Horizontal, Vertical
|
|
14
|
+
from textual.screen import Screen
|
|
15
|
+
from textual.widgets import Input, Label, ListItem, ListView, Static
|
|
16
|
+
|
|
17
|
+
from ..client import JSTDataClient
|
|
18
|
+
from ..models import TimeSeries
|
|
19
|
+
from ..session import Session
|
|
20
|
+
from .base import StepArgument, StepBinding, StepSpec, label_for, register
|
|
21
|
+
from .chart import (
|
|
22
|
+
VALUE_WIDTH,
|
|
23
|
+
format_rank_line,
|
|
24
|
+
last_observation_timestamp,
|
|
25
|
+
last_observation_value,
|
|
26
|
+
pick_entity,
|
|
27
|
+
shared_pane_max,
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
PAGE_SIZE = 50
|
|
31
|
+
RANK_DEBOUNCE_S = 0.4
|
|
32
|
+
TAXONOMY_PAGE = 20
|
|
33
|
+
TAXONOMY_ID_CAP = 2000
|
|
34
|
+
NAME_WIDTH = 18
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@dataclass
|
|
38
|
+
class RankEntry:
|
|
39
|
+
entity_id: str
|
|
40
|
+
entity_label: str
|
|
41
|
+
series_id: str
|
|
42
|
+
value: float
|
|
43
|
+
frequency: str
|
|
44
|
+
units: str
|
|
45
|
+
as_of: Optional[datetime]
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class MetricRow(ListItem):
|
|
49
|
+
def __init__(self, metric_id: str, caption: str) -> None:
|
|
50
|
+
super().__init__()
|
|
51
|
+
self.metric_id = metric_id
|
|
52
|
+
self.caption = caption
|
|
53
|
+
|
|
54
|
+
def compose(self) -> ComposeResult:
|
|
55
|
+
yield Label(self.caption)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class RankRow(ListItem):
|
|
59
|
+
def __init__(self, entry: RankEntry, rank: int, pane_max: float, bar_width: int) -> None:
|
|
60
|
+
super().__init__()
|
|
61
|
+
self.entry = entry
|
|
62
|
+
self.rank = rank
|
|
63
|
+
self.pane_max = pane_max
|
|
64
|
+
self.bar_width = bar_width
|
|
65
|
+
|
|
66
|
+
def compose(self) -> ComposeResult:
|
|
67
|
+
yield Label(
|
|
68
|
+
format_rank_line(
|
|
69
|
+
self.rank,
|
|
70
|
+
self.entry.entity_label,
|
|
71
|
+
self.entry.value,
|
|
72
|
+
self.pane_max,
|
|
73
|
+
self.bar_width,
|
|
74
|
+
name_width=NAME_WIDTH,
|
|
75
|
+
)
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
class RankScreen(Screen):
|
|
80
|
+
"""Session metrics on the left; value-sorted leaderboard on the right."""
|
|
81
|
+
|
|
82
|
+
BINDINGS = [
|
|
83
|
+
Binding("slash", "focus_filter", "Filter ranking", key_display="/"),
|
|
84
|
+
Binding("escape", "leave_filter", "Leave filter", show=False),
|
|
85
|
+
Binding("tab", "cycle_panes", "Switch metrics / rank", priority=True),
|
|
86
|
+
Binding(
|
|
87
|
+
"shift+tab",
|
|
88
|
+
"cycle_panes",
|
|
89
|
+
"Switch panes reverse",
|
|
90
|
+
show=False,
|
|
91
|
+
priority=True,
|
|
92
|
+
),
|
|
93
|
+
Binding("enter", "confirm", "Add entity / open ranking", priority=True),
|
|
94
|
+
Binding("shift+enter", "add_entity", "Add entity to session", show=False, priority=True),
|
|
95
|
+
Binding("j", "cursor_down", "Move highlight down", show=False),
|
|
96
|
+
Binding("k", "cursor_up", "Move highlight up", show=False),
|
|
97
|
+
]
|
|
98
|
+
|
|
99
|
+
DEFAULT_CSS = """
|
|
100
|
+
#rank-body { height: 1fr; }
|
|
101
|
+
|
|
102
|
+
.pane-container {
|
|
103
|
+
border: solid #222;
|
|
104
|
+
background: #0f0f0f;
|
|
105
|
+
}
|
|
106
|
+
#metrics-pane { width: 36%; }
|
|
107
|
+
#board-pane { width: 1fr; }
|
|
108
|
+
|
|
109
|
+
.pane-header {
|
|
110
|
+
background: #1a1a1a;
|
|
111
|
+
color: #4ade80;
|
|
112
|
+
padding: 0 1;
|
|
113
|
+
text-style: bold;
|
|
114
|
+
height: 1;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
#metrics-list, #rank-list {
|
|
118
|
+
height: 1fr;
|
|
119
|
+
background: #0f0f0f;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
ListView > ListItem,
|
|
123
|
+
MetricRow,
|
|
124
|
+
RankRow {
|
|
125
|
+
height: 1;
|
|
126
|
+
min-height: 1;
|
|
127
|
+
max-height: 1;
|
|
128
|
+
width: 100%;
|
|
129
|
+
padding: 0 1;
|
|
130
|
+
overflow: hidden;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
MetricRow:focus,
|
|
134
|
+
MetricRow.-highlighted,
|
|
135
|
+
RankRow:focus,
|
|
136
|
+
RankRow.-highlighted {
|
|
137
|
+
background: #1a3a1a;
|
|
138
|
+
color: #4ade80;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
#empty-prompt {
|
|
142
|
+
height: 1fr;
|
|
143
|
+
padding: 2 2;
|
|
144
|
+
color: #4ade80;
|
|
145
|
+
text-style: bold;
|
|
146
|
+
background: #111111;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
#rank-filter {
|
|
150
|
+
height: 3;
|
|
151
|
+
background: transparent;
|
|
152
|
+
border: none;
|
|
153
|
+
border-bottom: solid #333;
|
|
154
|
+
padding: 0 1;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
#board-meta {
|
|
158
|
+
height: 1;
|
|
159
|
+
color: #888;
|
|
160
|
+
padding: 0 1;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
#board-status {
|
|
164
|
+
height: 1;
|
|
165
|
+
color: #888;
|
|
166
|
+
padding: 0 1;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
#cmd-bar {
|
|
170
|
+
height: 3;
|
|
171
|
+
background: #0f0f0f;
|
|
172
|
+
border-top: solid #4ade80;
|
|
173
|
+
align: left middle;
|
|
174
|
+
padding: 0 1;
|
|
175
|
+
}
|
|
176
|
+
#help-hint { color: #888; }
|
|
177
|
+
"""
|
|
178
|
+
|
|
179
|
+
def __init__(
|
|
180
|
+
self,
|
|
181
|
+
client: JSTDataClient,
|
|
182
|
+
session: Session,
|
|
183
|
+
taxonomy: Optional[str] = None,
|
|
184
|
+
):
|
|
185
|
+
super().__init__()
|
|
186
|
+
self.client = client
|
|
187
|
+
self.session = session
|
|
188
|
+
self.taxonomy = (taxonomy or "").strip() or None
|
|
189
|
+
self.filter_query = ""
|
|
190
|
+
self.page_offset = 0
|
|
191
|
+
self.has_more = False
|
|
192
|
+
self.loading_more = False
|
|
193
|
+
self.rank_task: asyncio.Task[None] | None = None
|
|
194
|
+
self._metric_snapshot: tuple[str, ...] = ()
|
|
195
|
+
self._active_metric_id: Optional[str] = None
|
|
196
|
+
self._board: list[RankEntry] = []
|
|
197
|
+
self._taxonomy_ids: set[str] | None = None
|
|
198
|
+
|
|
199
|
+
def compose(self) -> ComposeResult:
|
|
200
|
+
with Horizontal(id="rank-body"):
|
|
201
|
+
with Vertical(classes="pane-container", id="metrics-pane"):
|
|
202
|
+
yield Label("METRICS // SESSION", classes="pane-header")
|
|
203
|
+
yield ListView(id="metrics-list")
|
|
204
|
+
yield Static(
|
|
205
|
+
"No metrics in session.\n\n"
|
|
206
|
+
"Press [bold]f[/bold] to find metrics and add them.\n"
|
|
207
|
+
"Then highlight a metric — the ranking fills on the right.\n"
|
|
208
|
+
"Tab to the ranking, Enter to add an entity.",
|
|
209
|
+
id="empty-prompt",
|
|
210
|
+
markup=True,
|
|
211
|
+
)
|
|
212
|
+
with Vertical(classes="pane-container", id="board-pane"):
|
|
213
|
+
yield Label("RANK // —", classes="pane-header", id="board-header")
|
|
214
|
+
yield Label("", id="board-meta")
|
|
215
|
+
yield Input(
|
|
216
|
+
placeholder="/ filter this page by name…",
|
|
217
|
+
id="rank-filter",
|
|
218
|
+
)
|
|
219
|
+
yield ListView(id="rank-list")
|
|
220
|
+
yield Label("", id="board-status")
|
|
221
|
+
with Horizontal(id="cmd-bar"):
|
|
222
|
+
yield Label("", id="help-hint")
|
|
223
|
+
|
|
224
|
+
def on_mount(self) -> None:
|
|
225
|
+
self._refresh_help_hint()
|
|
226
|
+
if self.taxonomy:
|
|
227
|
+
self.load_taxonomy_ids()
|
|
228
|
+
self._sync_metrics(force=True)
|
|
229
|
+
self.query_one("#metrics-list", ListView).focus()
|
|
230
|
+
|
|
231
|
+
def on_screen_resume(self) -> None:
|
|
232
|
+
self._sync_metrics()
|
|
233
|
+
|
|
234
|
+
def _labels(self) -> dict[str, str]:
|
|
235
|
+
return getattr(self.app, "labels", {})
|
|
236
|
+
|
|
237
|
+
def _refresh_help_hint(self) -> None:
|
|
238
|
+
n = getattr(self.app, "step_count", 1)
|
|
239
|
+
i = getattr(self.app, "step_index", 0) + 1
|
|
240
|
+
tax = self.taxonomy or "all"
|
|
241
|
+
parts = [
|
|
242
|
+
f"rank {i}/{n}",
|
|
243
|
+
f"taxonomy {tax}",
|
|
244
|
+
"[bold]/[/bold] filter",
|
|
245
|
+
"[bold]enter[/bold] open rank / add",
|
|
246
|
+
"[bold]tab[/bold] panes",
|
|
247
|
+
"[bold]j/k[/bold] move",
|
|
248
|
+
"[bold]s[/bold] session",
|
|
249
|
+
"[bold]f[/bold] find",
|
|
250
|
+
"[bold]e[/bold] export",
|
|
251
|
+
"[bold]n[/bold]/[bold]p[/bold]",
|
|
252
|
+
"[bold]q[/bold] quit",
|
|
253
|
+
"[bold]?[/bold]",
|
|
254
|
+
]
|
|
255
|
+
self.query_one("#help-hint").update(" // ".join(parts))
|
|
256
|
+
|
|
257
|
+
def _sync_metrics(self, force: bool = False) -> None:
|
|
258
|
+
snapshot = tuple(self.session.metric)
|
|
259
|
+
if not force and snapshot == self._metric_snapshot:
|
|
260
|
+
return
|
|
261
|
+
self._metric_snapshot = snapshot
|
|
262
|
+
empty = self.query_one("#empty-prompt")
|
|
263
|
+
roster = self.query_one("#metrics-list", ListView)
|
|
264
|
+
roster.clear()
|
|
265
|
+
if not snapshot:
|
|
266
|
+
empty.display = True
|
|
267
|
+
roster.display = False
|
|
268
|
+
self._clear_board("Add metrics to rank a population.")
|
|
269
|
+
return
|
|
270
|
+
empty.display = False
|
|
271
|
+
roster.display = True
|
|
272
|
+
labels = self._labels()
|
|
273
|
+
for mid in snapshot:
|
|
274
|
+
name = label_for(labels, mid)
|
|
275
|
+
roster.append(MetricRow(mid, f" {name} [{mid}]"))
|
|
276
|
+
# Defer highlight + first fetch until rows are mounted.
|
|
277
|
+
self.call_after_refresh(self._select_initial_metric)
|
|
278
|
+
|
|
279
|
+
def _select_initial_metric(self) -> None:
|
|
280
|
+
roster = self.query_one("#metrics-list", ListView)
|
|
281
|
+
if not len(roster):
|
|
282
|
+
return
|
|
283
|
+
if roster.index is None:
|
|
284
|
+
roster.index = 0
|
|
285
|
+
child = roster.highlighted_child
|
|
286
|
+
if isinstance(child, MetricRow):
|
|
287
|
+
self._active_metric_id = child.metric_id
|
|
288
|
+
self.load_rank(reset=True)
|
|
289
|
+
|
|
290
|
+
def _clear_board(self, status: str) -> None:
|
|
291
|
+
self._board = []
|
|
292
|
+
self._active_metric_id = None
|
|
293
|
+
self.has_more = False
|
|
294
|
+
self.page_offset = 0
|
|
295
|
+
self.query_one("#rank-list", ListView).clear()
|
|
296
|
+
self.query_one("#board-header").update("RANK // —")
|
|
297
|
+
self.query_one("#board-meta").update("")
|
|
298
|
+
self.query_one("#board-status").update(status)
|
|
299
|
+
|
|
300
|
+
def _bar_width(self) -> int:
|
|
301
|
+
# pane ≈ body width minus metrics pane; leave room for rank/name/value.
|
|
302
|
+
width = self.size.width or 80
|
|
303
|
+
usable = max(24, int(width * 0.64) - NAME_WIDTH - VALUE_WIDTH - 12)
|
|
304
|
+
return usable
|
|
305
|
+
|
|
306
|
+
def _filtered_board(self) -> list[RankEntry]:
|
|
307
|
+
q = self.filter_query.strip().lower()
|
|
308
|
+
if not q:
|
|
309
|
+
return list(self._board)
|
|
310
|
+
out: list[RankEntry] = []
|
|
311
|
+
for entry in self._board:
|
|
312
|
+
hay = f"{entry.entity_label} {entry.entity_id}".lower()
|
|
313
|
+
if q in hay:
|
|
314
|
+
out.append(entry)
|
|
315
|
+
return out
|
|
316
|
+
|
|
317
|
+
def _rebuild_rank_list(self) -> None:
|
|
318
|
+
list_view = self.query_one("#rank-list", ListView)
|
|
319
|
+
list_view.clear()
|
|
320
|
+
rows = self._filtered_board()
|
|
321
|
+
if not rows:
|
|
322
|
+
return
|
|
323
|
+
pane_max = shared_pane_max(e.value for e in rows)
|
|
324
|
+
bar_width = self._bar_width()
|
|
325
|
+
# Rank numbers follow absolute board order, not the filtered subset.
|
|
326
|
+
index_by_id = {
|
|
327
|
+
(e.entity_id, e.series_id): i + 1 for i, e in enumerate(self._board)
|
|
328
|
+
}
|
|
329
|
+
for entry in rows:
|
|
330
|
+
rank = index_by_id.get((entry.entity_id, entry.series_id), 0)
|
|
331
|
+
list_view.append(RankRow(entry, rank, pane_max, bar_width))
|
|
332
|
+
# clear() leaves index=None; without this, Enter's select_cursor is a no-op
|
|
333
|
+
# until the user moves with j/k.
|
|
334
|
+
list_view.index = 0
|
|
335
|
+
|
|
336
|
+
def _entries_from_series(self, series_list: list[TimeSeries]) -> list[RankEntry]:
|
|
337
|
+
entries: list[RankEntry] = []
|
|
338
|
+
for ts in series_list:
|
|
339
|
+
value = last_observation_value(ts)
|
|
340
|
+
if value is None:
|
|
341
|
+
continue
|
|
342
|
+
entity = pick_entity(ts.series.entities, self._taxonomy_ids)
|
|
343
|
+
if entity is None:
|
|
344
|
+
continue
|
|
345
|
+
entries.append(
|
|
346
|
+
RankEntry(
|
|
347
|
+
entity_id=entity.id,
|
|
348
|
+
entity_label=entity.label or entity.id,
|
|
349
|
+
series_id=ts.series.id,
|
|
350
|
+
value=value,
|
|
351
|
+
frequency=ts.series.frequency or "",
|
|
352
|
+
units=ts.series.units or "",
|
|
353
|
+
as_of=last_observation_timestamp(ts),
|
|
354
|
+
)
|
|
355
|
+
)
|
|
356
|
+
return entries
|
|
357
|
+
|
|
358
|
+
@work(exclusive=True, group="taxonomy")
|
|
359
|
+
async def load_taxonomy_ids(self) -> None:
|
|
360
|
+
if not self.taxonomy:
|
|
361
|
+
self._taxonomy_ids = None
|
|
362
|
+
return
|
|
363
|
+
ids: set[str] = set()
|
|
364
|
+
offset = 0
|
|
365
|
+
try:
|
|
366
|
+
while offset < TAXONOMY_ID_CAP:
|
|
367
|
+
page = await asyncio.to_thread(
|
|
368
|
+
self.client.get_taxonomy_entities,
|
|
369
|
+
self.taxonomy,
|
|
370
|
+
TAXONOMY_PAGE,
|
|
371
|
+
offset,
|
|
372
|
+
)
|
|
373
|
+
if not page:
|
|
374
|
+
break
|
|
375
|
+
ids.update(e.id for e in page)
|
|
376
|
+
if len(page) < TAXONOMY_PAGE:
|
|
377
|
+
break
|
|
378
|
+
offset += TAXONOMY_PAGE
|
|
379
|
+
if len(ids) >= TAXONOMY_ID_CAP:
|
|
380
|
+
break
|
|
381
|
+
except Exception:
|
|
382
|
+
self._taxonomy_ids = None
|
|
383
|
+
return
|
|
384
|
+
self._taxonomy_ids = ids
|
|
385
|
+
|
|
386
|
+
@work(exclusive=True, group="rank")
|
|
387
|
+
async def load_rank(self, reset: bool = False) -> None:
|
|
388
|
+
metric_id = self._active_metric_id
|
|
389
|
+
if not metric_id:
|
|
390
|
+
self.loading_more = False
|
|
391
|
+
return
|
|
392
|
+
if reset:
|
|
393
|
+
self.page_offset = 0
|
|
394
|
+
self.has_more = False
|
|
395
|
+
self._board = []
|
|
396
|
+
status = self.query_one("#board-status")
|
|
397
|
+
status.update("Loading ranking…")
|
|
398
|
+
labels = self._labels()
|
|
399
|
+
name = label_for(labels, metric_id)
|
|
400
|
+
self.query_one("#board-header").update(f"RANK // {name}")
|
|
401
|
+
try:
|
|
402
|
+
series_list: list[TimeSeries] = await asyncio.to_thread(
|
|
403
|
+
self.client.query,
|
|
404
|
+
metric=metric_id,
|
|
405
|
+
taxonomy=self.taxonomy,
|
|
406
|
+
tail=1,
|
|
407
|
+
sort_by="value",
|
|
408
|
+
limit=PAGE_SIZE,
|
|
409
|
+
offset=self.page_offset,
|
|
410
|
+
)
|
|
411
|
+
except Exception as e:
|
|
412
|
+
status.update(f"Error: {e}")
|
|
413
|
+
self.loading_more = False
|
|
414
|
+
return
|
|
415
|
+
if metric_id != self._active_metric_id:
|
|
416
|
+
self.loading_more = False
|
|
417
|
+
return
|
|
418
|
+
if reset:
|
|
419
|
+
self._board = []
|
|
420
|
+
page = self._entries_from_series(series_list)
|
|
421
|
+
self._board.extend(page)
|
|
422
|
+
self.has_more = len(series_list) == PAGE_SIZE
|
|
423
|
+
self._rebuild_rank_list()
|
|
424
|
+
|
|
425
|
+
meta_bits: list[str] = []
|
|
426
|
+
if page:
|
|
427
|
+
freqs = {e.frequency for e in self._board if e.frequency}
|
|
428
|
+
units = next((e.units for e in self._board if e.units), "")
|
|
429
|
+
as_ofs = [e.as_of for e in self._board if e.as_of is not None]
|
|
430
|
+
if len(freqs) == 1:
|
|
431
|
+
meta_bits.append(next(iter(freqs)))
|
|
432
|
+
elif freqs:
|
|
433
|
+
meta_bits.append("mixed freq")
|
|
434
|
+
if units:
|
|
435
|
+
meta_bits.append(units)
|
|
436
|
+
if as_ofs:
|
|
437
|
+
years = {t.year for t in as_ofs}
|
|
438
|
+
if len(years) == 1:
|
|
439
|
+
meta_bits.append(f"as of {next(iter(years))}")
|
|
440
|
+
else:
|
|
441
|
+
meta_bits.append(f"as of {min(years)}–{max(years)}")
|
|
442
|
+
self.query_one("#board-meta").update(" · ".join(meta_bits))
|
|
443
|
+
|
|
444
|
+
shown = len(self._filtered_board())
|
|
445
|
+
total = len(self._board)
|
|
446
|
+
extra = "+" if self.has_more else ""
|
|
447
|
+
tax = self.taxonomy or "all"
|
|
448
|
+
q = self.filter_query.strip()
|
|
449
|
+
qbit = f" · filter “{q}”" if q else ""
|
|
450
|
+
if reset and not series_list:
|
|
451
|
+
tax_bit = f" in taxonomy “{tax}”" if self.taxonomy else ""
|
|
452
|
+
status.update(
|
|
453
|
+
f"No series for “{metric_id}”{tax_bit}. "
|
|
454
|
+
"Try another metric, or check the metric id."
|
|
455
|
+
)
|
|
456
|
+
elif reset and series_list and not page:
|
|
457
|
+
status.update(
|
|
458
|
+
f"Got {len(series_list)} series but no entities to show · {tax}"
|
|
459
|
+
)
|
|
460
|
+
else:
|
|
461
|
+
status.update(f"{shown}/{total}{extra} · desc · {tax}{qbit}")
|
|
462
|
+
self.loading_more = False
|
|
463
|
+
|
|
464
|
+
def action_focus_filter(self) -> None:
|
|
465
|
+
self.query_one("#rank-filter", Input).focus()
|
|
466
|
+
|
|
467
|
+
def action_leave_filter(self) -> None:
|
|
468
|
+
filt = self.query_one("#rank-filter", Input)
|
|
469
|
+
if self.focused is filt:
|
|
470
|
+
self.query_one("#rank-list", ListView).focus()
|
|
471
|
+
|
|
472
|
+
def action_cycle_panes(self) -> None:
|
|
473
|
+
if isinstance(self.focused, Input):
|
|
474
|
+
self.query_one("#rank-list", ListView).focus()
|
|
475
|
+
return
|
|
476
|
+
if self.focused is self.query_one("#metrics-list"):
|
|
477
|
+
self.query_one("#rank-list", ListView).focus()
|
|
478
|
+
else:
|
|
479
|
+
self.query_one("#metrics-list", ListView).focus()
|
|
480
|
+
|
|
481
|
+
def action_cursor_down(self) -> None:
|
|
482
|
+
focused = self.focused
|
|
483
|
+
if isinstance(focused, Input):
|
|
484
|
+
return
|
|
485
|
+
if focused and hasattr(focused, "action_cursor_down"):
|
|
486
|
+
focused.action_cursor_down()
|
|
487
|
+
|
|
488
|
+
def action_cursor_up(self) -> None:
|
|
489
|
+
focused = self.focused
|
|
490
|
+
if isinstance(focused, Input):
|
|
491
|
+
return
|
|
492
|
+
if focused and hasattr(focused, "action_cursor_up"):
|
|
493
|
+
focused.action_cursor_up()
|
|
494
|
+
|
|
495
|
+
def _highlighted_rank(self) -> Optional[RankEntry]:
|
|
496
|
+
child = self.query_one("#rank-list", ListView).highlighted_child
|
|
497
|
+
if isinstance(child, RankRow):
|
|
498
|
+
return child.entry
|
|
499
|
+
return None
|
|
500
|
+
|
|
501
|
+
def _rank_list_focused(self) -> bool:
|
|
502
|
+
focused = self.focused
|
|
503
|
+
if focused is self.query_one("#rank-list"):
|
|
504
|
+
return True
|
|
505
|
+
node = focused
|
|
506
|
+
while node is not None:
|
|
507
|
+
if getattr(node, "id", None) == "rank-list":
|
|
508
|
+
return True
|
|
509
|
+
node = getattr(node, "parent", None)
|
|
510
|
+
return False
|
|
511
|
+
|
|
512
|
+
def _add_entity(self, entry: RankEntry) -> None:
|
|
513
|
+
if not self.session.add_entity(entry.entity_id):
|
|
514
|
+
self.notify(f"{entry.entity_id} is already in session", severity="warning")
|
|
515
|
+
return
|
|
516
|
+
if hasattr(self.app, "remember_label"):
|
|
517
|
+
self.app.remember_label(entry.entity_id, entry.entity_label)
|
|
518
|
+
self.notify(f"Added {entry.entity_id} to session")
|
|
519
|
+
|
|
520
|
+
def action_confirm(self) -> None:
|
|
521
|
+
"""Enter: on the board, add entity; on metrics, jump to the board."""
|
|
522
|
+
if isinstance(self.focused, Input):
|
|
523
|
+
return
|
|
524
|
+
if self._rank_list_focused():
|
|
525
|
+
self.action_add_entity()
|
|
526
|
+
return
|
|
527
|
+
# Metrics list: ranking loads on highlight — just move focus over.
|
|
528
|
+
self.query_one("#rank-list", ListView).focus()
|
|
529
|
+
|
|
530
|
+
def action_add_entity(self) -> None:
|
|
531
|
+
if isinstance(self.focused, Input):
|
|
532
|
+
return
|
|
533
|
+
list_view = self.query_one("#rank-list", ListView)
|
|
534
|
+
entry = self._highlighted_rank()
|
|
535
|
+
if entry is None and len(list_view):
|
|
536
|
+
list_view.index = 0
|
|
537
|
+
entry = self._highlighted_rank()
|
|
538
|
+
if entry is None:
|
|
539
|
+
if not self._board:
|
|
540
|
+
self.notify(
|
|
541
|
+
"Ranking is empty — pick a metric that has series "
|
|
542
|
+
"(right pane shows why)",
|
|
543
|
+
severity="warning",
|
|
544
|
+
)
|
|
545
|
+
else:
|
|
546
|
+
self.notify("No entity selected on the ranking", severity="warning")
|
|
547
|
+
return
|
|
548
|
+
self._add_entity(entry)
|
|
549
|
+
|
|
550
|
+
@on(ListView.Selected, "#rank-list")
|
|
551
|
+
def on_rank_selected(self, event: ListView.Selected) -> None:
|
|
552
|
+
"""Enter on the leaderboard adds the highlighted entity."""
|
|
553
|
+
if isinstance(event.item, RankRow):
|
|
554
|
+
self._add_entity(event.item.entry)
|
|
555
|
+
|
|
556
|
+
@on(ListView.Selected, "#metrics-list")
|
|
557
|
+
def on_metric_selected(self, event: ListView.Selected) -> None:
|
|
558
|
+
"""Enter on a metric focuses the ranking for that metric."""
|
|
559
|
+
if isinstance(event.item, MetricRow):
|
|
560
|
+
self._active_metric_id = event.item.metric_id
|
|
561
|
+
self.load_rank(reset=True)
|
|
562
|
+
self.query_one("#rank-list", ListView).focus()
|
|
563
|
+
|
|
564
|
+
@on(Input.Changed, "#rank-filter")
|
|
565
|
+
def on_filter_changed(self, event: Input.Changed) -> None:
|
|
566
|
+
self.filter_query = event.value
|
|
567
|
+
self._rebuild_rank_list()
|
|
568
|
+
shown = len(self._filtered_board())
|
|
569
|
+
total = len(self._board)
|
|
570
|
+
extra = "+" if self.has_more else ""
|
|
571
|
+
tax = self.taxonomy or "all"
|
|
572
|
+
q = self.filter_query.strip()
|
|
573
|
+
qbit = f" · filter “{q}”" if q else ""
|
|
574
|
+
self.query_one("#board-status").update(
|
|
575
|
+
f"{shown}/{total}{extra} · desc · {tax}{qbit}"
|
|
576
|
+
)
|
|
577
|
+
|
|
578
|
+
@on(Input.Submitted, "#rank-filter")
|
|
579
|
+
def on_filter_submit(self) -> None:
|
|
580
|
+
self.query_one("#rank-list", ListView).focus()
|
|
581
|
+
|
|
582
|
+
@on(ListView.Highlighted, "#metrics-list")
|
|
583
|
+
def on_metric_highlighted(self, event: ListView.Highlighted) -> None:
|
|
584
|
+
if not isinstance(event.item, MetricRow):
|
|
585
|
+
return
|
|
586
|
+
metric_id = event.item.metric_id
|
|
587
|
+
if self.rank_task:
|
|
588
|
+
self.rank_task.cancel()
|
|
589
|
+
self.rank_task = asyncio.create_task(self._debounced_rank(metric_id))
|
|
590
|
+
|
|
591
|
+
async def _debounced_rank(self, metric_id: str) -> None:
|
|
592
|
+
try:
|
|
593
|
+
await asyncio.sleep(RANK_DEBOUNCE_S)
|
|
594
|
+
self._active_metric_id = metric_id
|
|
595
|
+
self.load_rank(reset=True)
|
|
596
|
+
except asyncio.CancelledError:
|
|
597
|
+
pass
|
|
598
|
+
|
|
599
|
+
@on(ListView.Highlighted, "#rank-list")
|
|
600
|
+
def on_rank_highlighted(self, event: ListView.Highlighted) -> None:
|
|
601
|
+
if self.loading_more or not self.has_more:
|
|
602
|
+
return
|
|
603
|
+
if self.filter_query.strip():
|
|
604
|
+
return
|
|
605
|
+
list_view = self.query_one("#rank-list", ListView)
|
|
606
|
+
if event.item is None:
|
|
607
|
+
return
|
|
608
|
+
index = list_view.index
|
|
609
|
+
if index is None or index < len(self._board) - 1:
|
|
610
|
+
return
|
|
611
|
+
self.loading_more = True
|
|
612
|
+
self.page_offset = len(self._board)
|
|
613
|
+
self.load_rank(reset=False)
|
|
614
|
+
|
|
615
|
+
|
|
616
|
+
def create_rank_screen(client, session, taxonomy=None, **kwargs):
|
|
617
|
+
return RankScreen(client, session, taxonomy=taxonomy)
|
|
618
|
+
|
|
619
|
+
|
|
620
|
+
RANK = register(
|
|
621
|
+
StepSpec(
|
|
622
|
+
id="rank",
|
|
623
|
+
name="Rank",
|
|
624
|
+
description=(
|
|
625
|
+
"Rank entities for a session metric by latest value "
|
|
626
|
+
"(shared-scale leaderboard)."
|
|
627
|
+
),
|
|
628
|
+
create_screen=create_rank_screen,
|
|
629
|
+
arguments=(
|
|
630
|
+
StepArgument(
|
|
631
|
+
name="taxonomy",
|
|
632
|
+
type="string",
|
|
633
|
+
description=(
|
|
634
|
+
"Restrict ranking to entities in this taxonomy"
|
|
635
|
+
),
|
|
636
|
+
),
|
|
637
|
+
),
|
|
638
|
+
bindings=(
|
|
639
|
+
StepBinding("/", "focus_filter", "Filter the current ranking page by name"),
|
|
640
|
+
StepBinding(
|
|
641
|
+
"enter",
|
|
642
|
+
"confirm",
|
|
643
|
+
"On ranking: add entity. On metrics: open the ranking pane.",
|
|
644
|
+
),
|
|
645
|
+
StepBinding(
|
|
646
|
+
"shift+enter",
|
|
647
|
+
"add_entity",
|
|
648
|
+
"Add the highlighted ranked entity to the session",
|
|
649
|
+
),
|
|
650
|
+
StepBinding(
|
|
651
|
+
"tab", "cycle_panes", "Switch focus between metrics and ranking"
|
|
652
|
+
),
|
|
653
|
+
StepBinding("j / k", "cursor", "Move highlight in the focused list"),
|
|
654
|
+
StepBinding("escape", "leave_filter", "Leave the ranking filter"),
|
|
655
|
+
),
|
|
656
|
+
example="jst run console --taxonomy country : discover : rank --taxonomy country",
|
|
657
|
+
)
|
|
658
|
+
)
|