threadmill 0.4.0__tar.gz → 0.5.0__tar.gz

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.
Files changed (23) hide show
  1. {threadmill-0.4.0 → threadmill-0.5.0}/PKG-INFO +1 -1
  2. {threadmill-0.4.0 → threadmill-0.5.0}/threadmill/_version.py +3 -3
  3. {threadmill-0.4.0 → threadmill-0.5.0}/threadmill/backends/base.py +18 -1
  4. {threadmill-0.4.0 → threadmill-0.5.0}/threadmill/backends/lua/acquire.lua +1 -1
  5. {threadmill-0.4.0 → threadmill-0.5.0}/threadmill/backends/redis.py +68 -65
  6. {threadmill-0.4.0 → threadmill-0.5.0}/threadmill/inspector/app.py +102 -7
  7. {threadmill-0.4.0 → threadmill-0.5.0}/threadmill/inspector/inspector.scss +31 -0
  8. threadmill-0.5.0/threadmill/inspector/screens.py +52 -0
  9. {threadmill-0.4.0 → threadmill-0.5.0}/LICENSE +0 -0
  10. {threadmill-0.4.0 → threadmill-0.5.0}/README.md +0 -0
  11. {threadmill-0.4.0 → threadmill-0.5.0}/pyproject.toml +0 -0
  12. {threadmill-0.4.0 → threadmill-0.5.0}/threadmill/__init__.py +0 -0
  13. {threadmill-0.4.0 → threadmill-0.5.0}/threadmill/backends/__init__.py +0 -0
  14. {threadmill-0.4.0 → threadmill-0.5.0}/threadmill/backends/lua/acknowledge.lua +0 -0
  15. {threadmill-0.4.0 → threadmill-0.5.0}/threadmill/backends/lua/mover.lua +0 -0
  16. {threadmill-0.4.0 → threadmill-0.5.0}/threadmill/backends/lua/reaper.lua +0 -0
  17. {threadmill-0.4.0 → threadmill-0.5.0}/threadmill/exceptions.py +0 -0
  18. {threadmill-0.4.0 → threadmill-0.5.0}/threadmill/executor.py +0 -0
  19. {threadmill-0.4.0 → threadmill-0.5.0}/threadmill/inspector/__init__.py +0 -0
  20. {threadmill-0.4.0 → threadmill-0.5.0}/threadmill/management/__init__.py +0 -0
  21. {threadmill-0.4.0 → threadmill-0.5.0}/threadmill/management/commands/__init__.py +0 -0
  22. {threadmill-0.4.0 → threadmill-0.5.0}/threadmill/management/commands/threadmill.py +0 -0
  23. {threadmill-0.4.0 → threadmill-0.5.0}/threadmill/retry.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: threadmill
3
- Version: 0.4.0
3
+ Version: 0.5.0
4
4
  Summary: The most reliable backend for Django's task framework.
5
5
  Keywords: Django,tasks,worker
6
6
  Author-email: Johannes Maron <johannes@maron.family>
@@ -18,7 +18,7 @@ version_tuple: tuple[int | str, ...]
18
18
  commit_id: str | None
19
19
  __commit_id__: str | None
20
20
 
21
- __version__ = version = '0.4.0'
22
- __version_tuple__ = version_tuple = (0, 4, 0)
21
+ __version__ = version = '0.5.0'
22
+ __version_tuple__ = version_tuple = (0, 5, 0)
23
23
 
24
- __commit_id__ = commit_id = 'gf9749f870'
24
+ __commit_id__ = commit_id = 'g190acbe27'
@@ -217,7 +217,12 @@ class ThreadmillTaskBackend(BaseTaskBackend, ABC):
217
217
  raise NotImplementedError
218
218
 
219
219
  def requeue(self, task_result: TaskResult, run_after: datetime.datetime) -> None:
220
- """Re-queue a failed task result for a retry attempt after `run_after`."""
220
+ """Re-queue a failed task result for a retry attempt after `run_after`.
221
+
222
+ Cleans up any persisted failed result so the method works both for
223
+ in-flight retries (task still running) and inspector-driven requeues
224
+ of already-failed tasks.
225
+ """
221
226
  raise NotImplementedError
222
227
 
223
228
  def peek(
@@ -246,3 +251,15 @@ class ThreadmillTaskBackend(BaseTaskBackend, ABC):
246
251
  interval: The time window for rolling rates.
247
252
  """
248
253
  raise NotImplementedError
254
+
255
+ def dequeue(self, task_result: TaskResult) -> None:
256
+ """Delete a single task from its current status segment."""
257
+ raise NotImplementedError
258
+
259
+ def purge(self, queue_name: str) -> None:
260
+ """Delete every task across all segments of a queue.
261
+
262
+ Args:
263
+ queue_name: The queue to purge.
264
+ """
265
+ raise NotImplementedError
@@ -7,7 +7,7 @@
7
7
  -- KEYS[4] = queue, etc.
8
8
  -- ARGV[1] -- current time in milliseconds
9
9
  -- ARGV[2] -- current time as ISO-8601 string
10
- -- ARGV[3] -- task key prefix (e.g. "threadmill:default:task:")
10
+ -- ARGV[3] -- task key prefix (e.g. "threadmill:task:")
11
11
  -- ARGV[4] -- number of queue pairs (N/2)
12
12
  -- ARGV[5] -- worker name
13
13
  -- ARGV[6] -- lease TTL in milliseconds
@@ -55,14 +55,12 @@ class RedisBroker(Broker):
55
55
  deferred_key = self.backend.DEFERRED_KEY.format(
56
56
  prefix=self.backend.key_prefix, queue_name=queue_name
57
57
  )
58
- queue_key = self.backend.QUEUE_KEY.format(
59
- prefix=self.backend.key_prefix, queue_name=queue_name
60
- )
58
+ queue_key = self.backend._segment_key(TaskResultStatus.READY, queue_name)
61
59
  self._mover_script(
62
60
  keys=[deferred_key, queue_key],
63
61
  args=[
64
62
  str(time.time() * 1000),
65
- self.backend.key_prefix + ":task:",
63
+ f"{self.backend.key_prefix}:task:",
66
64
  str(self.backend.batch_size),
67
65
  ],
68
66
  )
@@ -72,18 +70,16 @@ class RedisBroker(Broker):
72
70
  now = timezone.now()
73
71
  now_ms = now.timestamp() * 1000
74
72
  finished_at_iso = now.isoformat()
75
- running_key = self.backend.RUNNING_KEY.format(
76
- prefix=self.backend.key_prefix, queue_name=queue_name
77
- )
78
- failed_results_key = self.backend.FAILED_RESULTS_KEY.format(
79
- prefix=self.backend.key_prefix, queue_name=queue_name
73
+ running_key = self.backend._segment_key(TaskResultStatus.RUNNING, queue_name)
74
+ failed_results_key = self.backend._segment_key(
75
+ TaskResultStatus.FAILED, queue_name
80
76
  )
81
77
  self._reaper_script(
82
78
  keys=[running_key, failed_results_key],
83
79
  args=[
84
80
  str(now_ms),
85
- self.backend.key_prefix + ":task:",
86
- self.backend.key_prefix + ":result:",
81
+ f"{self.backend.key_prefix}:task:",
82
+ f"{self.backend.key_prefix}:result:",
87
83
  str(self.backend.batch_size),
88
84
  str(int(self.backend.result_ttl.total_seconds())),
89
85
  finished_at_iso,
@@ -125,20 +121,25 @@ class RedisTaskBackend(ThreadmillTaskBackend):
125
121
 
126
122
  broker_class = RedisBroker
127
123
 
128
- QUEUE_KEY = "{prefix}:queue:{queue_name}"
129
- RUNNING_KEY = "{prefix}:running:{queue_name}"
130
- DEFERRED_KEY = "{prefix}:deferred:{queue_name}"
131
124
  TASK_KEY = "{prefix}:task:{task_id}"
132
125
  RESULT_KEY = "{prefix}:result:{result_id}"
133
- SUCCESSFUL_RESULTS_KEY = "{prefix}:results:{queue_name}:successful"
134
- FAILED_RESULTS_KEY = "{prefix}:results:{queue_name}:failed"
135
- INGRESS_KEY = "{prefix}:ingress:{queue_name}:events"
126
+ SEGMENT_KEY = "{prefix}:{queue_name}:{status}"
127
+ DEFERRED_KEY = "{prefix}:{queue_name}:deferred"
128
+
129
+ INGRESS_KEY = "{prefix}:{queue_name}:ingress:events"
136
130
 
137
131
  ACQUIRE_SCRIPT = _load_lua("acquire")
138
132
  """Pop the next task from a priority queue and move it directly to the running set."""
139
133
  ACKNOWLEDGE_SCRIPT = _load_lua("acknowledge")
140
134
  """Remove from running, persist the result, and clean up."""
141
135
 
136
+ def _segment_key(self, status: TaskResultStatus, queue_name: str) -> str:
137
+ return self.SEGMENT_KEY.format(
138
+ prefix=self.key_prefix,
139
+ queue_name=queue_name,
140
+ status=status.value.lower(),
141
+ )
142
+
142
143
  def __init__(self, alias: str, params: dict) -> None:
143
144
  super().__init__(alias=alias, params=params)
144
145
 
@@ -224,9 +225,7 @@ class RedisTaskBackend(ThreadmillTaskBackend):
224
225
  run_after_ms = task.run_after.timestamp() * 1000
225
226
  pipe.zadd(deferred_key, {task_result.id: run_after_ms})
226
227
  else:
227
- queue_key = self.QUEUE_KEY.format(
228
- prefix=self.key_prefix, queue_name=task.queue_name
229
- )
228
+ queue_key = self._segment_key(TaskResultStatus.READY, task.queue_name)
230
229
  pipe.zadd(queue_key, {task_result.id: score})
231
230
 
232
231
  pipe.execute()
@@ -246,8 +245,8 @@ class RedisTaskBackend(ThreadmillTaskBackend):
246
245
  key
247
246
  for queue_name in queue_names
248
247
  for key in (
249
- self.RUNNING_KEY.format(prefix=self.key_prefix, queue_name=queue_name),
250
- self.QUEUE_KEY.format(prefix=self.key_prefix, queue_name=queue_name),
248
+ self._segment_key(TaskResultStatus.RUNNING, queue_name),
249
+ self._segment_key(TaskResultStatus.READY, queue_name),
251
250
  )
252
251
  ]
253
252
 
@@ -261,7 +260,7 @@ class RedisTaskBackend(ThreadmillTaskBackend):
261
260
  args=[
262
261
  str(now_ms),
263
262
  now_iso,
264
- self.key_prefix + ":task:",
263
+ f"{self.key_prefix}:task:",
265
264
  str(len(queue_names)),
266
265
  worker,
267
266
  str(int(self.lease_ttl.total_seconds() * 1000)),
@@ -281,18 +280,18 @@ class RedisTaskBackend(ThreadmillTaskBackend):
281
280
 
282
281
  def acknowledge(self, task_result: TaskResult) -> None:
283
282
  serialized = self.serialize_task_result(task_result)
284
- running_key = self.RUNNING_KEY.format(
285
- prefix=self.key_prefix, queue_name=task_result.task.queue_name
283
+ running_key = self._segment_key(
284
+ TaskResultStatus.RUNNING, task_result.task.queue_name
286
285
  )
287
286
  result_key = self.RESULT_KEY.format(
288
287
  prefix=self.key_prefix, result_id=task_result.id
289
288
  )
290
289
  task_key = self.TASK_KEY.format(prefix=self.key_prefix, task_id=task_result.id)
291
- successful_results_key = self.SUCCESSFUL_RESULTS_KEY.format(
292
- prefix=self.key_prefix, queue_name=task_result.task.queue_name
290
+ successful_results_key = self._segment_key(
291
+ TaskResultStatus.SUCCESSFUL, task_result.task.queue_name
293
292
  )
294
- failed_results_key = self.FAILED_RESULTS_KEY.format(
295
- prefix=self.key_prefix, queue_name=task_result.task.queue_name
293
+ failed_results_key = self._segment_key(
294
+ TaskResultStatus.FAILED, task_result.task.queue_name
296
295
  )
297
296
  finished_at = task_result.finished_at or timezone.now()
298
297
  finish_score = finished_at.timestamp() * 1000
@@ -322,13 +321,19 @@ class RedisTaskBackend(ThreadmillTaskBackend):
322
321
  finished_at=None,
323
322
  )
324
323
  serialized = self.serialize_task_result(task_result)
325
- running_key = self.RUNNING_KEY.format(
326
- prefix=self.key_prefix, queue_name=task_result.task.queue_name
324
+ running_key = self._segment_key(
325
+ TaskResultStatus.RUNNING, task_result.task.queue_name
327
326
  )
328
327
  deferred_key = self.DEFERRED_KEY.format(
329
328
  prefix=self.key_prefix, queue_name=task_result.task.queue_name
330
329
  )
330
+ failed_key = self._segment_key(
331
+ TaskResultStatus.FAILED, task_result.task.queue_name
332
+ )
331
333
  task_key = self.TASK_KEY.format(prefix=self.key_prefix, task_id=task_result.id)
334
+ result_key = self.RESULT_KEY.format(
335
+ prefix=self.key_prefix, result_id=task_result.id
336
+ )
332
337
  score = self._compute_score(task_result.task.priority, task_result.enqueued_at)
333
338
  run_after_ms = run_after.timestamp() * 1000
334
339
  task_data_ttl = int(
@@ -337,11 +342,26 @@ class RedisTaskBackend(ThreadmillTaskBackend):
337
342
 
338
343
  pipe = self.client.pipeline()
339
344
  pipe.zrem(running_key, task_result.id)
345
+ pipe.zrem(failed_key, task_result.id)
346
+ pipe.delete(result_key)
340
347
  pipe.hset(task_key, mapping={"data": serialized, "score": str(score)})
341
348
  pipe.expire(task_key, task_data_ttl)
342
349
  pipe.zadd(deferred_key, {task_result.id: run_after_ms})
343
350
  pipe.execute()
344
351
 
352
+ def dequeue(self, task_result: TaskResult) -> None:
353
+ self.client.zrem(
354
+ self._segment_key(task_result.status, task_result.task.queue_name),
355
+ task_result.id,
356
+ )
357
+
358
+ def purge(self, queue_name: str) -> None:
359
+ pattern = f"{self.key_prefix}:{queue_name}:*"
360
+ pipe = self.client.pipeline()
361
+ for key in self.client.scan_iter(match=pattern):
362
+ pipe.delete(key)
363
+ pipe.execute()
364
+
345
365
  def peek(
346
366
  self,
347
367
  queue_name: str = DEFAULT_TASK_QUEUE_NAME,
@@ -350,34 +370,27 @@ class RedisTaskBackend(ThreadmillTaskBackend):
350
370
  count: int = 1,
351
371
  ) -> Generator[TaskResult]:
352
372
  match status:
353
- case TaskResultStatus.READY:
373
+ case TaskResultStatus.READY | TaskResultStatus.RUNNING:
354
374
  yield from self._peek(
355
- self.QUEUE_KEY, self.TASK_KEY, queue_name, count, "data"
375
+ self._segment_key(status, queue_name),
376
+ self.TASK_KEY,
377
+ count,
378
+ "data",
356
379
  )
357
- case TaskResultStatus.RUNNING:
380
+ case TaskResultStatus.SUCCESSFUL | TaskResultStatus.FAILED:
358
381
  yield from self._peek(
359
- self.RUNNING_KEY, self.TASK_KEY, queue_name, count, "data"
360
- )
361
- case TaskResultStatus.SUCCESSFUL:
362
- yield from self._peek(
363
- self.SUCCESSFUL_RESULTS_KEY, self.RESULT_KEY, queue_name, count
364
- )
365
- case TaskResultStatus.FAILED:
366
- yield from self._peek(
367
- self.FAILED_RESULTS_KEY, self.RESULT_KEY, queue_name, count
382
+ self._segment_key(status, queue_name),
383
+ self.RESULT_KEY,
384
+ count,
368
385
  )
369
386
 
370
387
  def _peek(
371
388
  self,
372
- zset_key_template: str,
389
+ zset_key: str,
373
390
  data_key_template: str,
374
- queue_name: str,
375
391
  count: int,
376
392
  field: str | None = None,
377
393
  ) -> Generator[TaskResult]:
378
- zset_key = zset_key_template.format(
379
- prefix=self.key_prefix, queue_name=queue_name
380
- )
381
394
  pipe = self.client.pipeline()
382
395
  for member in self.client.zrange(zset_key, 0, count - 1):
383
396
  member_id = member.decode() if isinstance(member, bytes) else member
@@ -412,16 +425,12 @@ class RedisTaskBackend(ThreadmillTaskBackend):
412
425
  cutoff,
413
426
  )
414
427
  pipe.zremrangebyscore(
415
- self.SUCCESSFUL_RESULTS_KEY.format(
416
- prefix=self.key_prefix, queue_name=queue_name
417
- ),
428
+ self._segment_key(TaskResultStatus.SUCCESSFUL, queue_name),
418
429
  0,
419
430
  cutoff,
420
431
  )
421
432
  pipe.zremrangebyscore(
422
- self.FAILED_RESULTS_KEY.format(
423
- prefix=self.key_prefix, queue_name=queue_name
424
- ),
433
+ self._segment_key(TaskResultStatus.FAILED, queue_name),
425
434
  0,
426
435
  cutoff,
427
436
  )
@@ -436,21 +445,15 @@ class RedisTaskBackend(ThreadmillTaskBackend):
436
445
  retention_start_ms = now_ms - self.result_ttl.total_seconds() * 1000
437
446
  pipe = self.client.pipeline()
438
447
  for queue_name in self.queues:
439
- pipe.zcard(
440
- self.QUEUE_KEY.format(prefix=self.key_prefix, queue_name=queue_name)
441
- )
442
- pipe.zcard(
443
- self.RUNNING_KEY.format(prefix=self.key_prefix, queue_name=queue_name)
444
- )
448
+ pipe.zcard(self._segment_key(TaskResultStatus.READY, queue_name))
449
+ pipe.zcard(self._segment_key(TaskResultStatus.RUNNING, queue_name))
445
450
  pipe.zcard(
446
451
  self.DEFERRED_KEY.format(prefix=self.key_prefix, queue_name=queue_name)
447
452
  )
448
- successful_results_key = self.SUCCESSFUL_RESULTS_KEY.format(
449
- prefix=self.key_prefix, queue_name=queue_name
450
- )
451
- failed_results_key = self.FAILED_RESULTS_KEY.format(
452
- prefix=self.key_prefix, queue_name=queue_name
453
+ successful_results_key = self._segment_key(
454
+ TaskResultStatus.SUCCESSFUL, queue_name
453
455
  )
456
+ failed_results_key = self._segment_key(TaskResultStatus.FAILED, queue_name)
454
457
  pipe.zcard(successful_results_key)
455
458
  pipe.zcard(failed_results_key)
456
459
  ingress_key = self.INGRESS_KEY.format(
@@ -30,6 +30,7 @@ from textual.widgets import (
30
30
  )
31
31
 
32
32
  from ..backends.base import BackendTelemetry, ThreadmillTaskBackend
33
+ from .screens import ConfirmScreen, PurgeScreen
33
34
 
34
35
  logger = logging.getLogger(__name__)
35
36
 
@@ -163,6 +164,21 @@ class TaskList(Vertical):
163
164
  counts: reactive[dict[str, int]] = reactive({})
164
165
  selected_task: reactive[TaskResult | None] = reactive(None)
165
166
 
167
+ BINDINGS = [
168
+ Binding("f5", "refresh", "Refresh"),
169
+ Binding("r", "requeue", "Requeue"),
170
+ Binding("d", "dequeue", "Drop"),
171
+ ]
172
+
173
+ def check_action(self, action: str, parameters: tuple[object, ...]) -> bool | None:
174
+ """Show requeue only on the Failed tab, dequeue on non-Running tabs."""
175
+ status = self.active_status()
176
+ if action == "requeue":
177
+ return status == TaskResultStatus.FAILED
178
+ if action == "dequeue":
179
+ return status is not None and status != TaskResultStatus.RUNNING
180
+ return True
181
+
166
182
  def compose(self) -> ComposeResult:
167
183
  with TabbedContent(initial="tab-ready") as tabs:
168
184
  for label, _ in TAB_STATUSES:
@@ -212,6 +228,11 @@ class TaskList(Vertical):
212
228
  """Activate the tab with the given id."""
213
229
  self._tabs.active = tab_id
214
230
 
231
+ def action_refresh(self) -> None:
232
+ """Refresh telemetry and re-fetch the task list."""
233
+ self.app._refresh_telemetry()
234
+ self.refresh_tasks()
235
+
215
236
  def on_data_table_row_selected(self, event: DataTable.RowSelected) -> None:
216
237
  """Notify the app when a task row is selected."""
217
238
  if isinstance(event.row_key.value, str):
@@ -227,6 +248,7 @@ class TaskList(Vertical):
227
248
  ) -> None:
228
249
  """Refresh the visible table when the user switches tabs."""
229
250
  self._refresh_data()
251
+ self.refresh_bindings()
230
252
 
231
253
  def _select_task_by_id(self, task_id: str) -> None:
232
254
  """Find the task result matching the row key in the current results."""
@@ -235,6 +257,57 @@ class TaskList(Vertical):
235
257
  None,
236
258
  )
237
259
 
260
+ def active_status(self) -> TaskResultStatus | None:
261
+ """Return the status segment of the currently active task tab."""
262
+ tab_id = self._tabs.active.removeprefix("tab-")
263
+ return next(
264
+ (status for label, status in TAB_STATUSES if label.lower() == tab_id),
265
+ None,
266
+ )
267
+
268
+ def action_requeue(self) -> None:
269
+ """Requeue the selected failed task after confirmation."""
270
+ task = self.selected_task
271
+ if task and self.active_status() == TaskResultStatus.FAILED:
272
+ self.app.push_screen(
273
+ ConfirmScreen(f"Requeue task {task.id[:8]}?"),
274
+ lambda confirmed: self._do_requeue(confirmed, task),
275
+ )
276
+
277
+ def _do_requeue(self, confirmed: bool, task: TaskResult) -> None:
278
+ if not confirmed:
279
+ return
280
+ try:
281
+ self.backend.requeue(task, datetime.datetime.now(datetime.UTC))
282
+ except Exception: # noqa: BLE001
283
+ logger.exception("Requeue failed for task %s", task.id)
284
+ self.app.notify("Requeue failed.", severity="error")
285
+ return
286
+ self.app._after_action("Task requeued.")
287
+
288
+ def action_dequeue(self) -> None:
289
+ """Remove the selected task from its current segment after confirmation."""
290
+ task = self.selected_task
291
+ if task and self.active_status() is not None:
292
+ self.app.push_screen(
293
+ ConfirmScreen(
294
+ f"Delete task {task.task.func.__name__} ({task.id[:8]})?",
295
+ danger=True,
296
+ ),
297
+ lambda confirmed: self._do_dequeue(confirmed, task),
298
+ )
299
+
300
+ def _do_dequeue(self, confirmed: bool, task: TaskResult) -> None:
301
+ if not confirmed:
302
+ return
303
+ try:
304
+ self.backend.dequeue(task)
305
+ except Exception: # noqa: BLE001
306
+ logger.exception("Delete failed for task %s", task.id)
307
+ self.app.notify("Delete failed.", severity="error")
308
+ return
309
+ self.app._after_action("Task deleted.")
310
+
238
311
  def _refresh_data(self) -> None:
239
312
  """Fetch and display tasks for the current queue and active tab."""
240
313
  backend = self.backend
@@ -291,6 +364,10 @@ class QueueList(ListView):
291
364
 
292
365
  telemetry: reactive[BackendTelemetry | None] = reactive(None)
293
366
 
367
+ BINDINGS = [
368
+ Binding("p", "purge", "Purge"),
369
+ ]
370
+
294
371
  def __init__(self, **kwargs: Any) -> None:
295
372
  super().__init__(**kwargs)
296
373
  self._items: dict[str, QueueItem] = {}
@@ -300,12 +377,10 @@ class QueueList(ListView):
300
377
  self.border_title = "Queues"
301
378
 
302
379
  def watch_telemetry(self, telemetry: BackendTelemetry | None) -> None:
303
- """Refresh queue labels when a new telemetry snapshot arrives."""
304
380
  if telemetry is not None:
305
381
  self.update_telemetry(telemetry)
306
382
 
307
383
  def update_telemetry(self, telemetry: BackendTelemetry) -> None:
308
- """Refresh queue labels from a new telemetry snapshot."""
309
384
  queues = telemetry.queues
310
385
  for queue_name, stats in sorted(queues.items()):
311
386
  theme = BUILTIN_THEMES[self.app.theme]
@@ -334,21 +409,40 @@ class QueueList(ListView):
334
409
  self._notify_selection(target)
335
410
 
336
411
  def _notify_selection(self, queue_name: str) -> None:
337
- """Tell the app which queue to display."""
338
412
  self.app._task_list.queue_name = queue_name
339
413
 
414
+ def action_purge(self) -> None:
415
+ item = self.highlighted_child
416
+ if not isinstance(item, QueueItem):
417
+ return
418
+ queue_name = item.queue_name
419
+ self.app.push_screen(
420
+ PurgeScreen(queue_name),
421
+ lambda confirmed: self._do_purge(confirmed, queue_name),
422
+ )
423
+
424
+ def _do_purge(self, confirmed: bool, queue_name: str) -> None:
425
+ if not confirmed:
426
+ return
427
+ try:
428
+ self.app.backend.purge(queue_name)
429
+ except Exception: # noqa: BLE001
430
+ logger.exception("Purge failed for %r", queue_name)
431
+ self.app.notify("Purge failed.", severity="error")
432
+ return
433
+ self.app._after_action(f"Purged queue {queue_name}.")
434
+
340
435
 
341
436
  class InspectorApp(App):
342
437
  """Threadmill TUI inspector with backend/queue/task panes."""
343
438
 
344
439
  CSS_PATH = "inspector.scss"
345
440
  BINDINGS = [
346
- Binding("q", "quit", "Quit"),
347
- Binding("f5", "refresh", "Refresh"),
348
441
  *(
349
442
  Binding(key, f"switch_tab('tab-{tab_id}')", tab_id.capitalize())
350
443
  for tab_id, key in TAB_KEYS.items()
351
444
  ),
445
+ Binding("q", "quit", "Quit"),
352
446
  ]
353
447
 
354
448
  backend: reactive[ThreadmillTaskBackend] = reactive(None)
@@ -415,8 +509,9 @@ class InspectorApp(App):
415
509
  """Exit the TUI."""
416
510
  self.exit()
417
511
 
418
- def action_refresh(self) -> None:
419
- """Refresh queue stats and re-fetch the task list on demand."""
512
+ def _after_action(self, message: str) -> None:
513
+ """Refresh telemetry and tasks after a mutating queue action."""
514
+ self.notify(message)
420
515
  self._refresh_telemetry()
421
516
  self._task_list.refresh_tasks()
422
517
 
@@ -2,6 +2,11 @@ Screen {
2
2
  background: $background;
3
3
  }
4
4
 
5
+ ConfirmScreen,
6
+ PurgeScreen {
7
+ align: center middle;
8
+ }
9
+
5
10
  * {
6
11
  scrollbar-color: $primary 10%;
7
12
  scrollbar-color-hover: $primary 80%;
@@ -104,3 +109,29 @@ TaskDetail {
104
109
  overflow: scroll;
105
110
  padding: 0 1;
106
111
  }
112
+
113
+ #dialog {
114
+ width: 60;
115
+ height: auto;
116
+ background: $surface;
117
+ padding: 1 2;
118
+ }
119
+
120
+ #dialog.warning {
121
+ border: thick $warning;
122
+ }
123
+
124
+ #dialog.danger {
125
+ border: thick $error;
126
+ }
127
+
128
+ #prompt {
129
+ text-align: center;
130
+ margin-bottom: 1;
131
+ }
132
+
133
+ #instruction {
134
+ text-align: center;
135
+ color: $text-muted;
136
+ margin-top: 1;
137
+ }
@@ -0,0 +1,52 @@
1
+ """Modal confirmation screens for inspector queue actions."""
2
+
3
+ from textual.app import ComposeResult
4
+ from textual.containers import Vertical
5
+ from textual.events import Key
6
+ from textual.screen import ModalScreen
7
+ from textual.widgets import Input, Label
8
+
9
+
10
+ class ConfirmScreen(ModalScreen[bool]):
11
+ """Yes/no confirmation: Enter confirms, Esc cancels."""
12
+
13
+ def __init__(self, prompt: str, *, danger: bool = False) -> None:
14
+ super().__init__()
15
+ self._prompt = prompt
16
+ self._danger = danger
17
+
18
+ def compose(self) -> ComposeResult:
19
+ with Vertical(id="dialog", classes="danger" if self._danger else "warning"):
20
+ yield Label(self._prompt, id="prompt")
21
+ yield Label("Press Enter to confirm, Esc to cancel.", id="instruction")
22
+
23
+ def on_key(self, event: Key) -> None:
24
+ match event.key:
25
+ case "enter":
26
+ self.dismiss(True)
27
+ case "escape":
28
+ self.dismiss(False)
29
+
30
+
31
+ class PurgeScreen(ModalScreen[bool]):
32
+ """Type the queue name to confirm a purge."""
33
+
34
+ def __init__(self, queue_name: str) -> None:
35
+ super().__init__()
36
+ self._queue_name = queue_name
37
+
38
+ def compose(self) -> ComposeResult:
39
+ with Vertical(id="dialog", classes="danger"):
40
+ yield Label(f"Purge queue [bold]{self._queue_name}[/bold]?", id="prompt")
41
+ yield Label(
42
+ "Type the queue name to confirm, Esc to cancel.", id="instruction"
43
+ )
44
+ yield Input(placeholder=self._queue_name, id="confirm-input")
45
+
46
+ def on_input_submitted(self, event: Input.Submitted) -> None:
47
+ if event.value == self._queue_name:
48
+ self.dismiss(True)
49
+
50
+ def on_key(self, event: Key) -> None:
51
+ if event.key == "escape":
52
+ self.dismiss(False)
File without changes
File without changes
File without changes