simple-module-background-tasks 0.0.30__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.30 → simple_module_background_tasks-0.0.32}/.gitignore +1 -0
  2. {simple_module_background_tasks-0.0.30 → simple_module_background_tasks-0.0.32}/PKG-INFO +6 -6
  3. {simple_module_background_tasks-0.0.30 → simple_module_background_tasks-0.0.32}/background_tasks/contracts/schemas.py +9 -1
  4. {simple_module_background_tasks-0.0.30 → simple_module_background_tasks-0.0.32}/background_tasks/endpoints/views.py +39 -1
  5. {simple_module_background_tasks-0.0.30 → simple_module_background_tasks-0.0.32}/background_tasks/locales/en.json +19 -0
  6. {simple_module_background_tasks-0.0.30 → simple_module_background_tasks-0.0.32}/background_tasks/module.py +15 -0
  7. {simple_module_background_tasks-0.0.30 → simple_module_background_tasks-0.0.32}/background_tasks/pages/Detail.tsx +3 -0
  8. {simple_module_background_tasks-0.0.30 → simple_module_background_tasks-0.0.32}/background_tasks/pages/Index.tsx +65 -17
  9. {simple_module_background_tasks-0.0.30 → simple_module_background_tasks-0.0.32}/background_tasks/pages/Workers.tsx +33 -4
  10. {simple_module_background_tasks-0.0.30 → simple_module_background_tasks-0.0.32}/background_tasks/pages/components/ExecutionRow.tsx +5 -2
  11. simple_module_background_tasks-0.0.32/background_tasks/pages/components/RetryConfirmDialog.tsx +81 -0
  12. simple_module_background_tasks-0.0.32/background_tasks/pages/components/StatusStrip.tsx +66 -0
  13. simple_module_background_tasks-0.0.32/background_tasks/pages/components/TasksEmpty.tsx +102 -0
  14. simple_module_background_tasks-0.0.32/background_tasks/pages/components/WorkerHealthBanner.tsx +90 -0
  15. {simple_module_background_tasks-0.0.30 → simple_module_background_tasks-0.0.32}/background_tasks/pages/retry.ts +3 -0
  16. simple_module_background_tasks-0.0.32/background_tasks/pages/worker-health.ts +22 -0
  17. {simple_module_background_tasks-0.0.30 → simple_module_background_tasks-0.0.32}/background_tasks/service.py +64 -16
  18. {simple_module_background_tasks-0.0.30 → simple_module_background_tasks-0.0.32}/pyproject.toml +5 -5
  19. {simple_module_background_tasks-0.0.30 → simple_module_background_tasks-0.0.32}/tests/test_admin_api.py +32 -0
  20. simple_module_background_tasks-0.0.32/tests/test_bg_index_worker_presence.py +143 -0
  21. simple_module_background_tasks-0.0.32/tests/test_bg_list_item_payload.py +77 -0
  22. simple_module_background_tasks-0.0.32/tests/test_bg_page_clamp.py +60 -0
  23. {simple_module_background_tasks-0.0.30 → simple_module_background_tasks-0.0.32}/tests/test_bg_service.py +40 -0
  24. simple_module_background_tasks-0.0.30/background_tasks/pages/components/RetryConfirmDialog.tsx +0 -38
  25. {simple_module_background_tasks-0.0.30 → simple_module_background_tasks-0.0.32}/LICENSE +0 -0
  26. {simple_module_background_tasks-0.0.30 → simple_module_background_tasks-0.0.32}/README.md +0 -0
  27. {simple_module_background_tasks-0.0.30 → simple_module_background_tasks-0.0.32}/background_tasks/__init__.py +0 -0
  28. {simple_module_background_tasks-0.0.30 → simple_module_background_tasks-0.0.32}/background_tasks/_signal_support.py +0 -0
  29. {simple_module_background_tasks-0.0.30 → simple_module_background_tasks-0.0.32}/background_tasks/celery_app.py +0 -0
  30. {simple_module_background_tasks-0.0.30 → simple_module_background_tasks-0.0.32}/background_tasks/constants.py +0 -0
  31. {simple_module_background_tasks-0.0.30 → simple_module_background_tasks-0.0.32}/background_tasks/contracts/__init__.py +0 -0
  32. {simple_module_background_tasks-0.0.30 → simple_module_background_tasks-0.0.32}/background_tasks/contracts/events.py +0 -0
  33. {simple_module_background_tasks-0.0.30 → simple_module_background_tasks-0.0.32}/background_tasks/deps.py +0 -0
  34. {simple_module_background_tasks-0.0.30 → simple_module_background_tasks-0.0.32}/background_tasks/endpoints/__init__.py +0 -0
  35. {simple_module_background_tasks-0.0.30 → simple_module_background_tasks-0.0.32}/background_tasks/endpoints/api_admin.py +0 -0
  36. {simple_module_background_tasks-0.0.30 → simple_module_background_tasks-0.0.32}/background_tasks/log_context.py +0 -0
  37. {simple_module_background_tasks-0.0.30 → simple_module_background_tasks-0.0.32}/background_tasks/models.py +0 -0
  38. {simple_module_background_tasks-0.0.30 → simple_module_background_tasks-0.0.32}/background_tasks/pages/constants.ts +0 -0
  39. {simple_module_background_tasks-0.0.30 → simple_module_background_tasks-0.0.32}/background_tasks/py.typed +0 -0
  40. {simple_module_background_tasks-0.0.30 → simple_module_background_tasks-0.0.32}/background_tasks/services.py +0 -0
  41. {simple_module_background_tasks-0.0.30 → simple_module_background_tasks-0.0.32}/background_tasks/settings.py +0 -0
  42. {simple_module_background_tasks-0.0.30 → simple_module_background_tasks-0.0.32}/background_tasks/signals.py +0 -0
  43. {simple_module_background_tasks-0.0.30 → simple_module_background_tasks-0.0.32}/background_tasks/sync_db.py +0 -0
  44. {simple_module_background_tasks-0.0.30 → simple_module_background_tasks-0.0.32}/background_tasks/tasks.py +0 -0
  45. {simple_module_background_tasks-0.0.30 → simple_module_background_tasks-0.0.32}/background_tasks/worker_inspector.py +0 -0
  46. {simple_module_background_tasks-0.0.30 → simple_module_background_tasks-0.0.32}/package.json +0 -0
  47. {simple_module_background_tasks-0.0.30 → simple_module_background_tasks-0.0.32}/tests/conftest.py +0 -0
  48. {simple_module_background_tasks-0.0.30 → simple_module_background_tasks-0.0.32}/tests/test_beat_schedule.py +0 -0
  49. {simple_module_background_tasks-0.0.30 → simple_module_background_tasks-0.0.32}/tests/test_log_context.py +0 -0
  50. {simple_module_background_tasks-0.0.30 → simple_module_background_tasks-0.0.32}/tests/test_signals.py +0 -0
  51. {simple_module_background_tasks-0.0.30 → simple_module_background_tasks-0.0.32}/tests/test_sweep_stuck.py +0 -0
  52. {simple_module_background_tasks-0.0.30 → simple_module_background_tasks-0.0.32}/tests/test_sync_db.py +0 -0
  53. {simple_module_background_tasks-0.0.30 → simple_module_background_tasks-0.0.32}/tests/test_worker_inspector.py +0 -0
  54. {simple_module_background_tasks-0.0.30 → simple_module_background_tasks-0.0.32}/tests/test_workers_endpoints.py +0 -0
  55. {simple_module_background_tasks-0.0.30 → simple_module_background_tasks-0.0.32}/tsconfig.json +0 -0
@@ -63,3 +63,4 @@ Thumbs.db
63
63
  host/client_app/.playwright-cli/*
64
64
  .superpowers/
65
65
  .qa/
66
+ .verify/
@@ -1,6 +1,6 @@
1
- Metadata-Version: 2.4
1
+ Metadata-Version: 2.5
2
2
  Name: simple_module_background_tasks
3
- Version: 0.0.30
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.30
27
- Requires-Dist: simple-module-db==0.0.30
28
- Requires-Dist: simple-module-hosting==0.0.30
29
- Requires-Dist: simple-module-settings==0.0.30
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,10 +58,27 @@ async def index(
37
58
  page=page,
38
59
  per_page=PER_PAGE,
39
60
  )
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
40
76
  return await inertia.render(
41
77
  "BackgroundTasks/Index",
42
78
  {
43
79
  "executions": [i.model_dump(mode="json") for i in response.items],
80
+ "status_counts": {s.value: counts.get(s.value, 0) for s in TaskStatus},
81
+ "worker_presence": presence,
44
82
  "pagination": {
45
83
  "page": response.page,
46
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
  }
@@ -8,6 +8,7 @@ from pathlib import Path
8
8
  from typing import TYPE_CHECKING
9
9
 
10
10
  from fastapi import APIRouter
11
+ from simple_module_core.audit_links import AuditLinkRegistry
11
12
  from simple_module_core.menu import MenuItem, MenuRegistry, MenuSection
12
13
  from simple_module_core.module import ModuleBase, ModuleMeta
13
14
  from simple_module_core.permissions import PermissionRegistry
@@ -69,6 +70,20 @@ class BackgroundTasksModule(ModuleBase):
69
70
  def register_permissions(self, registry: PermissionRegistry) -> None:
70
71
  registry.add_group(PERM_GROUP, [PERM_VIEW, PERM_MANAGE])
71
72
 
73
+ def register_audit_links(self, registry: AuditLinkRegistry) -> None:
74
+ from simple_module_core.audit_links import AuditLink
75
+
76
+ from background_tasks.models import TaskExecution
77
+
78
+ registry.register(
79
+ AuditLink(
80
+ # Class name, not __tablename__ — see AuditLink.entity_type.
81
+ entity_type=TaskExecution.__name__,
82
+ url_template=f"{VIEW_PREFIX}/{{id}}",
83
+ label="Task execution",
84
+ )
85
+ )
86
+
72
87
  def register_menu_items(self, registry: MenuRegistry) -> None:
73
88
  registry.add(
74
89
  MenuItem(
@@ -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,17 +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
- import { STATUS_ORDER, VIEW_BASE } from './constants';
25
+ import { type StatusCounts, StatusStrip } from './components/StatusStrip';
26
+ import { TasksEmptyRow, type WorkerPresence } from './components/TasksEmpty';
27
+ import { WorkerHealthBanner } from './components/WorkerHealthBanner';
28
+ import { STATUS_ORDER, TASK_STATUS, VIEW_BASE } from './constants';
27
29
  import { type Execution, retryExecution } from './retry';
28
30
 
29
31
  interface Pagination {
@@ -36,8 +38,14 @@ interface Props {
36
38
  executions: Execution[];
37
39
  pagination: Pagination;
38
40
  filters: { status: string; task_name: string };
41
+ status_counts: StatusCounts;
42
+ /** Null unless the unfiltered list came back empty — see the index view. */
43
+ worker_presence: WorkerPresence | null;
39
44
  }
40
45
 
46
+ /** Task, Status, Queue, Queued, Duration, Worker, Actions. */
47
+ const COLUMN_COUNT = 7;
48
+
41
49
  const STATUS_ALL = '__all__';
42
50
 
43
51
  function pushFilters(filters: { status: string; task_name: string }, page: number): void {
@@ -53,6 +61,8 @@ function Index() {
53
61
  executions,
54
62
  pagination,
55
63
  filters: initialFilters,
64
+ status_counts: statusCounts,
65
+ worker_presence: workerPresence,
56
66
  } = usePage<{ props: Props }>().props as unknown as Props;
57
67
 
58
68
  const { can } = usePermissions();
@@ -61,10 +71,40 @@ function Index() {
61
71
  const [search, setSearch] = useState(initialFilters.task_name ?? '');
62
72
  const totalPages = Math.ceil(pagination.total / pagination.per_page);
63
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
+ }
64
100
 
65
101
  // Debounce search: any change from the server-provided value kicks off a
66
102
  // page-1 navigation 300ms after the user stops typing.
67
103
  useEffect(() => {
104
+ if (skipNextDebounceRef.current) {
105
+ skipNextDebounceRef.current = false;
106
+ return;
107
+ }
68
108
  if (search === (initialFilters.task_name ?? '')) return;
69
109
  const timeout = setTimeout(
70
110
  () => pushFilters({ status: statusValue, task_name: search }, 1),
@@ -75,7 +115,9 @@ function Index() {
75
115
 
76
116
  async function handleRetry(execution: Execution) {
77
117
  const created = await retryExecution(execution);
78
- if (created) router.reload({ only: ['executions', 'pagination'] });
118
+ // status_counts feeds the strip above the table — a retry moves a row out
119
+ // of "failed", so leaving it out of the reload leaves the tile lying.
120
+ if (created) router.reload({ only: ['executions', 'pagination', 'status_counts'] });
79
121
  }
80
122
 
81
123
  return (
@@ -85,6 +127,18 @@ function Index() {
85
127
  title="Background Tasks"
86
128
  description="Monitor task executions and retry failed or stuck jobs."
87
129
  >
130
+ <StatusStrip
131
+ counts={statusCounts ?? {}}
132
+ active={initialFilters.status ?? ''}
133
+ onSelect={(status) => pushFilters({ status, task_name: search }, 1)}
134
+ />
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
+
88
142
  <div className="mb-4 flex flex-col sm:flex-row items-stretch sm:items-center justify-between gap-3">
89
143
  <div className="relative flex-1 max-w-sm">
90
144
  <Search className="absolute left-3 top-1/2 -translate-y-1/2 size-4 text-muted-foreground" />
@@ -139,18 +193,12 @@ function Index() {
139
193
  <ExecutionRow key={e.id} execution={e} canRetry={canRetry} onRetry={handleRetry} />
140
194
  ))}
141
195
  {executions.length === 0 && (
142
- <TableRow>
143
- <TableCell colSpan={7} className="h-32 text-center">
144
- <div className="flex flex-col items-center gap-2 text-muted-foreground">
145
- <Activity className="size-8" />
146
- <p>
147
- {search
148
- ? `No tasks match "${search}"`
149
- : 'No task executions yet. Tasks appear here as soon as modules enqueue work.'}
150
- </p>
151
- </div>
152
- </TableCell>
153
- </TableRow>
196
+ <TasksEmptyRow
197
+ filtered={isFiltered}
198
+ columnCount={COLUMN_COUNT}
199
+ presence={workerPresence ?? null}
200
+ onClear={clearFilters}
201
+ />
154
202
  )}
155
203
  </TableBody>
156
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,66 @@
1
+ import { TASK_STATUS, type TaskStatus } from '../constants';
2
+ import { statusLabel } from './ExecutionRow';
3
+
4
+ export type StatusCounts = Partial<Record<TaskStatus, number>>;
5
+
6
+ interface Props {
7
+ counts: StatusCounts;
8
+ /** Currently filtered status, or '' for all. */
9
+ active: string;
10
+ onSelect: (status: string) => void;
11
+ }
12
+
13
+ /**
14
+ * Statuses worth a dedicated tile, in the order an operator triages them.
15
+ * Failed and stuck lead because they are the reason anyone opens this page.
16
+ */
17
+ const TILES: TaskStatus[] = [
18
+ TASK_STATUS.FAILED,
19
+ TASK_STATUS.STUCK,
20
+ TASK_STATUS.RETRYING,
21
+ TASK_STATUS.RUNNING,
22
+ TASK_STATUS.PENDING,
23
+ TASK_STATUS.SUCCESS,
24
+ ];
25
+
26
+ /** Tiles that mean "someone needs to look at this" get an alarm colour, but
27
+ * only when they are non-zero — a permanently red zero trains people to
28
+ * ignore it. */
29
+ const ALARMING = new Set<TaskStatus>([TASK_STATUS.FAILED, TASK_STATUS.STUCK]);
30
+
31
+ export function StatusStrip({ counts, active, onSelect }: Props) {
32
+ return (
33
+ <div className="mb-4 grid grid-cols-3 gap-2 sm:grid-cols-6">
34
+ {TILES.map((status) => {
35
+ const count = counts[status] ?? 0;
36
+ const isActive = active === status;
37
+ const alarm = ALARMING.has(status) && count > 0;
38
+ return (
39
+ <button
40
+ key={status}
41
+ type="button"
42
+ aria-pressed={isActive}
43
+ // Clicking the tile you already filtered by clears the filter,
44
+ // so the strip can undo itself without reaching for the dropdown.
45
+ onClick={() => onSelect(isActive ? '' : status)}
46
+ className={[
47
+ 'rounded-lg border px-3 py-2 text-left transition-colors',
48
+ isActive
49
+ ? 'border-primary bg-accent'
50
+ : 'border-border bg-card hover:border-primary/40 hover:bg-accent/50',
51
+ ].join(' ')}
52
+ >
53
+ <div
54
+ className={`text-lg font-semibold tabular-nums leading-tight ${
55
+ alarm ? 'text-red-600 dark:text-red-400' : 'text-foreground'
56
+ }`}
57
+ >
58
+ {count}
59
+ </div>
60
+ <div className="truncate text-[11px] text-muted-foreground">{statusLabel(status)}</div>
61
+ </button>
62
+ );
63
+ })}
64
+ </div>
65
+ );
66
+ }
@@ -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
+ }