simple-module-background-tasks 0.0.30__tar.gz → 0.0.31__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.31}/.gitignore +1 -0
- {simple_module_background_tasks-0.0.30 → simple_module_background_tasks-0.0.31}/PKG-INFO +6 -6
- {simple_module_background_tasks-0.0.30 → simple_module_background_tasks-0.0.31}/background_tasks/endpoints/views.py +2 -0
- {simple_module_background_tasks-0.0.30 → simple_module_background_tasks-0.0.31}/background_tasks/module.py +15 -0
- {simple_module_background_tasks-0.0.30 → simple_module_background_tasks-0.0.31}/background_tasks/pages/Index.tsx +12 -1
- simple_module_background_tasks-0.0.31/background_tasks/pages/components/StatusStrip.tsx +66 -0
- {simple_module_background_tasks-0.0.30 → simple_module_background_tasks-0.0.31}/background_tasks/service.py +20 -0
- {simple_module_background_tasks-0.0.30 → simple_module_background_tasks-0.0.31}/pyproject.toml +5 -5
- {simple_module_background_tasks-0.0.30 → simple_module_background_tasks-0.0.31}/tests/test_bg_service.py +40 -0
- {simple_module_background_tasks-0.0.30 → simple_module_background_tasks-0.0.31}/LICENSE +0 -0
- {simple_module_background_tasks-0.0.30 → simple_module_background_tasks-0.0.31}/README.md +0 -0
- {simple_module_background_tasks-0.0.30 → simple_module_background_tasks-0.0.31}/background_tasks/__init__.py +0 -0
- {simple_module_background_tasks-0.0.30 → simple_module_background_tasks-0.0.31}/background_tasks/_signal_support.py +0 -0
- {simple_module_background_tasks-0.0.30 → simple_module_background_tasks-0.0.31}/background_tasks/celery_app.py +0 -0
- {simple_module_background_tasks-0.0.30 → simple_module_background_tasks-0.0.31}/background_tasks/constants.py +0 -0
- {simple_module_background_tasks-0.0.30 → simple_module_background_tasks-0.0.31}/background_tasks/contracts/__init__.py +0 -0
- {simple_module_background_tasks-0.0.30 → simple_module_background_tasks-0.0.31}/background_tasks/contracts/events.py +0 -0
- {simple_module_background_tasks-0.0.30 → simple_module_background_tasks-0.0.31}/background_tasks/contracts/schemas.py +0 -0
- {simple_module_background_tasks-0.0.30 → simple_module_background_tasks-0.0.31}/background_tasks/deps.py +0 -0
- {simple_module_background_tasks-0.0.30 → simple_module_background_tasks-0.0.31}/background_tasks/endpoints/__init__.py +0 -0
- {simple_module_background_tasks-0.0.30 → simple_module_background_tasks-0.0.31}/background_tasks/endpoints/api_admin.py +0 -0
- {simple_module_background_tasks-0.0.30 → simple_module_background_tasks-0.0.31}/background_tasks/locales/en.json +0 -0
- {simple_module_background_tasks-0.0.30 → simple_module_background_tasks-0.0.31}/background_tasks/log_context.py +0 -0
- {simple_module_background_tasks-0.0.30 → simple_module_background_tasks-0.0.31}/background_tasks/models.py +0 -0
- {simple_module_background_tasks-0.0.30 → simple_module_background_tasks-0.0.31}/background_tasks/pages/Detail.tsx +0 -0
- {simple_module_background_tasks-0.0.30 → simple_module_background_tasks-0.0.31}/background_tasks/pages/Workers.tsx +0 -0
- {simple_module_background_tasks-0.0.30 → simple_module_background_tasks-0.0.31}/background_tasks/pages/components/ExecutionRow.tsx +0 -0
- {simple_module_background_tasks-0.0.30 → simple_module_background_tasks-0.0.31}/background_tasks/pages/components/RetryConfirmDialog.tsx +0 -0
- {simple_module_background_tasks-0.0.30 → simple_module_background_tasks-0.0.31}/background_tasks/pages/constants.ts +0 -0
- {simple_module_background_tasks-0.0.30 → simple_module_background_tasks-0.0.31}/background_tasks/pages/retry.ts +0 -0
- {simple_module_background_tasks-0.0.30 → simple_module_background_tasks-0.0.31}/background_tasks/py.typed +0 -0
- {simple_module_background_tasks-0.0.30 → simple_module_background_tasks-0.0.31}/background_tasks/services.py +0 -0
- {simple_module_background_tasks-0.0.30 → simple_module_background_tasks-0.0.31}/background_tasks/settings.py +0 -0
- {simple_module_background_tasks-0.0.30 → simple_module_background_tasks-0.0.31}/background_tasks/signals.py +0 -0
- {simple_module_background_tasks-0.0.30 → simple_module_background_tasks-0.0.31}/background_tasks/sync_db.py +0 -0
- {simple_module_background_tasks-0.0.30 → simple_module_background_tasks-0.0.31}/background_tasks/tasks.py +0 -0
- {simple_module_background_tasks-0.0.30 → simple_module_background_tasks-0.0.31}/background_tasks/worker_inspector.py +0 -0
- {simple_module_background_tasks-0.0.30 → simple_module_background_tasks-0.0.31}/package.json +0 -0
- {simple_module_background_tasks-0.0.30 → simple_module_background_tasks-0.0.31}/tests/conftest.py +0 -0
- {simple_module_background_tasks-0.0.30 → simple_module_background_tasks-0.0.31}/tests/test_admin_api.py +0 -0
- {simple_module_background_tasks-0.0.30 → simple_module_background_tasks-0.0.31}/tests/test_beat_schedule.py +0 -0
- {simple_module_background_tasks-0.0.30 → simple_module_background_tasks-0.0.31}/tests/test_log_context.py +0 -0
- {simple_module_background_tasks-0.0.30 → simple_module_background_tasks-0.0.31}/tests/test_signals.py +0 -0
- {simple_module_background_tasks-0.0.30 → simple_module_background_tasks-0.0.31}/tests/test_sweep_stuck.py +0 -0
- {simple_module_background_tasks-0.0.30 → simple_module_background_tasks-0.0.31}/tests/test_sync_db.py +0 -0
- {simple_module_background_tasks-0.0.30 → simple_module_background_tasks-0.0.31}/tests/test_worker_inspector.py +0 -0
- {simple_module_background_tasks-0.0.30 → simple_module_background_tasks-0.0.31}/tests/test_workers_endpoints.py +0 -0
- {simple_module_background_tasks-0.0.30 → simple_module_background_tasks-0.0.31}/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.31
|
|
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.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
|
|
30
30
|
Description-Content-Type: text/markdown
|
|
31
31
|
|
|
32
32
|
# simple_module_background_tasks
|
|
@@ -37,10 +37,12 @@ async def index(
|
|
|
37
37
|
page=page,
|
|
38
38
|
per_page=PER_PAGE,
|
|
39
39
|
)
|
|
40
|
+
counts = await service.status_counts(task_name=task_name or None)
|
|
40
41
|
return await inertia.render(
|
|
41
42
|
"BackgroundTasks/Index",
|
|
42
43
|
{
|
|
43
44
|
"executions": [i.model_dump(mode="json") for i in response.items],
|
|
45
|
+
"status_counts": {s.value: counts.get(s.value, 0) for s in TaskStatus},
|
|
44
46
|
"pagination": {
|
|
45
47
|
"page": response.page,
|
|
46
48
|
"per_page": response.per_page,
|
|
@@ -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(
|
|
@@ -23,6 +23,7 @@ import { AuthenticatedLayout } from '@simple-module-py/ui/layouts/AuthenticatedL
|
|
|
23
23
|
import { Activity, Search, ServerCog } from 'lucide-react';
|
|
24
24
|
import { useEffect, useState } from 'react';
|
|
25
25
|
import { ExecutionRow, statusLabel } from './components/ExecutionRow';
|
|
26
|
+
import { type StatusCounts, StatusStrip } from './components/StatusStrip';
|
|
26
27
|
import { STATUS_ORDER, VIEW_BASE } from './constants';
|
|
27
28
|
import { type Execution, retryExecution } from './retry';
|
|
28
29
|
|
|
@@ -36,6 +37,7 @@ interface Props {
|
|
|
36
37
|
executions: Execution[];
|
|
37
38
|
pagination: Pagination;
|
|
38
39
|
filters: { status: string; task_name: string };
|
|
40
|
+
status_counts: StatusCounts;
|
|
39
41
|
}
|
|
40
42
|
|
|
41
43
|
const STATUS_ALL = '__all__';
|
|
@@ -53,6 +55,7 @@ function Index() {
|
|
|
53
55
|
executions,
|
|
54
56
|
pagination,
|
|
55
57
|
filters: initialFilters,
|
|
58
|
+
status_counts: statusCounts,
|
|
56
59
|
} = usePage<{ props: Props }>().props as unknown as Props;
|
|
57
60
|
|
|
58
61
|
const { can } = usePermissions();
|
|
@@ -75,7 +78,9 @@ function Index() {
|
|
|
75
78
|
|
|
76
79
|
async function handleRetry(execution: Execution) {
|
|
77
80
|
const created = await retryExecution(execution);
|
|
78
|
-
|
|
81
|
+
// status_counts feeds the strip above the table — a retry moves a row out
|
|
82
|
+
// of "failed", so leaving it out of the reload leaves the tile lying.
|
|
83
|
+
if (created) router.reload({ only: ['executions', 'pagination', 'status_counts'] });
|
|
79
84
|
}
|
|
80
85
|
|
|
81
86
|
return (
|
|
@@ -85,6 +90,12 @@ function Index() {
|
|
|
85
90
|
title="Background Tasks"
|
|
86
91
|
description="Monitor task executions and retry failed or stuck jobs."
|
|
87
92
|
>
|
|
93
|
+
<StatusStrip
|
|
94
|
+
counts={statusCounts ?? {}}
|
|
95
|
+
active={initialFilters.status ?? ''}
|
|
96
|
+
onSelect={(status) => pushFilters({ status, task_name: search }, 1)}
|
|
97
|
+
/>
|
|
98
|
+
|
|
88
99
|
<div className="mb-4 flex flex-col sm:flex-row items-stretch sm:items-center justify-between gap-3">
|
|
89
100
|
<div className="relative flex-1 max-w-sm">
|
|
90
101
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 size-4 text-muted-foreground" />
|
|
@@ -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
|
+
}
|
|
@@ -82,6 +82,26 @@ class BackgroundTaskService:
|
|
|
82
82
|
task_name=task_name,
|
|
83
83
|
)
|
|
84
84
|
|
|
85
|
+
async def status_counts(self, *, task_name: str | None = None) -> dict[str, int]:
|
|
86
|
+
"""Count executions per status for the ops strip above the table.
|
|
87
|
+
|
|
88
|
+
Deliberately ignores the status filter — the strip is how the operator
|
|
89
|
+
picks a status, so it has to keep showing the ones they aren't looking
|
|
90
|
+
at. It does honour ``task_name`` so the counts describe the same
|
|
91
|
+
result set the table is paging through.
|
|
92
|
+
|
|
93
|
+
Statuses with no rows are omitted; callers fill in zeros.
|
|
94
|
+
"""
|
|
95
|
+
query = select(TaskExecution.status, func.count().label("n"))
|
|
96
|
+
if task_name:
|
|
97
|
+
query = query.where(TaskExecution.task_name.ilike(f"%{task_name}%"))
|
|
98
|
+
query = query.group_by(TaskExecution.status)
|
|
99
|
+
|
|
100
|
+
rows = (await self.db.execute(query)).all()
|
|
101
|
+
# `status` is a TaskStatus (StrEnum) on Postgres but comes back as a
|
|
102
|
+
# plain str on SQLite; normalise so the JSON keys match either way.
|
|
103
|
+
return {str(getattr(row[0], "value", row[0])): int(row[1]) for row in rows}
|
|
104
|
+
|
|
85
105
|
async def get(self, execution_id: uuid.UUID) -> TaskExecutionDetail | None:
|
|
86
106
|
row = await self.db.get(TaskExecution, execution_id)
|
|
87
107
|
if row is None:
|
{simple_module_background_tasks-0.0.30 → simple_module_background_tasks-0.0.31}/pyproject.toml
RENAMED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "simple_module_background_tasks"
|
|
3
|
-
version = "0.0.
|
|
3
|
+
version = "0.0.31"
|
|
4
4
|
description = "Celery + Redis task queue with admin UI for monitoring and retrying failed/stuck tasks"
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
license = "MIT"
|
|
@@ -21,10 +21,10 @@ classifiers = [
|
|
|
21
21
|
"Typing :: Typed",
|
|
22
22
|
]
|
|
23
23
|
dependencies = [
|
|
24
|
-
"simple_module_core==0.0.
|
|
25
|
-
"simple_module_db==0.0.
|
|
26
|
-
"simple_module_hosting==0.0.
|
|
27
|
-
"simple_module_settings==0.0.
|
|
24
|
+
"simple_module_core==0.0.31",
|
|
25
|
+
"simple_module_db==0.0.31",
|
|
26
|
+
"simple_module_hosting==0.0.31",
|
|
27
|
+
"simple_module_settings==0.0.31",
|
|
28
28
|
"celery[redis]>=5.4",
|
|
29
29
|
"redis>=5",
|
|
30
30
|
]
|
|
@@ -99,6 +99,46 @@ class TestList:
|
|
|
99
99
|
assert [i.task_name for i in resp.items] == ["orders.send_receipt"]
|
|
100
100
|
|
|
101
101
|
|
|
102
|
+
class TestStatusCounts:
|
|
103
|
+
"""Feeds the failed/stuck ops strip above the executions table."""
|
|
104
|
+
|
|
105
|
+
async def test_counts_are_grouped_by_status(
|
|
106
|
+
self, db_session: AsyncSession, service: BackgroundTaskService
|
|
107
|
+
):
|
|
108
|
+
for status in (TaskStatus.FAILED, TaskStatus.FAILED, TaskStatus.SUCCESS):
|
|
109
|
+
db_session.add(_make_row(status=status))
|
|
110
|
+
await db_session.flush()
|
|
111
|
+
|
|
112
|
+
counts = await service.status_counts()
|
|
113
|
+
assert counts[TaskStatus.FAILED.value] == 2
|
|
114
|
+
assert counts[TaskStatus.SUCCESS.value] == 1
|
|
115
|
+
|
|
116
|
+
async def test_empty_table_yields_no_counts(self, service: BackgroundTaskService):
|
|
117
|
+
assert await service.status_counts() == {}
|
|
118
|
+
|
|
119
|
+
async def test_search_narrows_the_counts(
|
|
120
|
+
self, db_session: AsyncSession, service: BackgroundTaskService
|
|
121
|
+
):
|
|
122
|
+
"""The strip must describe the same rows the table is paging through."""
|
|
123
|
+
db_session.add(_make_row(task_name="orders.send_receipt", status=TaskStatus.FAILED))
|
|
124
|
+
db_session.add(_make_row(task_name="users.sync", status=TaskStatus.FAILED))
|
|
125
|
+
await db_session.flush()
|
|
126
|
+
|
|
127
|
+
counts = await service.status_counts(task_name="receipt")
|
|
128
|
+
assert counts == {TaskStatus.FAILED.value: 1}
|
|
129
|
+
|
|
130
|
+
async def test_counts_keys_are_plain_strings(
|
|
131
|
+
self, db_session: AsyncSession, service: BackgroundTaskService
|
|
132
|
+
):
|
|
133
|
+
"""Postgres hands back the enum, SQLite a str — the page needs one shape."""
|
|
134
|
+
db_session.add(_make_row(status=TaskStatus.STUCK))
|
|
135
|
+
await db_session.flush()
|
|
136
|
+
|
|
137
|
+
counts = await service.status_counts()
|
|
138
|
+
assert all(type(key) is str for key in counts)
|
|
139
|
+
assert "stuck" in counts
|
|
140
|
+
|
|
141
|
+
|
|
102
142
|
class TestGet:
|
|
103
143
|
async def test_returns_none_for_missing_id(self, service: BackgroundTaskService):
|
|
104
144
|
assert await service.get(uuid.uuid4()) is None
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{simple_module_background_tasks-0.0.30 → simple_module_background_tasks-0.0.31}/package.json
RENAMED
|
File without changes
|
{simple_module_background_tasks-0.0.30 → simple_module_background_tasks-0.0.31}/tests/conftest.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{simple_module_background_tasks-0.0.30 → simple_module_background_tasks-0.0.31}/tsconfig.json
RENAMED
|
File without changes
|