simple-module-background-tasks 0.0.31__tar.gz → 0.0.32__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 (55) hide show
  1. {simple_module_background_tasks-0.0.31 → simple_module_background_tasks-0.0.32}/PKG-INFO +5 -5
  2. {simple_module_background_tasks-0.0.31 → simple_module_background_tasks-0.0.32}/background_tasks/contracts/schemas.py +9 -1
  3. {simple_module_background_tasks-0.0.31 → simple_module_background_tasks-0.0.32}/background_tasks/endpoints/views.py +38 -2
  4. {simple_module_background_tasks-0.0.31 → simple_module_background_tasks-0.0.32}/background_tasks/locales/en.json +19 -0
  5. {simple_module_background_tasks-0.0.31 → simple_module_background_tasks-0.0.32}/background_tasks/pages/Detail.tsx +3 -0
  6. {simple_module_background_tasks-0.0.31 → simple_module_background_tasks-0.0.32}/background_tasks/pages/Index.tsx +53 -16
  7. {simple_module_background_tasks-0.0.31 → simple_module_background_tasks-0.0.32}/background_tasks/pages/Workers.tsx +33 -4
  8. {simple_module_background_tasks-0.0.31 → simple_module_background_tasks-0.0.32}/background_tasks/pages/components/ExecutionRow.tsx +5 -2
  9. simple_module_background_tasks-0.0.32/background_tasks/pages/components/RetryConfirmDialog.tsx +81 -0
  10. simple_module_background_tasks-0.0.32/background_tasks/pages/components/TasksEmpty.tsx +102 -0
  11. simple_module_background_tasks-0.0.32/background_tasks/pages/components/WorkerHealthBanner.tsx +90 -0
  12. {simple_module_background_tasks-0.0.31 → simple_module_background_tasks-0.0.32}/background_tasks/pages/retry.ts +3 -0
  13. simple_module_background_tasks-0.0.32/background_tasks/pages/worker-health.ts +22 -0
  14. {simple_module_background_tasks-0.0.31 → simple_module_background_tasks-0.0.32}/background_tasks/service.py +45 -17
  15. {simple_module_background_tasks-0.0.31 → simple_module_background_tasks-0.0.32}/pyproject.toml +5 -5
  16. {simple_module_background_tasks-0.0.31 → simple_module_background_tasks-0.0.32}/tests/test_admin_api.py +32 -0
  17. simple_module_background_tasks-0.0.32/tests/test_bg_index_worker_presence.py +143 -0
  18. simple_module_background_tasks-0.0.32/tests/test_bg_list_item_payload.py +77 -0
  19. simple_module_background_tasks-0.0.32/tests/test_bg_page_clamp.py +60 -0
  20. simple_module_background_tasks-0.0.31/background_tasks/pages/components/RetryConfirmDialog.tsx +0 -38
  21. {simple_module_background_tasks-0.0.31 → simple_module_background_tasks-0.0.32}/.gitignore +0 -0
  22. {simple_module_background_tasks-0.0.31 → simple_module_background_tasks-0.0.32}/LICENSE +0 -0
  23. {simple_module_background_tasks-0.0.31 → simple_module_background_tasks-0.0.32}/README.md +0 -0
  24. {simple_module_background_tasks-0.0.31 → simple_module_background_tasks-0.0.32}/background_tasks/__init__.py +0 -0
  25. {simple_module_background_tasks-0.0.31 → simple_module_background_tasks-0.0.32}/background_tasks/_signal_support.py +0 -0
  26. {simple_module_background_tasks-0.0.31 → simple_module_background_tasks-0.0.32}/background_tasks/celery_app.py +0 -0
  27. {simple_module_background_tasks-0.0.31 → simple_module_background_tasks-0.0.32}/background_tasks/constants.py +0 -0
  28. {simple_module_background_tasks-0.0.31 → simple_module_background_tasks-0.0.32}/background_tasks/contracts/__init__.py +0 -0
  29. {simple_module_background_tasks-0.0.31 → simple_module_background_tasks-0.0.32}/background_tasks/contracts/events.py +0 -0
  30. {simple_module_background_tasks-0.0.31 → simple_module_background_tasks-0.0.32}/background_tasks/deps.py +0 -0
  31. {simple_module_background_tasks-0.0.31 → simple_module_background_tasks-0.0.32}/background_tasks/endpoints/__init__.py +0 -0
  32. {simple_module_background_tasks-0.0.31 → simple_module_background_tasks-0.0.32}/background_tasks/endpoints/api_admin.py +0 -0
  33. {simple_module_background_tasks-0.0.31 → simple_module_background_tasks-0.0.32}/background_tasks/log_context.py +0 -0
  34. {simple_module_background_tasks-0.0.31 → simple_module_background_tasks-0.0.32}/background_tasks/models.py +0 -0
  35. {simple_module_background_tasks-0.0.31 → simple_module_background_tasks-0.0.32}/background_tasks/module.py +0 -0
  36. {simple_module_background_tasks-0.0.31 → simple_module_background_tasks-0.0.32}/background_tasks/pages/components/StatusStrip.tsx +0 -0
  37. {simple_module_background_tasks-0.0.31 → simple_module_background_tasks-0.0.32}/background_tasks/pages/constants.ts +0 -0
  38. {simple_module_background_tasks-0.0.31 → simple_module_background_tasks-0.0.32}/background_tasks/py.typed +0 -0
  39. {simple_module_background_tasks-0.0.31 → simple_module_background_tasks-0.0.32}/background_tasks/services.py +0 -0
  40. {simple_module_background_tasks-0.0.31 → simple_module_background_tasks-0.0.32}/background_tasks/settings.py +0 -0
  41. {simple_module_background_tasks-0.0.31 → simple_module_background_tasks-0.0.32}/background_tasks/signals.py +0 -0
  42. {simple_module_background_tasks-0.0.31 → simple_module_background_tasks-0.0.32}/background_tasks/sync_db.py +0 -0
  43. {simple_module_background_tasks-0.0.31 → simple_module_background_tasks-0.0.32}/background_tasks/tasks.py +0 -0
  44. {simple_module_background_tasks-0.0.31 → simple_module_background_tasks-0.0.32}/background_tasks/worker_inspector.py +0 -0
  45. {simple_module_background_tasks-0.0.31 → simple_module_background_tasks-0.0.32}/package.json +0 -0
  46. {simple_module_background_tasks-0.0.31 → simple_module_background_tasks-0.0.32}/tests/conftest.py +0 -0
  47. {simple_module_background_tasks-0.0.31 → simple_module_background_tasks-0.0.32}/tests/test_beat_schedule.py +0 -0
  48. {simple_module_background_tasks-0.0.31 → simple_module_background_tasks-0.0.32}/tests/test_bg_service.py +0 -0
  49. {simple_module_background_tasks-0.0.31 → simple_module_background_tasks-0.0.32}/tests/test_log_context.py +0 -0
  50. {simple_module_background_tasks-0.0.31 → simple_module_background_tasks-0.0.32}/tests/test_signals.py +0 -0
  51. {simple_module_background_tasks-0.0.31 → simple_module_background_tasks-0.0.32}/tests/test_sweep_stuck.py +0 -0
  52. {simple_module_background_tasks-0.0.31 → simple_module_background_tasks-0.0.32}/tests/test_sync_db.py +0 -0
  53. {simple_module_background_tasks-0.0.31 → simple_module_background_tasks-0.0.32}/tests/test_worker_inspector.py +0 -0
  54. {simple_module_background_tasks-0.0.31 → simple_module_background_tasks-0.0.32}/tests/test_workers_endpoints.py +0 -0
  55. {simple_module_background_tasks-0.0.31 → simple_module_background_tasks-0.0.32}/tsconfig.json +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: simple_module_background_tasks
3
- Version: 0.0.31
3
+ Version: 0.0.32
4
4
  Summary: Celery + Redis task queue with admin UI for monitoring and retrying failed/stuck tasks
5
5
  Project-URL: Homepage, https://github.com/antosubash/simple_module_python
6
6
  Project-URL: Repository, https://github.com/antosubash/simple_module_python
@@ -23,10 +23,10 @@ Classifier: Typing :: Typed
23
23
  Requires-Python: >=3.12
24
24
  Requires-Dist: celery[redis]>=5.4
25
25
  Requires-Dist: redis>=5
26
- Requires-Dist: simple-module-core==0.0.31
27
- Requires-Dist: simple-module-db==0.0.31
28
- Requires-Dist: simple-module-hosting==0.0.31
29
- Requires-Dist: simple-module-settings==0.0.31
26
+ Requires-Dist: simple-module-core==0.0.32
27
+ Requires-Dist: simple-module-db==0.0.32
28
+ Requires-Dist: simple-module-hosting==0.0.32
29
+ Requires-Dist: simple-module-settings==0.0.32
30
30
  Description-Content-Type: text/markdown
31
31
 
32
32
  # simple_module_background_tasks
@@ -13,7 +13,13 @@ from background_tasks.constants import TaskStatus
13
13
 
14
14
 
15
15
  class TaskExecutionListItem(SQLModel):
16
- """Compact row for the admin listing table."""
16
+ """Compact row for the admin listing table.
17
+
18
+ Carries ``args``/``kwargs`` even though the table never renders them: the
19
+ retry confirm opens straight from a row and has to show the payload it is
20
+ about to re-enqueue. Re-running a bad payload just fails the same way, so
21
+ the operator needs to read it *before* confirming, not on a second page.
22
+ """
17
23
 
18
24
  model_config = ConfigDict(from_attributes=True)
19
25
 
@@ -22,6 +28,8 @@ class TaskExecutionListItem(SQLModel):
22
28
  task_name: str
23
29
  status: TaskStatus
24
30
  queue: str
31
+ args: list[Any] = []
32
+ kwargs: dict[str, Any] = {}
25
33
  retries: int
26
34
  worker: str | None = None
27
35
  queued_at: datetime | None = None
@@ -23,12 +23,33 @@ router = APIRouter(dependencies=[Depends(RequiresPermission(PERM_VIEW))])
23
23
  PER_PAGE = 20
24
24
 
25
25
 
26
+ async def _worker_presence(request: Request) -> dict[str, object]:
27
+ """Whether anything is actually able to run a task right now.
28
+
29
+ An executions table with no rows has two very different causes: nothing has
30
+ been enqueued yet, or a worker was never started and the queue is piling up
31
+ unattended. The empty state cannot tell an operator which without asking
32
+ the broker.
33
+
34
+ Polling costs one inspect timeout (~1s), so this is called only when the
35
+ unfiltered list is genuinely empty — the one moment the answer changes what
36
+ the screen should say. The normal path never pays for it.
37
+ """
38
+ celery = request.app.state.background_tasks.celery
39
+ snapshot = await asyncio.to_thread(WorkerInspector(celery).snapshot)
40
+ return {
41
+ "broker_reachable": snapshot.broker_reachable,
42
+ "worker_count": sum(1 for w in snapshot.workers if w.online),
43
+ }
44
+
45
+
26
46
  @router.get("/", response_model=None)
27
47
  async def index(
28
48
  inertia: InertiaDep,
49
+ request: Request,
29
50
  status: TaskStatus | None = Query(default=None),
30
51
  task_name: str = Query(default="", alias="q"),
31
- page: int = Query(default=1, ge=1),
52
+ page: int = Query(default=1),
32
53
  service: BackgroundTaskService = Depends(get_background_task_service),
33
54
  ) -> InertiaResponse:
34
55
  response = await service.list(
@@ -37,12 +58,27 @@ async def index(
37
58
  page=page,
38
59
  per_page=PER_PAGE,
39
60
  )
40
- counts = await service.status_counts(task_name=task_name or None)
61
+ # A filtered-empty list says nothing about the fleet, so don't pay to poll
62
+ # it: the screen already knows to blame the filter.
63
+ unfiltered_and_empty = response.total == 0 and not task_name and status is None
64
+ # `_worker_presence` polls the broker over a thread, not the DB session
65
+ # `status_counts` uses — the two don't share state, so run them
66
+ # concurrently instead of paying the ~1s inspect timeout serially on top
67
+ # of the count query.
68
+ if unfiltered_and_empty:
69
+ counts, presence = await asyncio.gather(
70
+ service.status_counts(task_name=task_name or None),
71
+ _worker_presence(request),
72
+ )
73
+ else:
74
+ counts = await service.status_counts(task_name=task_name or None)
75
+ presence = None
41
76
  return await inertia.render(
42
77
  "BackgroundTasks/Index",
43
78
  {
44
79
  "executions": [i.model_dump(mode="json") for i in response.items],
45
80
  "status_counts": {s.value: counts.get(s.value, 0) for s in TaskStatus},
81
+ "worker_presence": presence,
46
82
  "pagination": {
47
83
  "page": response.page,
48
84
  "per_page": response.per_page,
@@ -53,5 +53,24 @@
53
53
  "toasts": {
54
54
  "retried": "Task \"{name}\" re-enqueued",
55
55
  "retry_failed": "Failed to retry task"
56
+ },
57
+ "worker_health": {
58
+ "broker_unreachable_title": "Broker unreachable",
59
+ "broker_unreachable_detail": "Nothing can be queued or run until the broker is back. The counts below are the last state written to the database.",
60
+ "no_workers_title_one": "{count} task waiting, no worker running",
61
+ "no_workers_title_other": "{count} tasks waiting, no worker running",
62
+ "no_workers_detail": "The broker is up but nothing is consuming the queue, so this backlog will not move on its own.",
63
+ "view_workers": "View workers"
64
+ },
65
+ "tasks_empty": {
66
+ "filtered_title": "No tasks match these filters",
67
+ "filtered_description": "Executions exist outside the current search and status filter.",
68
+ "clear_filters": "Clear filters",
69
+ "broker_unreachable_title": "Can't reach the broker",
70
+ "broker_unreachable_description": "Nothing can be queued or executed until the broker is back. Tasks are not being lost — they are not being accepted.",
71
+ "no_workers_title": "No worker is running",
72
+ "no_workers_description": "The broker is up, but nothing is consuming the queue. Tasks enqueued now will wait rather than run.",
73
+ "healthy_title": "No task has run yet",
74
+ "healthy_description": "Registered tasks appear here the first time they execute."
56
75
  }
57
76
  }
@@ -110,6 +110,9 @@ function Detail() {
110
110
  Retry task
111
111
  </Button>
112
112
  }
113
+ taskName={execution.task_name}
114
+ args={execution.args ?? []}
115
+ kwargs={execution.kwargs ?? {}}
113
116
  onConfirm={handleRetry}
114
117
  />
115
118
  )}
@@ -13,18 +13,19 @@ import {
13
13
  import {
14
14
  Table,
15
15
  TableBody,
16
- TableCell,
17
16
  TableHead,
18
17
  TableHeader,
19
18
  TableRow,
20
19
  } from '@simple-module-py/ui/components/ui/table';
21
20
  import { usePermissions } from '@simple-module-py/ui/hooks/use-permissions';
22
21
  import { AuthenticatedLayout } from '@simple-module-py/ui/layouts/AuthenticatedLayout';
23
- import { Activity, Search, ServerCog } from 'lucide-react';
24
- import { useEffect, useState } from 'react';
22
+ import { Search, ServerCog } from 'lucide-react';
23
+ import { useEffect, useRef, useState } from 'react';
25
24
  import { ExecutionRow, statusLabel } from './components/ExecutionRow';
26
25
  import { type StatusCounts, StatusStrip } from './components/StatusStrip';
27
- import { STATUS_ORDER, VIEW_BASE } from './constants';
26
+ import { TasksEmptyRow, type WorkerPresence } from './components/TasksEmpty';
27
+ import { WorkerHealthBanner } from './components/WorkerHealthBanner';
28
+ import { STATUS_ORDER, TASK_STATUS, VIEW_BASE } from './constants';
28
29
  import { type Execution, retryExecution } from './retry';
29
30
 
30
31
  interface Pagination {
@@ -38,8 +39,13 @@ interface Props {
38
39
  pagination: Pagination;
39
40
  filters: { status: string; task_name: string };
40
41
  status_counts: StatusCounts;
42
+ /** Null unless the unfiltered list came back empty — see the index view. */
43
+ worker_presence: WorkerPresence | null;
41
44
  }
42
45
 
46
+ /** Task, Status, Queue, Queued, Duration, Worker, Actions. */
47
+ const COLUMN_COUNT = 7;
48
+
43
49
  const STATUS_ALL = '__all__';
44
50
 
45
51
  function pushFilters(filters: { status: string; task_name: string }, page: number): void {
@@ -56,6 +62,7 @@ function Index() {
56
62
  pagination,
57
63
  filters: initialFilters,
58
64
  status_counts: statusCounts,
65
+ worker_presence: workerPresence,
59
66
  } = usePage<{ props: Props }>().props as unknown as Props;
60
67
 
61
68
  const { can } = usePermissions();
@@ -64,10 +71,40 @@ function Index() {
64
71
  const [search, setSearch] = useState(initialFilters.task_name ?? '');
65
72
  const totalPages = Math.ceil(pagination.total / pagination.per_page);
66
73
  const statusValue = initialFilters.status || STATUS_ALL;
74
+ // Derived from the server-confirmed filters, not the live `search` input:
75
+ // `workerPresence` reflects the last committed request, so mixing it with
76
+ // unsubmitted local state would flash the wrong empty-state copy during the
77
+ // debounce window between a keystroke and the resulting navigation.
78
+ const isFiltered = !!(initialFilters.task_name ?? '') || statusValue !== STATUS_ALL;
79
+
80
+ // Work that is supposed to be moving. Counting `running` too is deliberate:
81
+ // a row stuck in "running" with no worker alive means the worker died holding
82
+ // it, which is exactly as wrong as an unattended queue.
83
+ const backlog =
84
+ (statusCounts?.[TASK_STATUS.PENDING] ?? 0) + (statusCounts?.[TASK_STATUS.RUNNING] ?? 0);
85
+
86
+ // Set right before an explicit clear resets `search`, so the debounce
87
+ // effect below — which would otherwise see `search` change and reschedule
88
+ // a navigation using the still-stale `statusValue` prop — skips that one
89
+ // run instead of re-applying the status filter `clearFilters` just cleared.
90
+ const skipNextDebounceRef = useRef(false);
91
+
92
+ function clearFilters() {
93
+ // Only arm the skip when resetting `search` will actually fire the
94
+ // effect — if the box is already empty the effect never runs for the
95
+ // clear, and an armed flag would swallow the user's next keystroke.
96
+ if (search !== '') skipNextDebounceRef.current = true;
97
+ setSearch('');
98
+ pushFilters({ status: STATUS_ALL, task_name: '' }, 1);
99
+ }
67
100
 
68
101
  // Debounce search: any change from the server-provided value kicks off a
69
102
  // page-1 navigation 300ms after the user stops typing.
70
103
  useEffect(() => {
104
+ if (skipNextDebounceRef.current) {
105
+ skipNextDebounceRef.current = false;
106
+ return;
107
+ }
71
108
  if (search === (initialFilters.task_name ?? '')) return;
72
109
  const timeout = setTimeout(
73
110
  () => pushFilters({ status: statusValue, task_name: search }, 1),
@@ -96,6 +133,12 @@ function Index() {
96
133
  onSelect={(status) => pushFilters({ status, task_name: search }, 1)}
97
134
  />
98
135
 
136
+ {/* `statusCounts` (and therefore `backlog`) is scoped to the active
137
+ search/status filter — a narrowed view can read zero backlog while
138
+ the real fleet-wide queue is still stuck, so only trust it as a
139
+ health signal when nothing is filtering it. */}
140
+ {!isFiltered && <WorkerHealthBanner backlog={backlog} />}
141
+
99
142
  <div className="mb-4 flex flex-col sm:flex-row items-stretch sm:items-center justify-between gap-3">
100
143
  <div className="relative flex-1 max-w-sm">
101
144
  <Search className="absolute left-3 top-1/2 -translate-y-1/2 size-4 text-muted-foreground" />
@@ -150,18 +193,12 @@ function Index() {
150
193
  <ExecutionRow key={e.id} execution={e} canRetry={canRetry} onRetry={handleRetry} />
151
194
  ))}
152
195
  {executions.length === 0 && (
153
- <TableRow>
154
- <TableCell colSpan={7} className="h-32 text-center">
155
- <div className="flex flex-col items-center gap-2 text-muted-foreground">
156
- <Activity className="size-8" />
157
- <p>
158
- {search
159
- ? `No tasks match "${search}"`
160
- : 'No task executions yet. Tasks appear here as soon as modules enqueue work.'}
161
- </p>
162
- </div>
163
- </TableCell>
164
- </TableRow>
196
+ <TasksEmptyRow
197
+ filtered={isFiltered}
198
+ columnCount={COLUMN_COUNT}
199
+ presence={workerPresence ?? null}
200
+ onClear={clearFilters}
201
+ />
165
202
  )}
166
203
  </TableBody>
167
204
  </Table>
@@ -4,8 +4,9 @@ import { Badge } from '@simple-module-py/ui/components/ui/badge';
4
4
  import { Button } from '@simple-module-py/ui/components/ui/button';
5
5
  import { Card } from '@simple-module-py/ui/components/ui/card';
6
6
  import { AuthenticatedLayout } from '@simple-module-py/ui/layouts/AuthenticatedLayout';
7
+ import { ageOf, isStale, relativeAgeLabel } from '@simple-module-py/ui/lib/relative-time';
7
8
  import { ArrowLeft, RefreshCw, ServerCrash, ServerOff } from 'lucide-react';
8
- import { useState } from 'react';
9
+ import { useEffect, useState } from 'react';
9
10
  import { toast } from 'sonner';
10
11
  import { API_BASE, formatTs, VIEW_BASE, type WorkerInfo, type WorkerSnapshot } from './constants';
11
12
 
@@ -72,6 +73,19 @@ function Workers() {
72
73
  const [snapshot, setSnapshot] = useState<WorkerSnapshot>(initial);
73
74
  const [loading, setLoading] = useState(false);
74
75
 
76
+ // This page is a point-in-time poll that only updates when asked. Left open
77
+ // on a second monitor it keeps rendering a healthy fleet long after that
78
+ // fleet died — the one failure it exists to catch. Ticking a clock beside the
79
+ // reading is what makes the page admit it has gone cold; the poll itself
80
+ // stays manual, so nobody's open tab quietly hammers the broker.
81
+ const [now, setNow] = useState(() => Date.now());
82
+ useEffect(() => {
83
+ const id = setInterval(() => setNow(Date.now()), 5_000);
84
+ return () => clearInterval(id);
85
+ }, []);
86
+ const age = ageOf(snapshot.polled_at, now);
87
+ const stale = isStale(age);
88
+
75
89
  async function refresh() {
76
90
  setLoading(true);
77
91
  try {
@@ -80,6 +94,8 @@ function Workers() {
80
94
  });
81
95
  if (res.ok) {
82
96
  setSnapshot((await res.json()) as WorkerSnapshot);
97
+ // Don't wait up to a tick for the age to catch up with the new reading.
98
+ setNow(Date.now());
83
99
  } else {
84
100
  toast.error(`Failed to refresh workers (HTTP ${res.status})`);
85
101
  }
@@ -102,10 +118,23 @@ function Workers() {
102
118
  </Link>
103
119
  </Button>
104
120
  <div className="flex items-center gap-3">
105
- <span className="text-xs text-muted-foreground">
106
- Last updated {formatTs(snapshot.polled_at)}
121
+ <span
122
+ className={`text-xs ${stale ? 'text-amber-600' : 'text-muted-foreground'}`}
123
+ title={`Polled at ${formatTs(snapshot.polled_at)}`}
124
+ >
125
+ Updated {relativeAgeLabel(age)}
107
126
  </span>
108
- <Button variant="outline" size="sm" onClick={refresh} disabled={loading}>
127
+ {stale && (
128
+ <Badge variant="outline" className="border-amber-300 bg-amber-50 text-amber-700">
129
+ Stale
130
+ </Badge>
131
+ )}
132
+ <Button
133
+ variant={stale ? 'default' : 'outline'}
134
+ size="sm"
135
+ onClick={refresh}
136
+ disabled={loading}
137
+ >
109
138
  <RefreshCw className={`mr-2 size-4 ${loading ? 'animate-spin' : ''}`} />
110
139
  Refresh
111
140
  </Button>
@@ -3,7 +3,7 @@ import { Badge } from '@simple-module-py/ui/components/ui/badge';
3
3
  import { Button } from '@simple-module-py/ui/components/ui/button';
4
4
  import { TableCell, TableRow } from '@simple-module-py/ui/components/ui/table';
5
5
  import { RefreshCcw } from 'lucide-react';
6
- import { RETRYABLE_STATUSES, STATUS_BADGE_VARIANT, VIEW_BASE } from '../constants';
6
+ import { formatTs, RETRYABLE_STATUSES, STATUS_BADGE_VARIANT, VIEW_BASE } from '../constants';
7
7
  import type { Execution } from '../retry';
8
8
  import { RetryConfirmDialog } from './RetryConfirmDialog';
9
9
 
@@ -50,7 +50,7 @@ export function ExecutionRow({ execution, canRetry, onRetry }: Props) {
50
50
  {execution.queue}
51
51
  </TableCell>
52
52
  <TableCell className="hidden lg:table-cell text-sm text-muted-foreground">
53
- {execution.queued_at ? new Date(execution.queued_at).toLocaleString() : '—'}
53
+ {formatTs(execution.queued_at)}
54
54
  </TableCell>
55
55
  <TableCell className="hidden sm:table-cell text-sm tabular-nums">
56
56
  {formatDuration(execution.started_at, execution.finished_at)}
@@ -66,6 +66,9 @@ export function ExecutionRow({ execution, canRetry, onRetry }: Props) {
66
66
  <RefreshCcw />
67
67
  </Button>
68
68
  }
69
+ taskName={execution.task_name}
70
+ args={execution.args ?? []}
71
+ kwargs={execution.kwargs ?? {}}
69
72
  onConfirm={() => onRetry(execution)}
70
73
  />
71
74
  ) : (
@@ -0,0 +1,81 @@
1
+ import {
2
+ AlertDialog,
3
+ AlertDialogAction,
4
+ AlertDialogCancel,
5
+ AlertDialogContent,
6
+ AlertDialogDescription,
7
+ AlertDialogFooter,
8
+ AlertDialogHeader,
9
+ AlertDialogTitle,
10
+ AlertDialogTrigger,
11
+ } from '@simple-module-py/ui/components/ui/alert-dialog';
12
+ import type { ReactNode } from 'react';
13
+ import { useMemo } from 'react';
14
+
15
+ interface Props {
16
+ trigger: ReactNode;
17
+ onConfirm: () => void;
18
+ taskName: string;
19
+ args: unknown[];
20
+ kwargs: Record<string, unknown>;
21
+ }
22
+
23
+ /** Pretty-print a payload, degrading to a marker rather than throwing on a cycle. */
24
+ function format(value: unknown): string {
25
+ try {
26
+ return JSON.stringify(value, null, 1)?.replace(/\n\s*/g, ' ') ?? String(value);
27
+ } catch {
28
+ return '<unserialisable>';
29
+ }
30
+ }
31
+
32
+ export function RetryConfirmDialog({ trigger, onConfirm, taskName, args, kwargs }: Props) {
33
+ const hasArgs = args.length > 0;
34
+ const hasKwargs = Object.keys(kwargs).length > 0;
35
+ // One dialog is mounted per retryable row (up to a full page of them), so
36
+ // this is re-evaluated on every re-render of the row above it — memoize
37
+ // rather than re-stringify a payload that hasn't changed.
38
+ const formattedArgs = useMemo(() => (hasArgs ? format(args) : ''), [hasArgs, args]);
39
+ const formattedKwargs = useMemo(() => (hasKwargs ? format(kwargs) : ''), [hasKwargs, kwargs]);
40
+
41
+ return (
42
+ <AlertDialog>
43
+ <AlertDialogTrigger asChild>{trigger}</AlertDialogTrigger>
44
+ <AlertDialogContent>
45
+ <AlertDialogHeader>
46
+ <AlertDialogTitle>Retry {taskName}?</AlertDialogTitle>
47
+ <AlertDialogDescription>
48
+ Queues a new execution with the same arguments. The original row stays as it is, kept
49
+ for history.
50
+ </AlertDialogDescription>
51
+ </AlertDialogHeader>
52
+
53
+ {/* The arguments are the whole question: a retry re-sends this exact
54
+ payload, so if it is what failed, retrying blind just fails again. */}
55
+ {hasArgs || hasKwargs ? (
56
+ <dl className="grid gap-1.5 rounded-md bg-muted/60 p-3 font-mono text-xs">
57
+ {hasArgs && (
58
+ <div className="flex gap-2">
59
+ <dt className="shrink-0 text-muted-foreground">args:</dt>
60
+ <dd className="min-w-0 break-all">{formattedArgs}</dd>
61
+ </div>
62
+ )}
63
+ {hasKwargs && (
64
+ <div className="flex gap-2">
65
+ <dt className="shrink-0 text-muted-foreground">kwargs:</dt>
66
+ <dd className="min-w-0 break-all">{formattedKwargs}</dd>
67
+ </div>
68
+ )}
69
+ </dl>
70
+ ) : (
71
+ <p className="text-xs text-muted-foreground">This task takes no arguments.</p>
72
+ )}
73
+
74
+ <AlertDialogFooter>
75
+ <AlertDialogCancel>Cancel</AlertDialogCancel>
76
+ <AlertDialogAction onClick={onConfirm}>Retry task</AlertDialogAction>
77
+ </AlertDialogFooter>
78
+ </AlertDialogContent>
79
+ </AlertDialog>
80
+ );
81
+ }
@@ -0,0 +1,102 @@
1
+ import { Link } from '@inertiajs/react';
2
+ import { keys, useT } from '@simple-module-py/i18n';
3
+ import { EmptyState } from '@simple-module-py/ui/components/EmptyState';
4
+ import { TableEmptyRow } from '@simple-module-py/ui/components/TableEmptyRow';
5
+ import { Button } from '@simple-module-py/ui/components/ui/button';
6
+ import type { TFunction } from 'i18next';
7
+ import { Activity, type LucideIcon, PlugZap, ServerCog, ServerOff } from 'lucide-react';
8
+ import { VIEW_BASE } from '../constants';
9
+ import { diagnoseWorkerHealth } from '../worker-health';
10
+
11
+ export interface WorkerPresence {
12
+ broker_reachable: boolean;
13
+ worker_count: number;
14
+ }
15
+
16
+ interface TasksEmptyRowProps {
17
+ /** A search term or status filter is narrowing the list. */
18
+ filtered: boolean;
19
+ columnCount: number;
20
+ /** Fleet state, polled by the server only when the unfiltered list is empty. */
21
+ presence: WorkerPresence | null;
22
+ onClear: () => void;
23
+ }
24
+
25
+ interface EmptyCopy {
26
+ icon: LucideIcon;
27
+ title: string;
28
+ description: string;
29
+ }
30
+
31
+ /**
32
+ * Which "nothing here" message the executions table should show.
33
+ *
34
+ * "No task has run yet" is reassuring and wrong when the real cause is that no
35
+ * worker was ever started: the queue is filling and nothing is draining it.
36
+ * These are three different operator problems, so they get three different
37
+ * messages. A filtered-empty list is handled by the caller — the fleet is not
38
+ * why those rows are missing.
39
+ */
40
+ function emptyCopy(t: TFunction, presence: WorkerPresence | null): EmptyCopy {
41
+ const state = presence
42
+ ? diagnoseWorkerHealth({
43
+ brokerReachable: presence.broker_reachable,
44
+ onlineWorkerCount: presence.worker_count,
45
+ })
46
+ : 'healthy';
47
+ if (state === 'broker_unreachable') {
48
+ return {
49
+ icon: PlugZap,
50
+ title: t(keys.background_tasks.tasks_empty.broker_unreachable_title),
51
+ description: t(keys.background_tasks.tasks_empty.broker_unreachable_description),
52
+ };
53
+ }
54
+ if (state === 'no_workers_online') {
55
+ return {
56
+ icon: ServerOff,
57
+ title: t(keys.background_tasks.tasks_empty.no_workers_title),
58
+ description: t(keys.background_tasks.tasks_empty.no_workers_description),
59
+ };
60
+ }
61
+ return {
62
+ icon: Activity,
63
+ title: t(keys.background_tasks.tasks_empty.healthy_title),
64
+ description: t(keys.background_tasks.tasks_empty.healthy_description),
65
+ };
66
+ }
67
+
68
+ /** The executions table's empty row, in whichever of its four states applies. */
69
+ export function TasksEmptyRow({ filtered, columnCount, presence, onClear }: TasksEmptyRowProps) {
70
+ const { t } = useT();
71
+ const copy = emptyCopy(t, presence);
72
+ return (
73
+ <TableEmptyRow columnCount={columnCount}>
74
+ {filtered ? (
75
+ <EmptyState
76
+ icon={Activity}
77
+ title={t(keys.background_tasks.tasks_empty.filtered_title)}
78
+ description={t(keys.background_tasks.tasks_empty.filtered_description)}
79
+ action={
80
+ <Button variant="outline" onClick={onClear}>
81
+ {t(keys.background_tasks.tasks_empty.clear_filters)}
82
+ </Button>
83
+ }
84
+ />
85
+ ) : (
86
+ <EmptyState
87
+ icon={copy.icon}
88
+ title={copy.title}
89
+ description={copy.description}
90
+ action={
91
+ <Button variant="outline" asChild>
92
+ <Link href={`${VIEW_BASE}/workers`}>
93
+ <ServerCog className="mr-2 size-4" />
94
+ {t(keys.background_tasks.worker_health.view_workers)}
95
+ </Link>
96
+ </Button>
97
+ }
98
+ />
99
+ )}
100
+ </TableEmptyRow>
101
+ );
102
+ }
@@ -0,0 +1,90 @@
1
+ import { Link } from '@inertiajs/react';
2
+ import { keys, useT } from '@simple-module-py/i18n';
3
+ import { InlineBanner } from '@simple-module-py/ui/components/InlineBanner';
4
+ import { Button } from '@simple-module-py/ui/components/ui/button';
5
+ import { ServerCrash, ServerOff } from 'lucide-react';
6
+ import { useEffect, useState } from 'react';
7
+ import { API_BASE, VIEW_BASE, type WorkerSnapshot } from '../constants';
8
+ import { diagnoseWorkerHealth, type WorkerHealthState } from '../worker-health';
9
+
10
+ interface Props {
11
+ /** Tasks queued or already running — the backlog whose fate is in question. */
12
+ backlog: number;
13
+ }
14
+
15
+ /** The diagnosed states that actually render a banner — `healthy` renders nothing. */
16
+ type BannerState = Exclude<WorkerHealthState, 'healthy'>;
17
+
18
+ function diagnose(snapshot: WorkerSnapshot): BannerState | null {
19
+ const state = diagnoseWorkerHealth({
20
+ brokerReachable: snapshot.broker_reachable,
21
+ onlineWorkerCount: snapshot.workers.filter((w) => w.online).length,
22
+ });
23
+ return state === 'healthy' ? null : state;
24
+ }
25
+
26
+ /**
27
+ * Warns when a backlog has nobody to work it.
28
+ *
29
+ * A queue of twelve with a healthy worker and a queue of twelve with no worker
30
+ * at all render identically — same rows, same counts — and only one of them is
31
+ * fine. Answering that needs the broker, which costs a full inspect timeout
32
+ * even when everything is healthy (the probes wait out the timeout rather than
33
+ * returning early). So this is deliberately *not* part of the page render:
34
+ * the list paints immediately and the warning arrives a moment later, and only
35
+ * when there is a backlog whose fate is actually in question.
36
+ *
37
+ * Silent on failure — a page that can't reach its own API should not invent a
38
+ * diagnosis about the broker.
39
+ */
40
+ export function WorkerHealthBanner({ backlog }: Props) {
41
+ const { t } = useT();
42
+ const [state, setState] = useState<BannerState | null>(null);
43
+
44
+ useEffect(() => {
45
+ if (backlog <= 0) {
46
+ setState(null);
47
+ return;
48
+ }
49
+ const controller = new AbortController();
50
+ fetch(`${API_BASE}/workers`, {
51
+ headers: { Accept: 'application/json' },
52
+ signal: controller.signal,
53
+ })
54
+ .then((res) => (res.ok ? (res.json() as Promise<WorkerSnapshot>) : null))
55
+ .then((snapshot) => snapshot && setState(diagnose(snapshot)))
56
+ .catch(() => {
57
+ /* offline, aborted, or forbidden — say nothing rather than guess */
58
+ });
59
+ return () => controller.abort();
60
+ }, [backlog]);
61
+
62
+ if (!state) return null;
63
+
64
+ const isBrokerUnreachable = state === 'broker_unreachable';
65
+
66
+ return (
67
+ <InlineBanner
68
+ icon={isBrokerUnreachable ? ServerCrash : ServerOff}
69
+ tone="warning"
70
+ align="start"
71
+ title={
72
+ isBrokerUnreachable
73
+ ? t(keys.background_tasks.worker_health.broker_unreachable_title)
74
+ : t(keys.background_tasks.worker_health.no_workers_title, { count: backlog })
75
+ }
76
+ description={
77
+ isBrokerUnreachable
78
+ ? t(keys.background_tasks.worker_health.broker_unreachable_detail)
79
+ : t(keys.background_tasks.worker_health.no_workers_detail)
80
+ }
81
+ action={
82
+ <Button variant="outline" size="sm" asChild>
83
+ <Link href={`${VIEW_BASE}/workers`}>
84
+ {t(keys.background_tasks.worker_health.view_workers)}
85
+ </Link>
86
+ </Button>
87
+ }
88
+ />
89
+ );
90
+ }
@@ -11,6 +11,9 @@ export interface Execution {
11
11
  task_name: string;
12
12
  status: TaskStatus;
13
13
  queue: string;
14
+ // Shown by the retry confirm before it re-enqueues them — see the list schema.
15
+ args: unknown[];
16
+ kwargs: Record<string, unknown>;
14
17
  retries: number;
15
18
  worker: string | null;
16
19
  queued_at: string | null;