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.
- {simple_module_background_tasks-0.0.30 → simple_module_background_tasks-0.0.32}/.gitignore +1 -0
- {simple_module_background_tasks-0.0.30 → simple_module_background_tasks-0.0.32}/PKG-INFO +6 -6
- {simple_module_background_tasks-0.0.30 → simple_module_background_tasks-0.0.32}/background_tasks/contracts/schemas.py +9 -1
- {simple_module_background_tasks-0.0.30 → simple_module_background_tasks-0.0.32}/background_tasks/endpoints/views.py +39 -1
- {simple_module_background_tasks-0.0.30 → simple_module_background_tasks-0.0.32}/background_tasks/locales/en.json +19 -0
- {simple_module_background_tasks-0.0.30 → simple_module_background_tasks-0.0.32}/background_tasks/module.py +15 -0
- {simple_module_background_tasks-0.0.30 → simple_module_background_tasks-0.0.32}/background_tasks/pages/Detail.tsx +3 -0
- {simple_module_background_tasks-0.0.30 → simple_module_background_tasks-0.0.32}/background_tasks/pages/Index.tsx +65 -17
- {simple_module_background_tasks-0.0.30 → simple_module_background_tasks-0.0.32}/background_tasks/pages/Workers.tsx +33 -4
- {simple_module_background_tasks-0.0.30 → simple_module_background_tasks-0.0.32}/background_tasks/pages/components/ExecutionRow.tsx +5 -2
- simple_module_background_tasks-0.0.32/background_tasks/pages/components/RetryConfirmDialog.tsx +81 -0
- simple_module_background_tasks-0.0.32/background_tasks/pages/components/StatusStrip.tsx +66 -0
- simple_module_background_tasks-0.0.32/background_tasks/pages/components/TasksEmpty.tsx +102 -0
- simple_module_background_tasks-0.0.32/background_tasks/pages/components/WorkerHealthBanner.tsx +90 -0
- {simple_module_background_tasks-0.0.30 → simple_module_background_tasks-0.0.32}/background_tasks/pages/retry.ts +3 -0
- simple_module_background_tasks-0.0.32/background_tasks/pages/worker-health.ts +22 -0
- {simple_module_background_tasks-0.0.30 → simple_module_background_tasks-0.0.32}/background_tasks/service.py +64 -16
- {simple_module_background_tasks-0.0.30 → simple_module_background_tasks-0.0.32}/pyproject.toml +5 -5
- {simple_module_background_tasks-0.0.30 → simple_module_background_tasks-0.0.32}/tests/test_admin_api.py +32 -0
- simple_module_background_tasks-0.0.32/tests/test_bg_index_worker_presence.py +143 -0
- simple_module_background_tasks-0.0.32/tests/test_bg_list_item_payload.py +77 -0
- simple_module_background_tasks-0.0.32/tests/test_bg_page_clamp.py +60 -0
- {simple_module_background_tasks-0.0.30 → simple_module_background_tasks-0.0.32}/tests/test_bg_service.py +40 -0
- simple_module_background_tasks-0.0.30/background_tasks/pages/components/RetryConfirmDialog.tsx +0 -38
- {simple_module_background_tasks-0.0.30 → simple_module_background_tasks-0.0.32}/LICENSE +0 -0
- {simple_module_background_tasks-0.0.30 → simple_module_background_tasks-0.0.32}/README.md +0 -0
- {simple_module_background_tasks-0.0.30 → simple_module_background_tasks-0.0.32}/background_tasks/__init__.py +0 -0
- {simple_module_background_tasks-0.0.30 → simple_module_background_tasks-0.0.32}/background_tasks/_signal_support.py +0 -0
- {simple_module_background_tasks-0.0.30 → simple_module_background_tasks-0.0.32}/background_tasks/celery_app.py +0 -0
- {simple_module_background_tasks-0.0.30 → simple_module_background_tasks-0.0.32}/background_tasks/constants.py +0 -0
- {simple_module_background_tasks-0.0.30 → simple_module_background_tasks-0.0.32}/background_tasks/contracts/__init__.py +0 -0
- {simple_module_background_tasks-0.0.30 → simple_module_background_tasks-0.0.32}/background_tasks/contracts/events.py +0 -0
- {simple_module_background_tasks-0.0.30 → simple_module_background_tasks-0.0.32}/background_tasks/deps.py +0 -0
- {simple_module_background_tasks-0.0.30 → simple_module_background_tasks-0.0.32}/background_tasks/endpoints/__init__.py +0 -0
- {simple_module_background_tasks-0.0.30 → simple_module_background_tasks-0.0.32}/background_tasks/endpoints/api_admin.py +0 -0
- {simple_module_background_tasks-0.0.30 → simple_module_background_tasks-0.0.32}/background_tasks/log_context.py +0 -0
- {simple_module_background_tasks-0.0.30 → simple_module_background_tasks-0.0.32}/background_tasks/models.py +0 -0
- {simple_module_background_tasks-0.0.30 → simple_module_background_tasks-0.0.32}/background_tasks/pages/constants.ts +0 -0
- {simple_module_background_tasks-0.0.30 → simple_module_background_tasks-0.0.32}/background_tasks/py.typed +0 -0
- {simple_module_background_tasks-0.0.30 → simple_module_background_tasks-0.0.32}/background_tasks/services.py +0 -0
- {simple_module_background_tasks-0.0.30 → simple_module_background_tasks-0.0.32}/background_tasks/settings.py +0 -0
- {simple_module_background_tasks-0.0.30 → simple_module_background_tasks-0.0.32}/background_tasks/signals.py +0 -0
- {simple_module_background_tasks-0.0.30 → simple_module_background_tasks-0.0.32}/background_tasks/sync_db.py +0 -0
- {simple_module_background_tasks-0.0.30 → simple_module_background_tasks-0.0.32}/background_tasks/tasks.py +0 -0
- {simple_module_background_tasks-0.0.30 → simple_module_background_tasks-0.0.32}/background_tasks/worker_inspector.py +0 -0
- {simple_module_background_tasks-0.0.30 → simple_module_background_tasks-0.0.32}/package.json +0 -0
- {simple_module_background_tasks-0.0.30 → simple_module_background_tasks-0.0.32}/tests/conftest.py +0 -0
- {simple_module_background_tasks-0.0.30 → simple_module_background_tasks-0.0.32}/tests/test_beat_schedule.py +0 -0
- {simple_module_background_tasks-0.0.30 → simple_module_background_tasks-0.0.32}/tests/test_log_context.py +0 -0
- {simple_module_background_tasks-0.0.30 → simple_module_background_tasks-0.0.32}/tests/test_signals.py +0 -0
- {simple_module_background_tasks-0.0.30 → simple_module_background_tasks-0.0.32}/tests/test_sweep_stuck.py +0 -0
- {simple_module_background_tasks-0.0.30 → simple_module_background_tasks-0.0.32}/tests/test_sync_db.py +0 -0
- {simple_module_background_tasks-0.0.30 → simple_module_background_tasks-0.0.32}/tests/test_worker_inspector.py +0 -0
- {simple_module_background_tasks-0.0.30 → simple_module_background_tasks-0.0.32}/tests/test_workers_endpoints.py +0 -0
- {simple_module_background_tasks-0.0.30 → simple_module_background_tasks-0.0.32}/tsconfig.json +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
Metadata-Version: 2.
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
2
|
Name: simple_module_background_tasks
|
|
3
|
-
Version: 0.0.
|
|
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.
|
|
27
|
-
Requires-Dist: simple-module-db==0.0.
|
|
28
|
-
Requires-Dist: simple-module-hosting==0.0.
|
|
29
|
-
Requires-Dist: simple-module-settings==0.0.
|
|
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
|
|
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(
|
|
@@ -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 {
|
|
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 {
|
|
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
|
-
|
|
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
|
-
<
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
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
|
|
106
|
-
|
|
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
|
-
|
|
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
|
-
{
|
|
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
|
) : (
|
simple_module_background_tasks-0.0.32/background_tasks/pages/components/RetryConfirmDialog.tsx
ADDED
|
@@ -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
|
+
}
|