simple-module-background-tasks 0.0.1__py3-none-any.whl
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.
- background_tasks/__init__.py +1 -0
- background_tasks/_signal_support.py +105 -0
- background_tasks/celery_app.py +95 -0
- background_tasks/constants.py +75 -0
- background_tasks/contracts/__init__.py +1 -0
- background_tasks/contracts/events.py +26 -0
- background_tasks/contracts/schemas.py +66 -0
- background_tasks/deps.py +19 -0
- background_tasks/endpoints/__init__.py +0 -0
- background_tasks/endpoints/api_admin.py +62 -0
- background_tasks/endpoints/views.py +71 -0
- background_tasks/locales/en.json +57 -0
- background_tasks/models.py +86 -0
- background_tasks/module.py +121 -0
- background_tasks/package.json +16 -0
- background_tasks/pages/Detail.tsx +180 -0
- background_tasks/pages/Index.tsx +181 -0
- background_tasks/pages/components/ExecutionRow.tsx +79 -0
- background_tasks/pages/components/RetryConfirmDialog.tsx +38 -0
- background_tasks/pages/constants.ts +49 -0
- background_tasks/pages/retry.ts +42 -0
- background_tasks/py.typed +0 -0
- background_tasks/service.py +138 -0
- background_tasks/services.py +25 -0
- background_tasks/settings.py +83 -0
- background_tasks/signals.py +286 -0
- background_tasks/sync_db.py +82 -0
- background_tasks/tasks.py +105 -0
- simple_module_background_tasks-0.0.1.dist-info/METADATA +92 -0
- simple_module_background_tasks-0.0.1.dist-info/RECORD +33 -0
- simple_module_background_tasks-0.0.1.dist-info/WHEEL +4 -0
- simple_module_background_tasks-0.0.1.dist-info/entry_points.txt +2 -0
- simple_module_background_tasks-0.0.1.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
"""BackgroundTasks module definition."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import importlib.resources
|
|
6
|
+
import logging
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import TYPE_CHECKING
|
|
9
|
+
|
|
10
|
+
from fastapi import APIRouter
|
|
11
|
+
from simple_module_core.menu import MenuItem, MenuRegistry, MenuSection
|
|
12
|
+
from simple_module_core.module import ModuleBase, ModuleMeta
|
|
13
|
+
from simple_module_core.permissions import PermissionRegistry
|
|
14
|
+
|
|
15
|
+
from background_tasks.constants import (
|
|
16
|
+
API_PREFIX,
|
|
17
|
+
MENU_ICON,
|
|
18
|
+
MENU_LABEL,
|
|
19
|
+
MENU_ORDER,
|
|
20
|
+
MODULE_DISPLAY_NAME,
|
|
21
|
+
MODULE_NAME,
|
|
22
|
+
PERM_GROUP,
|
|
23
|
+
PERM_MANAGE,
|
|
24
|
+
PERM_VIEW,
|
|
25
|
+
VIEW_PREFIX,
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
if TYPE_CHECKING:
|
|
29
|
+
from fastapi import FastAPI
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
logger = logging.getLogger(__name__)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class BackgroundTasksModule(ModuleBase):
|
|
36
|
+
"""Celery + Redis task queue with an admin UI for retrying failed/stuck tasks."""
|
|
37
|
+
|
|
38
|
+
meta = ModuleMeta(
|
|
39
|
+
name=MODULE_DISPLAY_NAME,
|
|
40
|
+
route_prefix=API_PREFIX,
|
|
41
|
+
view_prefix=VIEW_PREFIX,
|
|
42
|
+
depends_on=["Users"],
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
def register_settings(self, app: FastAPI) -> None:
|
|
46
|
+
import importlib
|
|
47
|
+
|
|
48
|
+
from background_tasks.services import BackgroundTasksServices
|
|
49
|
+
from background_tasks.settings import BackgroundTasksSettings
|
|
50
|
+
|
|
51
|
+
# SM009 is AST-based: a static `from settings.registration import ...`
|
|
52
|
+
# from a module helper is fine (plugin→plugin), but we resolve via
|
|
53
|
+
# importlib here to match the convention used framework-side and to
|
|
54
|
+
# keep the dependency direction one-way explicit.
|
|
55
|
+
register_module_settings = importlib.import_module(
|
|
56
|
+
"settings.registration"
|
|
57
|
+
).register_module_settings
|
|
58
|
+
|
|
59
|
+
register_module_settings(
|
|
60
|
+
app,
|
|
61
|
+
"background_tasks",
|
|
62
|
+
BackgroundTasksSettings,
|
|
63
|
+
lambda s: BackgroundTasksServices(settings=s),
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
def register_permissions(self, registry: PermissionRegistry) -> None:
|
|
67
|
+
registry.add_group(PERM_GROUP, [PERM_VIEW, PERM_MANAGE])
|
|
68
|
+
|
|
69
|
+
def register_menu_items(self, registry: MenuRegistry) -> None:
|
|
70
|
+
registry.add(
|
|
71
|
+
MenuItem(
|
|
72
|
+
label=MENU_LABEL,
|
|
73
|
+
url=VIEW_PREFIX,
|
|
74
|
+
icon=MENU_ICON,
|
|
75
|
+
order=MENU_ORDER,
|
|
76
|
+
section=MenuSection.SIDEBAR,
|
|
77
|
+
roles=["admin"],
|
|
78
|
+
)
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
def register_routes(self, api_router: APIRouter, view_router: APIRouter) -> None:
|
|
82
|
+
from background_tasks.endpoints.api_admin import router as api_admin
|
|
83
|
+
from background_tasks.endpoints.views import router as views
|
|
84
|
+
|
|
85
|
+
api_router.include_router(api_admin)
|
|
86
|
+
view_router.include_router(views)
|
|
87
|
+
|
|
88
|
+
def locale_dirs(self) -> dict[str, Path]:
|
|
89
|
+
base = Path(str(importlib.resources.files(__package__) / "locales"))
|
|
90
|
+
return {MODULE_NAME: base}
|
|
91
|
+
|
|
92
|
+
async def on_startup(self, app: FastAPI) -> None:
|
|
93
|
+
"""Build the Celery app, install signal handlers, hand-off to worker."""
|
|
94
|
+
import asyncio
|
|
95
|
+
|
|
96
|
+
from background_tasks.celery_app import build_celery
|
|
97
|
+
from background_tasks.signals import bind_event_bus
|
|
98
|
+
|
|
99
|
+
services = app.state.background_tasks
|
|
100
|
+
# build_celery imports `signals` for side effects and runs
|
|
101
|
+
# `autodiscover_tasks` across every installed module.
|
|
102
|
+
services.celery = build_celery(services.settings)
|
|
103
|
+
# Let signal handlers hop onto the API loop to publish events.
|
|
104
|
+
# In a standalone worker process this bind never runs, so signals
|
|
105
|
+
# stay a silent no-op — that's the documented cross-process limit.
|
|
106
|
+
bind_event_bus(app.state.sm.event_bus, asyncio.get_running_loop())
|
|
107
|
+
logger.info(
|
|
108
|
+
"BackgroundTasks: Celery app ready (broker=%s, queue=%s)",
|
|
109
|
+
services.settings.broker_url,
|
|
110
|
+
services.settings.task_default_queue,
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
async def on_shutdown(self, app: FastAPI) -> None:
|
|
114
|
+
from background_tasks.signals import unbind_event_bus
|
|
115
|
+
from background_tasks.sync_db import dispose_sync_engine
|
|
116
|
+
|
|
117
|
+
services = app.state.background_tasks
|
|
118
|
+
if services.celery is not None:
|
|
119
|
+
services.celery.close()
|
|
120
|
+
unbind_event_bus()
|
|
121
|
+
dispose_sync_engine()
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@simple-module-py/background-tasks",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"private": true,
|
|
5
|
+
"description": "Frontend assets for the BackgroundTasks module",
|
|
6
|
+
"peerDependencies": {
|
|
7
|
+
"react": "^19.0.0",
|
|
8
|
+
"react-dom": "^19.0.0",
|
|
9
|
+
"@inertiajs/react": "^2.0.0",
|
|
10
|
+
"@simple-module-py/ui": "*"
|
|
11
|
+
},
|
|
12
|
+
"devDependencies": {
|
|
13
|
+
"@simple-module-py/tsconfig": "*"
|
|
14
|
+
},
|
|
15
|
+
"dependencies": {}
|
|
16
|
+
}
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
import { Link, router, usePage } from '@inertiajs/react';
|
|
2
|
+
import { PageShell } from '@simple-module-py/ui/components/PageShell';
|
|
3
|
+
import { Badge } from '@simple-module-py/ui/components/ui/badge';
|
|
4
|
+
import { Button } from '@simple-module-py/ui/components/ui/button';
|
|
5
|
+
import { Card } from '@simple-module-py/ui/components/ui/card';
|
|
6
|
+
import { usePermissions } from '@simple-module-py/ui/hooks/use-permissions';
|
|
7
|
+
import { AuthenticatedLayout } from '@simple-module-py/ui/layouts/AuthenticatedLayout';
|
|
8
|
+
import { ArrowLeft, RefreshCcw } from 'lucide-react';
|
|
9
|
+
import type { ReactNode } from 'react';
|
|
10
|
+
import { RetryConfirmDialog } from './components/RetryConfirmDialog';
|
|
11
|
+
import { RETRYABLE_STATUSES, STATUS_BADGE_VARIANT, type TaskStatus, VIEW_BASE } from './constants';
|
|
12
|
+
import { retryExecution } from './retry';
|
|
13
|
+
|
|
14
|
+
interface Execution {
|
|
15
|
+
id: string;
|
|
16
|
+
celery_task_id: string | null;
|
|
17
|
+
task_name: string;
|
|
18
|
+
status: TaskStatus;
|
|
19
|
+
queue: string;
|
|
20
|
+
args: unknown[];
|
|
21
|
+
kwargs: Record<string, unknown>;
|
|
22
|
+
result: Record<string, unknown> | null;
|
|
23
|
+
traceback: string | null;
|
|
24
|
+
exception_type: string | null;
|
|
25
|
+
worker: string | null;
|
|
26
|
+
retries: number;
|
|
27
|
+
retried_from_id: string | null;
|
|
28
|
+
queued_at: string | null;
|
|
29
|
+
started_at: string | null;
|
|
30
|
+
finished_at: string | null;
|
|
31
|
+
heartbeat_at: string | null;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function statusLabel(status: TaskStatus): string {
|
|
35
|
+
return status[0].toUpperCase() + status.slice(1);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function pretty(value: unknown): string {
|
|
39
|
+
if (value === null || value === undefined) return '—';
|
|
40
|
+
try {
|
|
41
|
+
return JSON.stringify(value, null, 2);
|
|
42
|
+
} catch {
|
|
43
|
+
return String(value);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function formatTs(ts: string | null): string {
|
|
48
|
+
return ts ? new Date(ts).toLocaleString() : '—';
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function JsonCard({ title, children }: { title: string; children: ReactNode }) {
|
|
52
|
+
return (
|
|
53
|
+
<Card className="p-4">
|
|
54
|
+
<h3 className="font-semibold mb-2">{title}</h3>
|
|
55
|
+
{children}
|
|
56
|
+
</Card>
|
|
57
|
+
);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function JsonBlock({ value }: { value: unknown }) {
|
|
61
|
+
return (
|
|
62
|
+
<pre className="text-xs bg-muted rounded p-3 overflow-x-auto whitespace-pre-wrap">
|
|
63
|
+
{pretty(value)}
|
|
64
|
+
</pre>
|
|
65
|
+
);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function Row({ label, value, mono }: { label: string; value: string; mono?: boolean }) {
|
|
69
|
+
return (
|
|
70
|
+
<div className="flex justify-between gap-3">
|
|
71
|
+
<dt className="text-muted-foreground">{label}</dt>
|
|
72
|
+
<dd className={mono ? 'font-mono text-xs break-all text-right' : 'text-right'}>{value}</dd>
|
|
73
|
+
</div>
|
|
74
|
+
);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function Detail() {
|
|
78
|
+
const { execution } = usePage<{ props: { execution: Execution } }>().props as unknown as {
|
|
79
|
+
execution: Execution;
|
|
80
|
+
};
|
|
81
|
+
const { can } = usePermissions();
|
|
82
|
+
const retryable = RETRYABLE_STATUSES.has(execution.status) && can('background_tasks.manage');
|
|
83
|
+
|
|
84
|
+
async function handleRetry() {
|
|
85
|
+
const created = await retryExecution(execution);
|
|
86
|
+
if (created) router.visit(`${VIEW_BASE}/${created.id}`);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
return (
|
|
90
|
+
<PageShell
|
|
91
|
+
title={execution.task_name}
|
|
92
|
+
description={`Task execution ${execution.id}`}
|
|
93
|
+
actions={
|
|
94
|
+
<div className="flex items-center gap-2">
|
|
95
|
+
<Button variant="outline" size="sm" asChild>
|
|
96
|
+
<Link href={VIEW_BASE}>
|
|
97
|
+
<ArrowLeft />
|
|
98
|
+
Back to tasks
|
|
99
|
+
</Link>
|
|
100
|
+
</Button>
|
|
101
|
+
{retryable && (
|
|
102
|
+
<RetryConfirmDialog
|
|
103
|
+
trigger={
|
|
104
|
+
<Button size="sm">
|
|
105
|
+
<RefreshCcw />
|
|
106
|
+
Retry task
|
|
107
|
+
</Button>
|
|
108
|
+
}
|
|
109
|
+
onConfirm={handleRetry}
|
|
110
|
+
/>
|
|
111
|
+
)}
|
|
112
|
+
</div>
|
|
113
|
+
}
|
|
114
|
+
>
|
|
115
|
+
<div className="grid grid-cols-1 lg:grid-cols-3 gap-4">
|
|
116
|
+
<Card className="p-4 lg:col-span-1">
|
|
117
|
+
<h3 className="font-semibold mb-3">Details</h3>
|
|
118
|
+
<dl className="text-sm space-y-2">
|
|
119
|
+
<div className="flex justify-between gap-3">
|
|
120
|
+
<dt className="text-muted-foreground">Status</dt>
|
|
121
|
+
<dd>
|
|
122
|
+
<Badge variant={STATUS_BADGE_VARIANT[execution.status]}>
|
|
123
|
+
{statusLabel(execution.status)}
|
|
124
|
+
</Badge>
|
|
125
|
+
</dd>
|
|
126
|
+
</div>
|
|
127
|
+
<Row label="Queue" value={execution.queue} />
|
|
128
|
+
<Row label="Retries" value={String(execution.retries)} />
|
|
129
|
+
<Row label="Worker" value={execution.worker || '—'} />
|
|
130
|
+
<Row label="Celery id" value={execution.celery_task_id || '—'} mono />
|
|
131
|
+
<Row label="Queued at" value={formatTs(execution.queued_at)} />
|
|
132
|
+
<Row label="Started at" value={formatTs(execution.started_at)} />
|
|
133
|
+
<Row label="Finished at" value={formatTs(execution.finished_at)} />
|
|
134
|
+
<Row label="Heartbeat" value={formatTs(execution.heartbeat_at)} />
|
|
135
|
+
<Row label="Exception" value={execution.exception_type || '—'} />
|
|
136
|
+
{execution.retried_from_id && (
|
|
137
|
+
<div className="flex justify-between gap-3">
|
|
138
|
+
<dt className="text-muted-foreground">Retried from</dt>
|
|
139
|
+
<dd>
|
|
140
|
+
<Link
|
|
141
|
+
href={`${VIEW_BASE}/${execution.retried_from_id}`}
|
|
142
|
+
className="hover:underline"
|
|
143
|
+
>
|
|
144
|
+
{execution.retried_from_id.slice(0, 8)}…
|
|
145
|
+
</Link>
|
|
146
|
+
</dd>
|
|
147
|
+
</div>
|
|
148
|
+
)}
|
|
149
|
+
</dl>
|
|
150
|
+
</Card>
|
|
151
|
+
|
|
152
|
+
<div className="lg:col-span-2 flex flex-col gap-4">
|
|
153
|
+
<JsonCard title="Arguments">
|
|
154
|
+
<JsonBlock value={execution.args} />
|
|
155
|
+
</JsonCard>
|
|
156
|
+
<JsonCard title="Keyword arguments">
|
|
157
|
+
<JsonBlock value={execution.kwargs} />
|
|
158
|
+
</JsonCard>
|
|
159
|
+
{execution.result !== null && (
|
|
160
|
+
<JsonCard title="Result">
|
|
161
|
+
<JsonBlock value={execution.result} />
|
|
162
|
+
</JsonCard>
|
|
163
|
+
)}
|
|
164
|
+
<JsonCard title="Traceback">
|
|
165
|
+
{execution.traceback ? (
|
|
166
|
+
<pre className="text-xs bg-muted rounded p-3 overflow-x-auto whitespace-pre-wrap">
|
|
167
|
+
{execution.traceback}
|
|
168
|
+
</pre>
|
|
169
|
+
) : (
|
|
170
|
+
<p className="text-sm text-muted-foreground">No traceback recorded.</p>
|
|
171
|
+
)}
|
|
172
|
+
</JsonCard>
|
|
173
|
+
</div>
|
|
174
|
+
</div>
|
|
175
|
+
</PageShell>
|
|
176
|
+
);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
Detail.layout = (page: React.ReactNode) => <AuthenticatedLayout>{page}</AuthenticatedLayout>;
|
|
180
|
+
export default Detail;
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
import { router, usePage } from '@inertiajs/react';
|
|
2
|
+
import { PageShell } from '@simple-module-py/ui/components/PageShell';
|
|
3
|
+
import { Button } from '@simple-module-py/ui/components/ui/button';
|
|
4
|
+
import { Card } from '@simple-module-py/ui/components/ui/card';
|
|
5
|
+
import { Input } from '@simple-module-py/ui/components/ui/input';
|
|
6
|
+
import {
|
|
7
|
+
Select,
|
|
8
|
+
SelectContent,
|
|
9
|
+
SelectItem,
|
|
10
|
+
SelectTrigger,
|
|
11
|
+
SelectValue,
|
|
12
|
+
} from '@simple-module-py/ui/components/ui/select';
|
|
13
|
+
import {
|
|
14
|
+
Table,
|
|
15
|
+
TableBody,
|
|
16
|
+
TableCell,
|
|
17
|
+
TableHead,
|
|
18
|
+
TableHeader,
|
|
19
|
+
TableRow,
|
|
20
|
+
} from '@simple-module-py/ui/components/ui/table';
|
|
21
|
+
import { usePermissions } from '@simple-module-py/ui/hooks/use-permissions';
|
|
22
|
+
import { AuthenticatedLayout } from '@simple-module-py/ui/layouts/AuthenticatedLayout';
|
|
23
|
+
import { Activity, Search } from 'lucide-react';
|
|
24
|
+
import { useEffect, useState } from 'react';
|
|
25
|
+
import { ExecutionRow, statusLabel } from './components/ExecutionRow';
|
|
26
|
+
import { STATUS_ORDER, VIEW_BASE } from './constants';
|
|
27
|
+
import { type Execution, retryExecution } from './retry';
|
|
28
|
+
|
|
29
|
+
interface Pagination {
|
|
30
|
+
page: number;
|
|
31
|
+
per_page: number;
|
|
32
|
+
total: number;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
interface Props {
|
|
36
|
+
executions: Execution[];
|
|
37
|
+
pagination: Pagination;
|
|
38
|
+
filters: { status: string; task_name: string };
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const STATUS_ALL = '__all__';
|
|
42
|
+
|
|
43
|
+
function pushFilters(filters: { status: string; task_name: string }, page: number): void {
|
|
44
|
+
const params: Record<string, string> = {};
|
|
45
|
+
if (filters.task_name) params.q = filters.task_name;
|
|
46
|
+
if (filters.status && filters.status !== STATUS_ALL) params.status = filters.status;
|
|
47
|
+
if (page > 1) params.page = String(page);
|
|
48
|
+
router.get(VIEW_BASE, params, { preserveState: true, preserveScroll: true });
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function Index() {
|
|
52
|
+
const {
|
|
53
|
+
executions,
|
|
54
|
+
pagination,
|
|
55
|
+
filters: initialFilters,
|
|
56
|
+
} = usePage<{ props: Props }>().props as unknown as Props;
|
|
57
|
+
|
|
58
|
+
const { can } = usePermissions();
|
|
59
|
+
const canRetry = can('background_tasks.manage');
|
|
60
|
+
|
|
61
|
+
const [search, setSearch] = useState(initialFilters.task_name ?? '');
|
|
62
|
+
const totalPages = Math.ceil(pagination.total / pagination.per_page);
|
|
63
|
+
const statusValue = initialFilters.status || STATUS_ALL;
|
|
64
|
+
|
|
65
|
+
// Debounce search: any change from the server-provided value kicks off a
|
|
66
|
+
// page-1 navigation 300ms after the user stops typing.
|
|
67
|
+
useEffect(() => {
|
|
68
|
+
if (search === (initialFilters.task_name ?? '')) return;
|
|
69
|
+
const timeout = setTimeout(
|
|
70
|
+
() => pushFilters({ status: statusValue, task_name: search }, 1),
|
|
71
|
+
300,
|
|
72
|
+
);
|
|
73
|
+
return () => clearTimeout(timeout);
|
|
74
|
+
}, [search, initialFilters.task_name, statusValue]);
|
|
75
|
+
|
|
76
|
+
async function handleRetry(execution: Execution) {
|
|
77
|
+
const created = await retryExecution(execution);
|
|
78
|
+
if (created) router.reload({ only: ['executions', 'pagination'] });
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
return (
|
|
82
|
+
<PageShell
|
|
83
|
+
title="Background Tasks"
|
|
84
|
+
description="Monitor task executions and retry failed or stuck jobs."
|
|
85
|
+
>
|
|
86
|
+
<div className="mb-4 flex flex-col sm:flex-row items-stretch sm:items-center justify-between gap-3">
|
|
87
|
+
<div className="relative flex-1 max-w-sm">
|
|
88
|
+
<Search className="absolute left-3 top-1/2 -translate-y-1/2 size-4 text-muted-foreground" />
|
|
89
|
+
<Input
|
|
90
|
+
placeholder="Search by task name…"
|
|
91
|
+
value={search}
|
|
92
|
+
onChange={(e) => setSearch(e.target.value)}
|
|
93
|
+
className="pl-9"
|
|
94
|
+
/>
|
|
95
|
+
</div>
|
|
96
|
+
<Select
|
|
97
|
+
value={statusValue}
|
|
98
|
+
onValueChange={(v) => pushFilters({ status: v, task_name: search }, 1)}
|
|
99
|
+
>
|
|
100
|
+
<SelectTrigger className="w-full sm:w-48">
|
|
101
|
+
<SelectValue placeholder="Status" />
|
|
102
|
+
</SelectTrigger>
|
|
103
|
+
<SelectContent>
|
|
104
|
+
<SelectItem value={STATUS_ALL}>All statuses</SelectItem>
|
|
105
|
+
{STATUS_ORDER.map((s) => (
|
|
106
|
+
<SelectItem key={s} value={s}>
|
|
107
|
+
{statusLabel(s)}
|
|
108
|
+
</SelectItem>
|
|
109
|
+
))}
|
|
110
|
+
</SelectContent>
|
|
111
|
+
</Select>
|
|
112
|
+
</div>
|
|
113
|
+
|
|
114
|
+
<Card>
|
|
115
|
+
<Table>
|
|
116
|
+
<TableHeader>
|
|
117
|
+
<TableRow>
|
|
118
|
+
<TableHead>Task</TableHead>
|
|
119
|
+
<TableHead>Status</TableHead>
|
|
120
|
+
<TableHead className="hidden md:table-cell">Queue</TableHead>
|
|
121
|
+
<TableHead className="hidden lg:table-cell">Queued</TableHead>
|
|
122
|
+
<TableHead className="hidden sm:table-cell">Duration</TableHead>
|
|
123
|
+
<TableHead className="hidden xl:table-cell">Worker</TableHead>
|
|
124
|
+
<TableHead className="text-right">Actions</TableHead>
|
|
125
|
+
</TableRow>
|
|
126
|
+
</TableHeader>
|
|
127
|
+
<TableBody>
|
|
128
|
+
{executions.map((e) => (
|
|
129
|
+
<ExecutionRow key={e.id} execution={e} canRetry={canRetry} onRetry={handleRetry} />
|
|
130
|
+
))}
|
|
131
|
+
{executions.length === 0 && (
|
|
132
|
+
<TableRow>
|
|
133
|
+
<TableCell colSpan={7} className="h-32 text-center">
|
|
134
|
+
<div className="flex flex-col items-center gap-2 text-muted-foreground">
|
|
135
|
+
<Activity className="size-8" />
|
|
136
|
+
<p>
|
|
137
|
+
{search
|
|
138
|
+
? `No tasks match "${search}"`
|
|
139
|
+
: 'No task executions yet. Tasks appear here as soon as modules enqueue work.'}
|
|
140
|
+
</p>
|
|
141
|
+
</div>
|
|
142
|
+
</TableCell>
|
|
143
|
+
</TableRow>
|
|
144
|
+
)}
|
|
145
|
+
</TableBody>
|
|
146
|
+
</Table>
|
|
147
|
+
</Card>
|
|
148
|
+
|
|
149
|
+
{totalPages > 1 && (
|
|
150
|
+
<div className="mt-4 flex items-center justify-center gap-2">
|
|
151
|
+
<Button
|
|
152
|
+
variant="outline"
|
|
153
|
+
size="sm"
|
|
154
|
+
disabled={pagination.page <= 1}
|
|
155
|
+
onClick={() =>
|
|
156
|
+
pushFilters({ status: statusValue, task_name: search }, pagination.page - 1)
|
|
157
|
+
}
|
|
158
|
+
>
|
|
159
|
+
Previous
|
|
160
|
+
</Button>
|
|
161
|
+
<span className="text-sm text-muted-foreground">
|
|
162
|
+
Page {pagination.page} of {totalPages}
|
|
163
|
+
</span>
|
|
164
|
+
<Button
|
|
165
|
+
variant="outline"
|
|
166
|
+
size="sm"
|
|
167
|
+
disabled={pagination.page >= totalPages}
|
|
168
|
+
onClick={() =>
|
|
169
|
+
pushFilters({ status: statusValue, task_name: search }, pagination.page + 1)
|
|
170
|
+
}
|
|
171
|
+
>
|
|
172
|
+
Next
|
|
173
|
+
</Button>
|
|
174
|
+
</div>
|
|
175
|
+
)}
|
|
176
|
+
</PageShell>
|
|
177
|
+
);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
Index.layout = (page: React.ReactNode) => <AuthenticatedLayout>{page}</AuthenticatedLayout>;
|
|
181
|
+
export default Index;
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { Link } from '@inertiajs/react';
|
|
2
|
+
import { Badge } from '@simple-module-py/ui/components/ui/badge';
|
|
3
|
+
import { Button } from '@simple-module-py/ui/components/ui/button';
|
|
4
|
+
import { TableCell, TableRow } from '@simple-module-py/ui/components/ui/table';
|
|
5
|
+
import { RefreshCcw } from 'lucide-react';
|
|
6
|
+
import { RETRYABLE_STATUSES, STATUS_BADGE_VARIANT, VIEW_BASE } from '../constants';
|
|
7
|
+
import type { Execution } from '../retry';
|
|
8
|
+
import { RetryConfirmDialog } from './RetryConfirmDialog';
|
|
9
|
+
|
|
10
|
+
function statusLabel(status: string): string {
|
|
11
|
+
return status[0].toUpperCase() + status.slice(1);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function formatDuration(started: string | null, finished: string | null): string {
|
|
15
|
+
if (!started) return '—';
|
|
16
|
+
const end = finished ? new Date(finished) : new Date();
|
|
17
|
+
const ms = end.getTime() - new Date(started).getTime();
|
|
18
|
+
if (ms < 0) return '—';
|
|
19
|
+
if (ms < 1000) return `${ms}ms`;
|
|
20
|
+
if (ms < 60_000) return `${(ms / 1000).toFixed(1)}s`;
|
|
21
|
+
return `${Math.floor(ms / 60_000)}m ${Math.floor((ms % 60_000) / 1000)}s`;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
interface Props {
|
|
25
|
+
execution: Execution;
|
|
26
|
+
canRetry: boolean;
|
|
27
|
+
onRetry: (execution: Execution) => void;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function ExecutionRow({ execution, canRetry, onRetry }: Props) {
|
|
31
|
+
const retryable = RETRYABLE_STATUSES.has(execution.status) && canRetry;
|
|
32
|
+
return (
|
|
33
|
+
<TableRow>
|
|
34
|
+
<TableCell>
|
|
35
|
+
<div className="flex flex-col">
|
|
36
|
+
<Link href={`${VIEW_BASE}/${execution.id}`} className="font-medium hover:underline">
|
|
37
|
+
{execution.task_name}
|
|
38
|
+
</Link>
|
|
39
|
+
{execution.exception_type && (
|
|
40
|
+
<span className="text-xs text-destructive">{execution.exception_type}</span>
|
|
41
|
+
)}
|
|
42
|
+
</div>
|
|
43
|
+
</TableCell>
|
|
44
|
+
<TableCell>
|
|
45
|
+
<Badge variant={STATUS_BADGE_VARIANT[execution.status]}>
|
|
46
|
+
{statusLabel(execution.status)}
|
|
47
|
+
</Badge>
|
|
48
|
+
</TableCell>
|
|
49
|
+
<TableCell className="hidden md:table-cell text-sm text-muted-foreground">
|
|
50
|
+
{execution.queue}
|
|
51
|
+
</TableCell>
|
|
52
|
+
<TableCell className="hidden lg:table-cell text-sm text-muted-foreground">
|
|
53
|
+
{execution.queued_at ? new Date(execution.queued_at).toLocaleString() : '—'}
|
|
54
|
+
</TableCell>
|
|
55
|
+
<TableCell className="hidden sm:table-cell text-sm tabular-nums">
|
|
56
|
+
{formatDuration(execution.started_at, execution.finished_at)}
|
|
57
|
+
</TableCell>
|
|
58
|
+
<TableCell className="hidden xl:table-cell text-sm text-muted-foreground">
|
|
59
|
+
{execution.worker || '—'}
|
|
60
|
+
</TableCell>
|
|
61
|
+
<TableCell className="text-right">
|
|
62
|
+
{retryable ? (
|
|
63
|
+
<RetryConfirmDialog
|
|
64
|
+
trigger={
|
|
65
|
+
<Button variant="ghost" size="icon-sm">
|
|
66
|
+
<RefreshCcw />
|
|
67
|
+
</Button>
|
|
68
|
+
}
|
|
69
|
+
onConfirm={() => onRetry(execution)}
|
|
70
|
+
/>
|
|
71
|
+
) : (
|
|
72
|
+
<span className="text-sm text-muted-foreground">—</span>
|
|
73
|
+
)}
|
|
74
|
+
</TableCell>
|
|
75
|
+
</TableRow>
|
|
76
|
+
);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export { statusLabel };
|
|
@@ -0,0 +1,38 @@
|
|
|
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
|
+
|
|
14
|
+
interface Props {
|
|
15
|
+
trigger: ReactNode;
|
|
16
|
+
onConfirm: () => void;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function RetryConfirmDialog({ trigger, onConfirm }: Props) {
|
|
20
|
+
return (
|
|
21
|
+
<AlertDialog>
|
|
22
|
+
<AlertDialogTrigger asChild>{trigger}</AlertDialogTrigger>
|
|
23
|
+
<AlertDialogContent>
|
|
24
|
+
<AlertDialogHeader>
|
|
25
|
+
<AlertDialogTitle>Retry this task?</AlertDialogTitle>
|
|
26
|
+
<AlertDialogDescription>
|
|
27
|
+
A new task execution will be enqueued with the same arguments. The original row is kept
|
|
28
|
+
for history.
|
|
29
|
+
</AlertDialogDescription>
|
|
30
|
+
</AlertDialogHeader>
|
|
31
|
+
<AlertDialogFooter>
|
|
32
|
+
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
|
33
|
+
<AlertDialogAction onClick={onConfirm}>Retry task</AlertDialogAction>
|
|
34
|
+
</AlertDialogFooter>
|
|
35
|
+
</AlertDialogContent>
|
|
36
|
+
</AlertDialog>
|
|
37
|
+
);
|
|
38
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
// Frontend mirror of background_tasks/constants.py.
|
|
2
|
+
// Kept small and hand-maintained — the backend file is the source of truth.
|
|
3
|
+
|
|
4
|
+
export const API_BASE = '/api/background_tasks/admin';
|
|
5
|
+
export const VIEW_BASE = '/admin/background-tasks';
|
|
6
|
+
|
|
7
|
+
export const TASK_STATUS = {
|
|
8
|
+
PENDING: 'pending',
|
|
9
|
+
RUNNING: 'running',
|
|
10
|
+
SUCCESS: 'success',
|
|
11
|
+
FAILED: 'failed',
|
|
12
|
+
STUCK: 'stuck',
|
|
13
|
+
REVOKED: 'revoked',
|
|
14
|
+
RETRYING: 'retrying',
|
|
15
|
+
} as const;
|
|
16
|
+
|
|
17
|
+
export type TaskStatus = (typeof TASK_STATUS)[keyof typeof TASK_STATUS];
|
|
18
|
+
|
|
19
|
+
export const RETRYABLE_STATUSES: ReadonlySet<TaskStatus> = new Set([
|
|
20
|
+
TASK_STATUS.FAILED,
|
|
21
|
+
TASK_STATUS.STUCK,
|
|
22
|
+
]);
|
|
23
|
+
|
|
24
|
+
export const TERMINAL_STATUSES: ReadonlySet<TaskStatus> = new Set([
|
|
25
|
+
TASK_STATUS.SUCCESS,
|
|
26
|
+
TASK_STATUS.FAILED,
|
|
27
|
+
TASK_STATUS.STUCK,
|
|
28
|
+
TASK_STATUS.REVOKED,
|
|
29
|
+
]);
|
|
30
|
+
|
|
31
|
+
export const STATUS_BADGE_VARIANT: Record<TaskStatus, 'secondary' | 'destructive' | 'outline'> = {
|
|
32
|
+
[TASK_STATUS.PENDING]: 'outline',
|
|
33
|
+
[TASK_STATUS.RUNNING]: 'secondary',
|
|
34
|
+
[TASK_STATUS.SUCCESS]: 'secondary',
|
|
35
|
+
[TASK_STATUS.FAILED]: 'destructive',
|
|
36
|
+
[TASK_STATUS.STUCK]: 'destructive',
|
|
37
|
+
[TASK_STATUS.REVOKED]: 'outline',
|
|
38
|
+
[TASK_STATUS.RETRYING]: 'outline',
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
export const STATUS_ORDER: TaskStatus[] = [
|
|
42
|
+
TASK_STATUS.PENDING,
|
|
43
|
+
TASK_STATUS.RUNNING,
|
|
44
|
+
TASK_STATUS.RETRYING,
|
|
45
|
+
TASK_STATUS.SUCCESS,
|
|
46
|
+
TASK_STATUS.FAILED,
|
|
47
|
+
TASK_STATUS.STUCK,
|
|
48
|
+
TASK_STATUS.REVOKED,
|
|
49
|
+
];
|