queue-watch 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.
- queue_watch/__init__.py +3 -0
- queue_watch/adapters/__init__.py +34 -0
- queue_watch/adapters/command.py +54 -0
- queue_watch/adapters/flock_queue.py +322 -0
- queue_watch/adapters/github_actions.py +164 -0
- queue_watch/adapters/github_merge_queue.py +141 -0
- queue_watch/app.py +876 -0
- queue_watch/changes.py +181 -0
- queue_watch/cli.py +129 -0
- queue_watch/config.py +105 -0
- queue_watch/discovery.py +149 -0
- queue_watch/model.py +188 -0
- queue_watch/process.py +67 -0
- queue_watch/widgets.py +774 -0
- queue_watch-0.1.0.dist-info/METADATA +141 -0
- queue_watch-0.1.0.dist-info/RECORD +19 -0
- queue_watch-0.1.0.dist-info/WHEEL +4 -0
- queue_watch-0.1.0.dist-info/entry_points.txt +2 -0
- queue_watch-0.1.0.dist-info/licenses/LICENSE +21 -0
queue_watch/widgets.py
ADDED
|
@@ -0,0 +1,774 @@
|
|
|
1
|
+
"""Widgets and text helpers for the dashboard (see design/README.md).
|
|
2
|
+
|
|
3
|
+
Every colour is a Textual theme variable, so ``--theme`` and the command palette
|
|
4
|
+
restyle everything. States always carry a glyph as well as a colour, and
|
|
5
|
+
``use_ascii`` swaps the glyphs for plain ASCII.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import statistics
|
|
11
|
+
import time
|
|
12
|
+
from collections import deque
|
|
13
|
+
from dataclasses import dataclass
|
|
14
|
+
from datetime import datetime
|
|
15
|
+
from typing import Any
|
|
16
|
+
from zoneinfo import ZoneInfo
|
|
17
|
+
|
|
18
|
+
from rich.style import Style as RichStyle
|
|
19
|
+
from rich.text import Text
|
|
20
|
+
from textual import on
|
|
21
|
+
from textual.app import App, ComposeResult
|
|
22
|
+
from textual.color import Color
|
|
23
|
+
from textual.containers import Horizontal, Vertical, VerticalScroll
|
|
24
|
+
from textual.content import Content
|
|
25
|
+
from textual.message import Message
|
|
26
|
+
from textual.widgets import Button, OptionList, Sparkline, Static
|
|
27
|
+
from textual.widgets.option_list import Option, OptionDoesNotExist
|
|
28
|
+
|
|
29
|
+
from .changes import GROUPS, Change, grouped, stamp
|
|
30
|
+
from .process import clean
|
|
31
|
+
|
|
32
|
+
PACIFIC = ZoneInfo("America/Los_Angeles")
|
|
33
|
+
MUTED = "$text-muted"
|
|
34
|
+
STATE_STYLE = {
|
|
35
|
+
"running": "$primary",
|
|
36
|
+
"queued": MUTED,
|
|
37
|
+
"success": "$success",
|
|
38
|
+
"failure": "$error",
|
|
39
|
+
"cancelled": MUTED,
|
|
40
|
+
"blocked": "$warning",
|
|
41
|
+
"neutral": MUTED,
|
|
42
|
+
"unknown": MUTED,
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@dataclass(frozen=True)
|
|
47
|
+
class Symbols:
|
|
48
|
+
glyphs: dict[str, str]
|
|
49
|
+
arrow: str
|
|
50
|
+
tee: str
|
|
51
|
+
elbow: str
|
|
52
|
+
pipe: str
|
|
53
|
+
bar_full: str
|
|
54
|
+
bar_empty: str
|
|
55
|
+
dot: str
|
|
56
|
+
rule: str
|
|
57
|
+
dash: str
|
|
58
|
+
live: str
|
|
59
|
+
folded: str
|
|
60
|
+
unfolded: str
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
UNICODE = Symbols(
|
|
64
|
+
glyphs={
|
|
65
|
+
"running": "●",
|
|
66
|
+
"queued": "◌",
|
|
67
|
+
"success": "✓",
|
|
68
|
+
"failure": "✗",
|
|
69
|
+
"cancelled": "⊘",
|
|
70
|
+
"blocked": "!",
|
|
71
|
+
"neutral": "·",
|
|
72
|
+
"unknown": "·",
|
|
73
|
+
},
|
|
74
|
+
arrow="→",
|
|
75
|
+
tee="├",
|
|
76
|
+
elbow="└",
|
|
77
|
+
pipe="│",
|
|
78
|
+
bar_full="█",
|
|
79
|
+
bar_empty="░",
|
|
80
|
+
dot="·",
|
|
81
|
+
rule="─",
|
|
82
|
+
dash="—",
|
|
83
|
+
live="✓",
|
|
84
|
+
folded="▸",
|
|
85
|
+
unfolded="▾",
|
|
86
|
+
)
|
|
87
|
+
ASCII = Symbols(
|
|
88
|
+
glyphs={
|
|
89
|
+
"running": "*",
|
|
90
|
+
"queued": "o",
|
|
91
|
+
"success": "+",
|
|
92
|
+
"failure": "x",
|
|
93
|
+
"cancelled": "/",
|
|
94
|
+
"blocked": "!",
|
|
95
|
+
"neutral": ".",
|
|
96
|
+
"unknown": ".",
|
|
97
|
+
},
|
|
98
|
+
arrow="->",
|
|
99
|
+
tee="|-",
|
|
100
|
+
elbow="`-",
|
|
101
|
+
pipe="| ",
|
|
102
|
+
bar_full="#",
|
|
103
|
+
bar_empty="-",
|
|
104
|
+
dot="|",
|
|
105
|
+
rule="-",
|
|
106
|
+
dash="-",
|
|
107
|
+
live="+",
|
|
108
|
+
folded=">",
|
|
109
|
+
unfolded="v",
|
|
110
|
+
)
|
|
111
|
+
SYMBOLS = UNICODE
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def use_ascii() -> None:
|
|
115
|
+
global SYMBOLS
|
|
116
|
+
SYMBOLS = ASCII
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
# --- text helpers ---------------------------------------------------------- #
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def glyph(state: str) -> tuple[str, str]:
|
|
123
|
+
"""(glyph, style) for a normalized state, for ``Content.assemble``."""
|
|
124
|
+
return SYMBOLS.glyphs.get(state, SYMBOLS.glyphs["unknown"]), STATE_STYLE.get(state, MUTED)
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def muted(text: str) -> tuple[str, str]:
|
|
128
|
+
return text, MUTED
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def dim(text: str) -> Content:
|
|
132
|
+
return Content.assemble(muted(text))
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def raw(record: dict) -> str:
|
|
136
|
+
return clean(record.get("raw_state") or record["state"]).replace("_", " ")
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def fit(content: Content, width: int, align: str = "left") -> Content:
|
|
140
|
+
content = content.truncate(width, ellipsis=True)
|
|
141
|
+
gap = width - content.cell_length
|
|
142
|
+
if align == "right" and gap > 0:
|
|
143
|
+
return content.pad_left(gap)
|
|
144
|
+
return content.pad_right(max(gap, 0))
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def lines(rows: list[Content]) -> Content:
|
|
148
|
+
return Content("\n").join(rows)
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def human_seconds(seconds: float) -> str:
|
|
152
|
+
seconds = max(0, round(seconds))
|
|
153
|
+
if seconds < 60:
|
|
154
|
+
return f"{seconds}s"
|
|
155
|
+
if seconds < 3600:
|
|
156
|
+
minutes, rest = divmod(seconds, 60)
|
|
157
|
+
return f"{minutes}m{rest:02d}s" if rest else f"{minutes}m"
|
|
158
|
+
hours, rest = divmod(seconds, 3600)
|
|
159
|
+
return f"{hours}h{rest // 60:02d}m"
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def duration(value: object) -> str:
|
|
163
|
+
if isinstance(value, bool) or not isinstance(value, (int, float)):
|
|
164
|
+
return "-"
|
|
165
|
+
return human_seconds(value)
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def eta_text(seconds: float) -> str:
|
|
169
|
+
"""Estimates are rough: whole minutes past a minute and a half, ``~7m``."""
|
|
170
|
+
return f"~{human_seconds(seconds if seconds < 90 else round(seconds / 60) * 60)}"
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def short_age(seconds: float | None) -> str:
|
|
174
|
+
"""Coarse age for table cells: 45s, 12m, 3h, 2d."""
|
|
175
|
+
if seconds is None:
|
|
176
|
+
return "?"
|
|
177
|
+
seconds = max(0, round(seconds))
|
|
178
|
+
for size, unit in ((86400, "d"), (3600, "h"), (60, "m")):
|
|
179
|
+
if seconds >= size:
|
|
180
|
+
return f"{seconds // size}{unit}"
|
|
181
|
+
return f"{seconds}s"
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def now() -> float:
|
|
185
|
+
"""Wall-clock epoch seconds; the one place ages read the clock (tests replace it)."""
|
|
186
|
+
return time.time()
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def ago(value: str | float | None) -> float | None:
|
|
190
|
+
"""Seconds since an ISO timestamp or epoch seconds."""
|
|
191
|
+
if isinstance(value, (int, float)):
|
|
192
|
+
return now() - value if value else None
|
|
193
|
+
try:
|
|
194
|
+
return now() - datetime.fromisoformat(value).timestamp()
|
|
195
|
+
except (TypeError, ValueError):
|
|
196
|
+
return None
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def clock(value: str | None) -> str:
|
|
200
|
+
try:
|
|
201
|
+
return datetime.fromisoformat(value).astimezone(PACIFIC).strftime("%-I:%M:%S %p %Z")
|
|
202
|
+
except (TypeError, ValueError):
|
|
203
|
+
return "unknown"
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def elapsed(record: dict) -> float | None:
|
|
207
|
+
"""How long a job or attempt took, or has taken so far while running."""
|
|
208
|
+
fields = record.get("fields") or {}
|
|
209
|
+
if isinstance(fields.get("duration_seconds"), (int, float)):
|
|
210
|
+
return fields["duration_seconds"]
|
|
211
|
+
start = stamp(record, "started_at")
|
|
212
|
+
if not start:
|
|
213
|
+
return None
|
|
214
|
+
if record["state"] == "running":
|
|
215
|
+
return now() - start
|
|
216
|
+
end = stamp(record, "finished_at")
|
|
217
|
+
return end - start if end >= start else None
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def eta_of(record: dict | None) -> float | None:
|
|
221
|
+
value = ((record or {}).get("fields") or {}).get("estimated_seconds")
|
|
222
|
+
return value if isinstance(value, (int, float)) and not isinstance(value, bool) else None
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def rich_style(app: App, token: str, bold: bool = False) -> RichStyle:
|
|
226
|
+
"""Resolve a theme variable ('$success') for Rich-rendered widgets such as DataTable."""
|
|
227
|
+
variables = app.get_css_variables()
|
|
228
|
+
value = variables.get(token.lstrip("$"), variables["foreground"])
|
|
229
|
+
if value.startswith("auto"): # "auto 60%": foreground at 60% over the surface
|
|
230
|
+
alpha = float(value.split()[1].rstrip("%")) / 100
|
|
231
|
+
color = Color.parse(variables["foreground"]).blend(
|
|
232
|
+
Color.parse(variables["surface"]), 1 - alpha
|
|
233
|
+
)
|
|
234
|
+
else:
|
|
235
|
+
color = Color.parse(value)
|
|
236
|
+
return RichStyle(color=color.rich_color, bold=bold)
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def state_text(app: App, record: dict) -> Text:
|
|
240
|
+
"""Glyph plus the adapter's own state word, for DataTable cells."""
|
|
241
|
+
text, style = glyph(record["state"])
|
|
242
|
+
return Text(f"{text} {raw(record)}", style=rich_style(app, style))
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
# --- change summaries ------------------------------------------------------ #
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
def ci_status(change: Change, label: str = "CI") -> Content:
|
|
249
|
+
"""``CI ✗ test`` (the worst workflow) or ``CI ✓ 3 runs``."""
|
|
250
|
+
checks = change.checks
|
|
251
|
+
if not checks:
|
|
252
|
+
return Content.assemble(muted(f"{label} {SYMBOLS.dash}"))
|
|
253
|
+
state = change.ci_state
|
|
254
|
+
if state == "success" and len(checks) > 1:
|
|
255
|
+
detail = f"{len(checks)} runs"
|
|
256
|
+
else:
|
|
257
|
+
worst = next(hit.record for hit in checks if hit.record["state"] == state)
|
|
258
|
+
detail = clean(worst["title"]) if len(checks) > 1 else raw(worst)
|
|
259
|
+
return Content.assemble(muted(f"{label} "), glyph(state), " ", detail)
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def queue_status(change: Change, label: str = "Queue", with_eta: bool = True) -> Content:
|
|
263
|
+
"""``Queue ! #4 unmergeable ~7m`` or, after an attempt, ``Queue ✓ merged 38s``."""
|
|
264
|
+
if change.entry:
|
|
265
|
+
entry = change.entry.record
|
|
266
|
+
parts: list[Any] = [muted(f"{label} "), glyph(entry["state"])]
|
|
267
|
+
if isinstance(entry.get("position"), int):
|
|
268
|
+
parts.append(f" #{entry['position']}")
|
|
269
|
+
parts.append(f" {raw(entry)}")
|
|
270
|
+
if with_eta and (eta := eta_of(entry)):
|
|
271
|
+
parts.append(muted(f" {eta_text(eta)}"))
|
|
272
|
+
return Content.assemble(*parts)
|
|
273
|
+
if change.attempts:
|
|
274
|
+
attempt = change.attempts[0].record
|
|
275
|
+
parts = [muted(f"{label} "), glyph(attempt["state"]), f" {raw(attempt)}"]
|
|
276
|
+
if with_eta and (took := elapsed(attempt)):
|
|
277
|
+
parts.append(muted(f" {human_seconds(took)}"))
|
|
278
|
+
return Content.assemble(*parts)
|
|
279
|
+
return Content.assemble(muted(f"{label} {SYMBOLS.dash}"))
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
# --- messages -------------------------------------------------------------- #
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
class ChangeSelected(Message):
|
|
286
|
+
def __init__(self, key: str) -> None:
|
|
287
|
+
super().__init__()
|
|
288
|
+
self.key = key
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
class ChipClicked(Message):
|
|
292
|
+
def __init__(self, source: str, record_id: str) -> None:
|
|
293
|
+
super().__init__()
|
|
294
|
+
self.source, self.record_id = source, record_id
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
# --- status bar ------------------------------------------------------------ #
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
@dataclass
|
|
301
|
+
class Health:
|
|
302
|
+
label: str
|
|
303
|
+
word: str # live, stale, paused, waiting, unavailable
|
|
304
|
+
age: float | None
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
class StatusBar(Horizontal):
|
|
308
|
+
"""One line: brand, a pill per source (label, state, age), group counts."""
|
|
309
|
+
|
|
310
|
+
def __init__(self, count: int) -> None:
|
|
311
|
+
super().__init__(id="status")
|
|
312
|
+
self.count = count
|
|
313
|
+
|
|
314
|
+
def compose(self) -> ComposeResult:
|
|
315
|
+
yield Static(Content.assemble(("queue-watch", "b")), id="brand")
|
|
316
|
+
for index in range(self.count):
|
|
317
|
+
yield Static(classes="pill", id=f"pill-{index}")
|
|
318
|
+
yield Static(id="counts-long", classes="counts")
|
|
319
|
+
yield Static(id="counts-short", classes="counts")
|
|
320
|
+
|
|
321
|
+
def show(self, health: list[Health], counts: dict[str, int] | None) -> None:
|
|
322
|
+
"""``health`` holds one entry per source, in configuration order."""
|
|
323
|
+
for index, item in enumerate(health):
|
|
324
|
+
pill = self.query_one(f"#pill-{index}", Static)
|
|
325
|
+
mark = SYMBOLS.live if item.word in ("live", "paused") else "!"
|
|
326
|
+
text = f"{item.word} {human_seconds(item.age)}" if item.age is not None else item.word
|
|
327
|
+
pill.update(Content.assemble((mark, "b"), f" {clean(item.label)} ", (text, "b")))
|
|
328
|
+
pill.set_classes(f"pill {item.word}")
|
|
329
|
+
for name, words in (("long", True), ("short", False)):
|
|
330
|
+
parts: list[Any] = []
|
|
331
|
+
if counts is not None:
|
|
332
|
+
for key, _, state in GROUPS:
|
|
333
|
+
parts += [glyph(state), f" {counts[key]} {key}" if words else f" {counts[key]}"]
|
|
334
|
+
parts.append(" " if words else " ")
|
|
335
|
+
self.query_one(f"#counts-{name}", Static).update(Content.assemble(*parts[:-1]))
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
# --- queue strip ----------------------------------------------------------- #
|
|
339
|
+
|
|
340
|
+
CHIP_MIN_WIDTH = 26
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
def chain_order(records: list[dict]) -> list[dict]:
|
|
344
|
+
"""Queue order: active holders without a position first, then by position."""
|
|
345
|
+
|
|
346
|
+
def key(record: dict) -> tuple[int, float]:
|
|
347
|
+
position = record.get("position")
|
|
348
|
+
if isinstance(position, int):
|
|
349
|
+
return (1, position)
|
|
350
|
+
return (0 if record["state"] == "running" else 2, 0)
|
|
351
|
+
|
|
352
|
+
return sorted(records, key=key)
|
|
353
|
+
|
|
354
|
+
|
|
355
|
+
class QueueChip(Static):
|
|
356
|
+
"""One queue entry: bordered, with its position in the border title.
|
|
357
|
+
|
|
358
|
+
On short terminals (the app's ``-short`` class) it is one borderless line instead.
|
|
359
|
+
"""
|
|
360
|
+
|
|
361
|
+
def __init__(self, source: str, record: dict, change: Change | None, holder: bool) -> None:
|
|
362
|
+
super().__init__(classes="chip holder" if holder else "chip")
|
|
363
|
+
self.source, self.record, self.change, self.holder = source, record, change, holder
|
|
364
|
+
self.full = self.line = Content("")
|
|
365
|
+
|
|
366
|
+
def on_mount(self) -> None:
|
|
367
|
+
position = self.record.get("position")
|
|
368
|
+
label = str(position) if isinstance(position, int) else raw(self.record)
|
|
369
|
+
label = f"{label} {SYMBOLS.dot} next" if self.holder else label
|
|
370
|
+
# Strings assigned to border_title and tooltip parse as markup; Content does not.
|
|
371
|
+
self.border_title = Content(label)
|
|
372
|
+
title = clean(self.record["title"])
|
|
373
|
+
if self.change and self.change.pr and not title.startswith("#"):
|
|
374
|
+
title = f"#{self.change.pr} {title}"
|
|
375
|
+
eta = eta_of(self.record)
|
|
376
|
+
tail: list[Any] = [muted(f" {SYMBOLS.dot} "), (eta_text(eta), "b")] if eta else []
|
|
377
|
+
if not eta and self.record.get("owner"):
|
|
378
|
+
tail = [muted(f" {SYMBOLS.dot} {clean(self.record['owner'])}")]
|
|
379
|
+
state = glyph(self.record["state"])
|
|
380
|
+
self.full = Content.assemble((title, "b"), "\n", state, f" {raw(self.record)}", *tail)
|
|
381
|
+
self.line = Content.assemble(muted(f"{label} "), state, " ", (title, "b"), *tail)
|
|
382
|
+
self.tooltip = Content(f"{title}\n{raw(self.record)}")
|
|
383
|
+
self.refresh()
|
|
384
|
+
|
|
385
|
+
def render(self) -> Content:
|
|
386
|
+
return self.line if self.app.has_class("-short") else self.full
|
|
387
|
+
|
|
388
|
+
def on_click(self) -> None:
|
|
389
|
+
self.post_message(ChipClicked(self.source, self.record["id"]))
|
|
390
|
+
|
|
391
|
+
|
|
392
|
+
DEPTH_SAMPLES = 120 # one per fetch: half an hour at the default 15 s interval
|
|
393
|
+
SPARK_CELLS = 20
|
|
394
|
+
|
|
395
|
+
|
|
396
|
+
class QueueStrip(Vertical):
|
|
397
|
+
"""Header (count, ETA, depth and attempt trends) over a FIFO chain of chips for one source."""
|
|
398
|
+
|
|
399
|
+
def __init__(self, source: str, label: str) -> None:
|
|
400
|
+
super().__init__(classes="queue")
|
|
401
|
+
self.source, self.label = source, label
|
|
402
|
+
self.entries: list[dict] = []
|
|
403
|
+
self.attempts: list[dict] = [] # with a duration: the "took" trend
|
|
404
|
+
self.merges: list[float] = [] # finish times of every successful attempt
|
|
405
|
+
self.history = False
|
|
406
|
+
self.by_record: dict[str, Change] = {}
|
|
407
|
+
self.built: tuple | None = None
|
|
408
|
+
# Queue length after each fetch this session; sources keep no history of it.
|
|
409
|
+
self.depth: deque[int] = deque(maxlen=DEPTH_SAMPLES)
|
|
410
|
+
|
|
411
|
+
def compose(self) -> ComposeResult:
|
|
412
|
+
with Horizontal(classes="queue-head"):
|
|
413
|
+
yield Static(classes="queue-title")
|
|
414
|
+
yield Static(classes="queue-eta")
|
|
415
|
+
yield Static(dim("depth"), classes="depth spark-label")
|
|
416
|
+
yield Sparkline([], classes="depth spark")
|
|
417
|
+
yield Static(classes="rate")
|
|
418
|
+
yield Static(dim("took"), classes="took spark-label")
|
|
419
|
+
yield Sparkline([], classes="took spark")
|
|
420
|
+
yield Static(classes="took took-stats")
|
|
421
|
+
yield Horizontal(classes="chain")
|
|
422
|
+
|
|
423
|
+
def sample(self, records: list[dict]) -> None:
|
|
424
|
+
"""Record the queue length of one fresh snapshot."""
|
|
425
|
+
self.depth.append(sum(r["kind"] == "queue_entry" for r in records))
|
|
426
|
+
|
|
427
|
+
async def show(self, records: list[dict], by_record: dict[str, Change]) -> None:
|
|
428
|
+
self.entries = chain_order([r for r in records if r["kind"] == "queue_entry"])
|
|
429
|
+
self.attempts = sorted(
|
|
430
|
+
(r for r in records if r["kind"] == "attempt" and elapsed(r) is not None),
|
|
431
|
+
key=lambda r: stamp(r, "finished_at", "started_at"),
|
|
432
|
+
)
|
|
433
|
+
# Merge counts need only a finish time; a log may omit start times and durations.
|
|
434
|
+
finished = [r for r in records if r["kind"] == "attempt" and stamp(r, "finished_at")]
|
|
435
|
+
self.history = bool(finished)
|
|
436
|
+
self.merges = [stamp(r, "finished_at") for r in finished if r["state"] == "success"]
|
|
437
|
+
self.by_record = by_record
|
|
438
|
+
self.head()
|
|
439
|
+
await self.chain()
|
|
440
|
+
|
|
441
|
+
def head(self) -> None:
|
|
442
|
+
waiting = len(self.entries)
|
|
443
|
+
status = f" {waiting} waiting"
|
|
444
|
+
if not waiting:
|
|
445
|
+
status = " nothing waiting"
|
|
446
|
+
if self.merges:
|
|
447
|
+
last = short_age(now() - max(self.merges))
|
|
448
|
+
status += f" {SYMBOLS.dot} last merge {last} ago"
|
|
449
|
+
self.query_one(".queue-title", Static).update(
|
|
450
|
+
Content.assemble((clean(self.label).upper(), "b"), muted(status))
|
|
451
|
+
)
|
|
452
|
+
clears = max((eta_of(e) or 0 for e in self.entries), default=0)
|
|
453
|
+
self.query_one(".queue-eta", Static).update(
|
|
454
|
+
Content.assemble(muted("clears in "), (eta_text(clears), "b")) if clears else ""
|
|
455
|
+
)
|
|
456
|
+
# Visibility is by class so that the narrow layout's CSS can still hide sparklines.
|
|
457
|
+
self.set_class(len(self.depth) > 1, "-depth")
|
|
458
|
+
if len(self.depth) > 1:
|
|
459
|
+
depth = self.query_one(".depth.spark", Sparkline)
|
|
460
|
+
depth.data = list(self.depth)
|
|
461
|
+
depth.styles.width = min(len(self.depth), SPARK_CELLS)
|
|
462
|
+
merged = sum(now() - finished <= 3600 for finished in self.merges)
|
|
463
|
+
self.query_one(".rate", Static).update(
|
|
464
|
+
Content.assemble(glyph("success"), muted(f" {merged} merged/hr"))
|
|
465
|
+
if self.history
|
|
466
|
+
else ""
|
|
467
|
+
)
|
|
468
|
+
took = [elapsed(a) for a in self.attempts[-30:]]
|
|
469
|
+
failed = sum(a["state"] == "failure" for a in self.attempts[-30:])
|
|
470
|
+
self.set_class(len(took) > 1, "-took")
|
|
471
|
+
if len(took) > 1:
|
|
472
|
+
trend = self.query_one(".took.spark", Sparkline)
|
|
473
|
+
trend.data = took
|
|
474
|
+
trend.styles.width = min(len(took), SPARK_CELLS)
|
|
475
|
+
self.query_one(".took-stats", Static).update(
|
|
476
|
+
Content.assemble(
|
|
477
|
+
muted(
|
|
478
|
+
f" median {human_seconds(statistics.median(took))} {SYMBOLS.dot} "
|
|
479
|
+
f"max {human_seconds(max(took))} {SYMBOLS.dot} "
|
|
480
|
+
),
|
|
481
|
+
glyph("failure"),
|
|
482
|
+
muted(f" {failed}/{len(took)}"),
|
|
483
|
+
)
|
|
484
|
+
)
|
|
485
|
+
|
|
486
|
+
async def chain(self) -> None:
|
|
487
|
+
chain = self.query_one(".chain", Horizontal)
|
|
488
|
+
width = chain.size.width or self.app.size.width
|
|
489
|
+
room = max(1, (width + 3) // (CHIP_MIN_WIDTH + 3))
|
|
490
|
+
shown = self.entries if len(self.entries) <= room else self.entries[: max(1, room - 1)]
|
|
491
|
+
signature = (
|
|
492
|
+
width,
|
|
493
|
+
tuple(
|
|
494
|
+
(e["id"], e["state"], e.get("position"), e["title"], eta_of(e), e.get("owner"))
|
|
495
|
+
for e in shown
|
|
496
|
+
),
|
|
497
|
+
len(self.entries),
|
|
498
|
+
tuple(e["id"] in self.by_record for e in shown),
|
|
499
|
+
)
|
|
500
|
+
if signature == self.built:
|
|
501
|
+
return
|
|
502
|
+
self.built = signature
|
|
503
|
+
widgets: list[Static] = []
|
|
504
|
+
for index, entry in enumerate(shown):
|
|
505
|
+
if index:
|
|
506
|
+
widgets.append(Static(SYMBOLS.arrow, classes="link"))
|
|
507
|
+
widgets.append(
|
|
508
|
+
QueueChip(self.source, entry, self.by_record.get(entry["id"]), holder=index == 0)
|
|
509
|
+
)
|
|
510
|
+
if len(shown) < len(self.entries):
|
|
511
|
+
widgets.append(Static(f"+{len(self.entries) - len(shown)} more", classes="more"))
|
|
512
|
+
await chain.remove_children()
|
|
513
|
+
await chain.mount_all(widgets)
|
|
514
|
+
chain.display = bool(widgets)
|
|
515
|
+
|
|
516
|
+
async def on_resize(self) -> None:
|
|
517
|
+
if self.built is not None and self.built[0] != self.query_one(".chain").size.width:
|
|
518
|
+
await self.chain()
|
|
519
|
+
|
|
520
|
+
|
|
521
|
+
# --- changes table --------------------------------------------------------- #
|
|
522
|
+
|
|
523
|
+
|
|
524
|
+
@dataclass
|
|
525
|
+
class Column:
|
|
526
|
+
key: str
|
|
527
|
+
label: str
|
|
528
|
+
width: int # 0 takes the remaining space
|
|
529
|
+
align: str = "left"
|
|
530
|
+
|
|
531
|
+
|
|
532
|
+
def change_columns(width: int, queue: bool, comfortable: bool = False) -> list[Column]:
|
|
533
|
+
"""Columns for a table ``width`` cells wide; narrow tables keep the essentials.
|
|
534
|
+
|
|
535
|
+
Comfortable rows carry title, owner and PR on a second line, so those columns go.
|
|
536
|
+
"""
|
|
537
|
+
roomy = width >= 96
|
|
538
|
+
wide = roomy and not comfortable
|
|
539
|
+
cols = [Column("glyph", "", 1), Column("ref", "CHANGE", 18 if roomy else 16)]
|
|
540
|
+
if wide:
|
|
541
|
+
cols += [Column("pr", "PR", 6), Column("title", "TITLE", 0), Column("owner", "OWNER", 10)]
|
|
542
|
+
cols.append(Column("ci", "CI", 20 if queue else 26))
|
|
543
|
+
if queue:
|
|
544
|
+
cols.append(Column("queue", "QUEUE", 24 if roomy else 16))
|
|
545
|
+
cols.append(Column("age", "AGE", 4, "right"))
|
|
546
|
+
flex = width - sum(c.width for c in cols) - (len(cols) - 1)
|
|
547
|
+
if not wide or flex < 12:
|
|
548
|
+
cols = [c for c in cols if c.key not in ("title", "owner")]
|
|
549
|
+
flex = width - sum(c.width for c in cols) - (len(cols) - 1)
|
|
550
|
+
if flex >= 0:
|
|
551
|
+
cols[1].width += flex
|
|
552
|
+
# Too narrow: give back cells from the text columns, down to a readable minimum.
|
|
553
|
+
for col in cols:
|
|
554
|
+
floor = {"ref": 8, "ci": 10, "queue": 8}.get(col.key, col.width)
|
|
555
|
+
take = min(-flex, col.width - floor) if flex < 0 else 0
|
|
556
|
+
col.width -= take
|
|
557
|
+
flex += take
|
|
558
|
+
for col in cols:
|
|
559
|
+
if col.width == 0:
|
|
560
|
+
col.width = flex
|
|
561
|
+
return cols
|
|
562
|
+
|
|
563
|
+
|
|
564
|
+
def change_cell(col: Column, change: Change, compact: bool) -> Content:
|
|
565
|
+
match col.key:
|
|
566
|
+
case "glyph":
|
|
567
|
+
return Content.assemble(glyph(change.state))
|
|
568
|
+
case "ref":
|
|
569
|
+
name = change.ref or change.key[:7]
|
|
570
|
+
return Content.assemble((clean(name), "b"))
|
|
571
|
+
case "pr":
|
|
572
|
+
return dim(f"#{change.pr}" if change.pr else "")
|
|
573
|
+
case "title":
|
|
574
|
+
return Content(clean(change.title))
|
|
575
|
+
case "owner":
|
|
576
|
+
return dim(clean(change.owner or ""))
|
|
577
|
+
case "ci":
|
|
578
|
+
return ci_status(change)
|
|
579
|
+
case "queue":
|
|
580
|
+
return Content.assemble(
|
|
581
|
+
muted(f"{SYMBOLS.arrow} "), queue_status(change, "Q" if compact else "Queue")
|
|
582
|
+
)
|
|
583
|
+
case "age":
|
|
584
|
+
return dim(short_age(ago(change.activity)))
|
|
585
|
+
return Content("")
|
|
586
|
+
|
|
587
|
+
|
|
588
|
+
def change_detail(change: Change, width: int) -> Content:
|
|
589
|
+
"""Second line of a comfortable row: title, owner and PR under the change name."""
|
|
590
|
+
parts = [
|
|
591
|
+
part
|
|
592
|
+
for part in (
|
|
593
|
+
clean(change.title or ""),
|
|
594
|
+
clean(change.owner or ""),
|
|
595
|
+
f"#{change.pr}" if change.pr else "",
|
|
596
|
+
)
|
|
597
|
+
if part
|
|
598
|
+
]
|
|
599
|
+
indent = 2 # under the change name, past the glyph column
|
|
600
|
+
detail = dim(f" {SYMBOLS.dot} ".join(parts) or SYMBOLS.dash)
|
|
601
|
+
return Content.assemble(" " * indent, fit(detail, width - indent, "left"))
|
|
602
|
+
|
|
603
|
+
|
|
604
|
+
class ChangeTable(Vertical):
|
|
605
|
+
"""Header line over an OptionList of group rows and change rows.
|
|
606
|
+
|
|
607
|
+
Enter or a click on a group row folds or unfolds it. Done starts folded whenever
|
|
608
|
+
another group has changes; a group the user has toggled keeps that choice.
|
|
609
|
+
"""
|
|
610
|
+
|
|
611
|
+
def __init__(self, density: str = "compact") -> None:
|
|
612
|
+
super().__init__(id="changes")
|
|
613
|
+
self.changes: list[Change] = []
|
|
614
|
+
self.queue = False
|
|
615
|
+
self.selected: str | None = None
|
|
616
|
+
self.width = 0
|
|
617
|
+
self.empty = "Waiting for sources..."
|
|
618
|
+
self.density = density
|
|
619
|
+
self.folds: dict[str, bool] = {} # explicit user choices by group key
|
|
620
|
+
|
|
621
|
+
def folded(self, key: str) -> bool:
|
|
622
|
+
if key in self.folds:
|
|
623
|
+
return self.folds[key]
|
|
624
|
+
others = any(c.group != "done" for c in self.changes)
|
|
625
|
+
return key == "done" and others
|
|
626
|
+
|
|
627
|
+
def compose(self) -> ComposeResult:
|
|
628
|
+
yield Static(id="colhead")
|
|
629
|
+
yield OptionList(id="rows")
|
|
630
|
+
|
|
631
|
+
def on_resize(self) -> None:
|
|
632
|
+
# Options are padded one cell either side; the list's scrollbar is one cell (app CSS).
|
|
633
|
+
width = self.size.width - 3
|
|
634
|
+
if width != self.width and width > 20:
|
|
635
|
+
self.width = width
|
|
636
|
+
self.rebuild()
|
|
637
|
+
|
|
638
|
+
def show(self, changes: list[Change], queue: bool, selected: str | None, empty: str) -> None:
|
|
639
|
+
self.changes, self.queue, self.selected, self.empty = changes, queue, selected, empty
|
|
640
|
+
self.rebuild()
|
|
641
|
+
|
|
642
|
+
def rebuild(self) -> None:
|
|
643
|
+
if not self.width:
|
|
644
|
+
return
|
|
645
|
+
comfortable = self.density == "comfortable"
|
|
646
|
+
cols = change_columns(self.width, self.queue, comfortable)
|
|
647
|
+
compact = self.width < 96
|
|
648
|
+
head = Content(" ").join(
|
|
649
|
+
fit(Content.assemble(muted(c.label)), c.width, c.align) for c in cols
|
|
650
|
+
)
|
|
651
|
+
self.query_one("#colhead", Static).update(head)
|
|
652
|
+
options: list[Option] = []
|
|
653
|
+
for key, label, state, changes in grouped(self.changes):
|
|
654
|
+
if not changes:
|
|
655
|
+
continue
|
|
656
|
+
folded = self.folded(key)
|
|
657
|
+
marker = SYMBOLS.folded if folded else SYMBOLS.unfolded
|
|
658
|
+
title = Content.assemble(
|
|
659
|
+
glyph(state),
|
|
660
|
+
" ",
|
|
661
|
+
(label.upper(), f"b {STATE_STYLE[state]}"),
|
|
662
|
+
muted(f" {len(changes)} {marker} "),
|
|
663
|
+
)
|
|
664
|
+
rule = SYMBOLS.rule * max(0, self.width - title.cell_length)
|
|
665
|
+
options.append(Option(Content.assemble(title, muted(rule)), id=f"g:{key}"))
|
|
666
|
+
if folded:
|
|
667
|
+
continue
|
|
668
|
+
for change in changes:
|
|
669
|
+
row = Content(" ").join(
|
|
670
|
+
fit(change_cell(c, change, compact), c.width, c.align) for c in cols
|
|
671
|
+
)
|
|
672
|
+
if comfortable:
|
|
673
|
+
row = Content("\n").join([row, change_detail(change, self.width)])
|
|
674
|
+
options.append(Option(row, id=f"c:{change.key}"))
|
|
675
|
+
if not options:
|
|
676
|
+
options.append(Option(dim(self.empty), id="g:empty", disabled=True))
|
|
677
|
+
rows = self.query_one("#rows", OptionList)
|
|
678
|
+
rows.clear_options()
|
|
679
|
+
rows.add_options(options)
|
|
680
|
+
self.highlight(self.selected)
|
|
681
|
+
|
|
682
|
+
def highlight(self, key: str | None, reveal: bool = False) -> None:
|
|
683
|
+
"""Move the cursor to a change; ``reveal`` unfolds its group if needed.
|
|
684
|
+
|
|
685
|
+
A change in a folded group puts the cursor on that group's row, so a rebuild
|
|
686
|
+
never leaves the list without a cursor.
|
|
687
|
+
"""
|
|
688
|
+
if key is None:
|
|
689
|
+
return
|
|
690
|
+
change = next((c for c in self.changes if c.key == key), None)
|
|
691
|
+
if reveal and change and self.folded(change.group):
|
|
692
|
+
self.folds[change.group] = False
|
|
693
|
+
self.rebuild()
|
|
694
|
+
rows = self.query_one("#rows", OptionList)
|
|
695
|
+
targets = [f"c:{key}"] + ([f"g:{change.group}"] if change else [])
|
|
696
|
+
for target in targets:
|
|
697
|
+
try:
|
|
698
|
+
index = rows.get_option_index(target)
|
|
699
|
+
except OptionDoesNotExist:
|
|
700
|
+
continue
|
|
701
|
+
if rows.highlighted != index:
|
|
702
|
+
rows.highlighted = index
|
|
703
|
+
return
|
|
704
|
+
|
|
705
|
+
def toggle(self, group: str) -> None:
|
|
706
|
+
self.folds[group] = not self.folded(group)
|
|
707
|
+
self.rebuild()
|
|
708
|
+
rows = self.query_one("#rows", OptionList)
|
|
709
|
+
rows.highlighted = rows.get_option_index(f"g:{group}")
|
|
710
|
+
|
|
711
|
+
@on(OptionList.OptionHighlighted)
|
|
712
|
+
@on(OptionList.OptionSelected)
|
|
713
|
+
def _highlighted(self, event) -> None:
|
|
714
|
+
event.stop()
|
|
715
|
+
rows = event.option_list
|
|
716
|
+
# Highlight events arrive asynchronously; one queued by a rebuild can land after a
|
|
717
|
+
# newer selection (a chip click) and must not drag the selection back.
|
|
718
|
+
current = rows.highlighted
|
|
719
|
+
if current is None or rows.get_option_at_index(current).id != event.option_id:
|
|
720
|
+
return
|
|
721
|
+
option_id = event.option_id or ""
|
|
722
|
+
if option_id.startswith("c:"):
|
|
723
|
+
self.post_message(ChangeSelected(option_id[2:]))
|
|
724
|
+
elif option_id.startswith("g:") and isinstance(event, OptionList.OptionSelected):
|
|
725
|
+
self.toggle(option_id[2:])
|
|
726
|
+
|
|
727
|
+
|
|
728
|
+
# --- inspector ------------------------------------------------------------- #
|
|
729
|
+
|
|
730
|
+
|
|
731
|
+
class Inspector(VerticalScroll):
|
|
732
|
+
"""Detail for the selection: a head, Open and Copy SHA, then titled sections."""
|
|
733
|
+
|
|
734
|
+
def __init__(self) -> None:
|
|
735
|
+
super().__init__(id="inspector")
|
|
736
|
+
self.pairs: list[tuple[Static, Static]] = []
|
|
737
|
+
|
|
738
|
+
def compose(self) -> ComposeResult:
|
|
739
|
+
yield Static(dim("Select a change to inspect it."), id="insp-head")
|
|
740
|
+
with Horizontal(id="insp-actions"):
|
|
741
|
+
yield Button("Open", id="open", compact=True, disabled=True)
|
|
742
|
+
yield Button("Copy SHA", id="copy", compact=True, disabled=True)
|
|
743
|
+
yield Vertical(id="sections")
|
|
744
|
+
|
|
745
|
+
async def show(
|
|
746
|
+
self,
|
|
747
|
+
head: Content,
|
|
748
|
+
sections: list[tuple[Content, Content]],
|
|
749
|
+
*,
|
|
750
|
+
url: bool,
|
|
751
|
+
sha: bool,
|
|
752
|
+
reset: bool,
|
|
753
|
+
) -> None:
|
|
754
|
+
self.query_one("#insp-head", Static).update(head)
|
|
755
|
+
self.query_one("#open", Button).disabled = not url
|
|
756
|
+
self.query_one("#copy", Button).disabled = not sha
|
|
757
|
+
holder = self.query_one("#sections", Vertical)
|
|
758
|
+
while len(self.pairs) < len(sections):
|
|
759
|
+
pair = (Static(classes="section-title"), Static(classes="section-body"))
|
|
760
|
+
self.pairs.append(pair)
|
|
761
|
+
await holder.mount_all(pair)
|
|
762
|
+
for index, (title, body) in enumerate(self.pairs):
|
|
763
|
+
visible = index < len(sections)
|
|
764
|
+
title.display = body.display = visible
|
|
765
|
+
if visible:
|
|
766
|
+
title.update(sections[index][0])
|
|
767
|
+
body.update(sections[index][1])
|
|
768
|
+
if reset:
|
|
769
|
+
self.scroll_home(animate=False)
|
|
770
|
+
|
|
771
|
+
def section(self, index: int, body: Content) -> None:
|
|
772
|
+
"""Replace one section body (lazy details) without touching the others."""
|
|
773
|
+
if index < len(self.pairs):
|
|
774
|
+
self.pairs[index][1].update(body)
|