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.
Files changed (48) hide show
  1. {simple_module_background_tasks-0.0.30 → simple_module_background_tasks-0.0.31}/.gitignore +1 -0
  2. {simple_module_background_tasks-0.0.30 → simple_module_background_tasks-0.0.31}/PKG-INFO +6 -6
  3. {simple_module_background_tasks-0.0.30 → simple_module_background_tasks-0.0.31}/background_tasks/endpoints/views.py +2 -0
  4. {simple_module_background_tasks-0.0.30 → simple_module_background_tasks-0.0.31}/background_tasks/module.py +15 -0
  5. {simple_module_background_tasks-0.0.30 → simple_module_background_tasks-0.0.31}/background_tasks/pages/Index.tsx +12 -1
  6. simple_module_background_tasks-0.0.31/background_tasks/pages/components/StatusStrip.tsx +66 -0
  7. {simple_module_background_tasks-0.0.30 → simple_module_background_tasks-0.0.31}/background_tasks/service.py +20 -0
  8. {simple_module_background_tasks-0.0.30 → simple_module_background_tasks-0.0.31}/pyproject.toml +5 -5
  9. {simple_module_background_tasks-0.0.30 → simple_module_background_tasks-0.0.31}/tests/test_bg_service.py +40 -0
  10. {simple_module_background_tasks-0.0.30 → simple_module_background_tasks-0.0.31}/LICENSE +0 -0
  11. {simple_module_background_tasks-0.0.30 → simple_module_background_tasks-0.0.31}/README.md +0 -0
  12. {simple_module_background_tasks-0.0.30 → simple_module_background_tasks-0.0.31}/background_tasks/__init__.py +0 -0
  13. {simple_module_background_tasks-0.0.30 → simple_module_background_tasks-0.0.31}/background_tasks/_signal_support.py +0 -0
  14. {simple_module_background_tasks-0.0.30 → simple_module_background_tasks-0.0.31}/background_tasks/celery_app.py +0 -0
  15. {simple_module_background_tasks-0.0.30 → simple_module_background_tasks-0.0.31}/background_tasks/constants.py +0 -0
  16. {simple_module_background_tasks-0.0.30 → simple_module_background_tasks-0.0.31}/background_tasks/contracts/__init__.py +0 -0
  17. {simple_module_background_tasks-0.0.30 → simple_module_background_tasks-0.0.31}/background_tasks/contracts/events.py +0 -0
  18. {simple_module_background_tasks-0.0.30 → simple_module_background_tasks-0.0.31}/background_tasks/contracts/schemas.py +0 -0
  19. {simple_module_background_tasks-0.0.30 → simple_module_background_tasks-0.0.31}/background_tasks/deps.py +0 -0
  20. {simple_module_background_tasks-0.0.30 → simple_module_background_tasks-0.0.31}/background_tasks/endpoints/__init__.py +0 -0
  21. {simple_module_background_tasks-0.0.30 → simple_module_background_tasks-0.0.31}/background_tasks/endpoints/api_admin.py +0 -0
  22. {simple_module_background_tasks-0.0.30 → simple_module_background_tasks-0.0.31}/background_tasks/locales/en.json +0 -0
  23. {simple_module_background_tasks-0.0.30 → simple_module_background_tasks-0.0.31}/background_tasks/log_context.py +0 -0
  24. {simple_module_background_tasks-0.0.30 → simple_module_background_tasks-0.0.31}/background_tasks/models.py +0 -0
  25. {simple_module_background_tasks-0.0.30 → simple_module_background_tasks-0.0.31}/background_tasks/pages/Detail.tsx +0 -0
  26. {simple_module_background_tasks-0.0.30 → simple_module_background_tasks-0.0.31}/background_tasks/pages/Workers.tsx +0 -0
  27. {simple_module_background_tasks-0.0.30 → simple_module_background_tasks-0.0.31}/background_tasks/pages/components/ExecutionRow.tsx +0 -0
  28. {simple_module_background_tasks-0.0.30 → simple_module_background_tasks-0.0.31}/background_tasks/pages/components/RetryConfirmDialog.tsx +0 -0
  29. {simple_module_background_tasks-0.0.30 → simple_module_background_tasks-0.0.31}/background_tasks/pages/constants.ts +0 -0
  30. {simple_module_background_tasks-0.0.30 → simple_module_background_tasks-0.0.31}/background_tasks/pages/retry.ts +0 -0
  31. {simple_module_background_tasks-0.0.30 → simple_module_background_tasks-0.0.31}/background_tasks/py.typed +0 -0
  32. {simple_module_background_tasks-0.0.30 → simple_module_background_tasks-0.0.31}/background_tasks/services.py +0 -0
  33. {simple_module_background_tasks-0.0.30 → simple_module_background_tasks-0.0.31}/background_tasks/settings.py +0 -0
  34. {simple_module_background_tasks-0.0.30 → simple_module_background_tasks-0.0.31}/background_tasks/signals.py +0 -0
  35. {simple_module_background_tasks-0.0.30 → simple_module_background_tasks-0.0.31}/background_tasks/sync_db.py +0 -0
  36. {simple_module_background_tasks-0.0.30 → simple_module_background_tasks-0.0.31}/background_tasks/tasks.py +0 -0
  37. {simple_module_background_tasks-0.0.30 → simple_module_background_tasks-0.0.31}/background_tasks/worker_inspector.py +0 -0
  38. {simple_module_background_tasks-0.0.30 → simple_module_background_tasks-0.0.31}/package.json +0 -0
  39. {simple_module_background_tasks-0.0.30 → simple_module_background_tasks-0.0.31}/tests/conftest.py +0 -0
  40. {simple_module_background_tasks-0.0.30 → simple_module_background_tasks-0.0.31}/tests/test_admin_api.py +0 -0
  41. {simple_module_background_tasks-0.0.30 → simple_module_background_tasks-0.0.31}/tests/test_beat_schedule.py +0 -0
  42. {simple_module_background_tasks-0.0.30 → simple_module_background_tasks-0.0.31}/tests/test_log_context.py +0 -0
  43. {simple_module_background_tasks-0.0.30 → simple_module_background_tasks-0.0.31}/tests/test_signals.py +0 -0
  44. {simple_module_background_tasks-0.0.30 → simple_module_background_tasks-0.0.31}/tests/test_sweep_stuck.py +0 -0
  45. {simple_module_background_tasks-0.0.30 → simple_module_background_tasks-0.0.31}/tests/test_sync_db.py +0 -0
  46. {simple_module_background_tasks-0.0.30 → simple_module_background_tasks-0.0.31}/tests/test_worker_inspector.py +0 -0
  47. {simple_module_background_tasks-0.0.30 → simple_module_background_tasks-0.0.31}/tests/test_workers_endpoints.py +0 -0
  48. {simple_module_background_tasks-0.0.30 → simple_module_background_tasks-0.0.31}/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.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.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.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
- if (created) router.reload({ only: ['executions', 'pagination'] });
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:
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "simple_module_background_tasks"
3
- version = "0.0.30"
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.30",
25
- "simple_module_db==0.0.30",
26
- "simple_module_hosting==0.0.30",
27
- "simple_module_settings==0.0.30",
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