rl-core-front 0.7.0 → 0.8.0
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.
- package/README.md +20 -0
- package/package.json +1 -1
- package/src/_services/api/schema.d.ts +381 -2
- package/src/components/app-shell.tsx +189 -54
- package/src/components/ui/dialog.tsx +17 -6
- package/src/components/ui/index.ts +1 -0
- package/src/components/ui/job-progress.tsx +100 -0
- package/src/features/queues/hooks/use-queue-jobs.ts +103 -0
- package/src/features/queues/queues-screen.tsx +264 -0
- package/src/features/queues/services/queues.service.ts +57 -0
- package/src/hooks/use-job-progress.ts +192 -0
- package/src/i18n/messages/en.ts +26 -0
- package/src/i18n/messages/pt.ts +26 -0
- package/src/index.ts +20 -0
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import type {
|
|
4
|
+
ColumnDef,
|
|
5
|
+
OnChangeFn,
|
|
6
|
+
PaginationState,
|
|
7
|
+
} from "@tanstack/react-table";
|
|
8
|
+
import { RotateCcw, Trash2 } from "lucide-react";
|
|
9
|
+
import type { JSX } from "react";
|
|
10
|
+
import { useMemo } from "react";
|
|
11
|
+
|
|
12
|
+
import {
|
|
13
|
+
Badge,
|
|
14
|
+
Button,
|
|
15
|
+
Card,
|
|
16
|
+
DataTable,
|
|
17
|
+
DataTableFeatures,
|
|
18
|
+
RowActions,
|
|
19
|
+
SegmentedControl,
|
|
20
|
+
SegmentedOption,
|
|
21
|
+
} from "#core/components/ui";
|
|
22
|
+
import { useAuth, useI18n } from "#core/contexts";
|
|
23
|
+
import { useQueueJobs } from "#core/features/queues/hooks/use-queue-jobs";
|
|
24
|
+
import {
|
|
25
|
+
QueueJob,
|
|
26
|
+
QueueJobState,
|
|
27
|
+
} from "#core/features/queues/services/queues.service";
|
|
28
|
+
import { useConfirm } from "#core/hooks/use-confirm";
|
|
29
|
+
|
|
30
|
+
/** Rótulo de cada estado — a mesma chave serve à barra e aos cartões. */
|
|
31
|
+
const STATE_LABEL: Record<QueueJobState, string> = {
|
|
32
|
+
[QueueJobState.WAITING]: "queues.stateWaiting",
|
|
33
|
+
[QueueJobState.ACTIVE]: "queues.stateActive",
|
|
34
|
+
[QueueJobState.COMPLETED]: "queues.stateCompleted",
|
|
35
|
+
[QueueJobState.FAILED]: "queues.stateFailed",
|
|
36
|
+
[QueueJobState.DELAYED]: "queues.stateDelayed",
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
const STATE_VARIANT: Record<
|
|
40
|
+
QueueJobState,
|
|
41
|
+
"secondary" | "warning" | "success" | "destructive"
|
|
42
|
+
> = {
|
|
43
|
+
[QueueJobState.WAITING]: "secondary",
|
|
44
|
+
[QueueJobState.ACTIVE]: "warning",
|
|
45
|
+
[QueueJobState.COMPLETED]: "success",
|
|
46
|
+
[QueueJobState.FAILED]: "destructive",
|
|
47
|
+
[QueueJobState.DELAYED]: "secondary",
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Fila de processamento — o que está rodando, o que falhou e o que fazer com
|
|
52
|
+
* isso.
|
|
53
|
+
*
|
|
54
|
+
* Substitui o painel do bull-board: aqui a tela mora no mesmo shell, com a
|
|
55
|
+
* mesma permissão e a mesma tradução do resto do sistema, em vez de um segundo
|
|
56
|
+
* frontend embutido no backend com autenticação própria.
|
|
57
|
+
*/
|
|
58
|
+
export function QueuesScreen(): JSX.Element {
|
|
59
|
+
const { t } = useI18n();
|
|
60
|
+
const { hasPermission } = useAuth();
|
|
61
|
+
const { confirm, confirmDialog } = useConfirm();
|
|
62
|
+
const queue = useQueueJobs();
|
|
63
|
+
|
|
64
|
+
const canManage = hasPermission("queues:manage:any");
|
|
65
|
+
|
|
66
|
+
const stateOptions = useMemo<SegmentedOption[]>(
|
|
67
|
+
() =>
|
|
68
|
+
Object.values(QueueJobState).map((value) => ({
|
|
69
|
+
value,
|
|
70
|
+
label: t(STATE_LABEL[value]),
|
|
71
|
+
})),
|
|
72
|
+
[t],
|
|
73
|
+
);
|
|
74
|
+
|
|
75
|
+
/** Remover é sem volta: some da fila e não há de onde reprocessar depois. */
|
|
76
|
+
const removeJob = async (id: string): Promise<void> => {
|
|
77
|
+
const confirmed = await confirm({
|
|
78
|
+
title: t("queues.removeTitle"),
|
|
79
|
+
description: t("queues.removeDescription"),
|
|
80
|
+
destructive: true,
|
|
81
|
+
});
|
|
82
|
+
if (confirmed) {
|
|
83
|
+
await queue.remove(id);
|
|
84
|
+
}
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
const columns = useMemo<ColumnDef<DataTableFeatures, QueueJob, unknown>[]>(
|
|
88
|
+
() => [
|
|
89
|
+
{
|
|
90
|
+
accessorKey: "name",
|
|
91
|
+
header: t("queues.job"),
|
|
92
|
+
meta: { label: t("queues.job") },
|
|
93
|
+
enableSorting: false,
|
|
94
|
+
cell: ({ row }) => (
|
|
95
|
+
<div className="flex flex-col">
|
|
96
|
+
<span className="font-medium">{row.original.name}</span>
|
|
97
|
+
<span className="font-mono text-xs text-muted-foreground">
|
|
98
|
+
#{row.original.id}
|
|
99
|
+
</span>
|
|
100
|
+
</div>
|
|
101
|
+
),
|
|
102
|
+
},
|
|
103
|
+
{
|
|
104
|
+
accessorKey: "state",
|
|
105
|
+
header: t("queues.state"),
|
|
106
|
+
meta: { label: t("queues.state") },
|
|
107
|
+
enableSorting: false,
|
|
108
|
+
cell: ({ row }) => {
|
|
109
|
+
const state = row.original.state as QueueJobState;
|
|
110
|
+
return (
|
|
111
|
+
<Badge variant={STATE_VARIANT[state] ?? "secondary"}>
|
|
112
|
+
{t(STATE_LABEL[state] ?? "queues.stateWaiting")}
|
|
113
|
+
</Badge>
|
|
114
|
+
);
|
|
115
|
+
},
|
|
116
|
+
},
|
|
117
|
+
{
|
|
118
|
+
accessorKey: "percent",
|
|
119
|
+
header: t("queues.progress"),
|
|
120
|
+
meta: { label: t("queues.progress") },
|
|
121
|
+
enableSorting: false,
|
|
122
|
+
cell: ({ getValue }) => {
|
|
123
|
+
const percent = getValue() as number | null;
|
|
124
|
+
return percent == null ? "—" : `${percent}%`;
|
|
125
|
+
},
|
|
126
|
+
},
|
|
127
|
+
{
|
|
128
|
+
accessorKey: "attemptsMade",
|
|
129
|
+
header: t("queues.attempts"),
|
|
130
|
+
meta: { label: t("queues.attempts") },
|
|
131
|
+
enableSorting: false,
|
|
132
|
+
},
|
|
133
|
+
{
|
|
134
|
+
accessorKey: "createdAt",
|
|
135
|
+
header: t("queues.createdAt"),
|
|
136
|
+
meta: { label: t("queues.createdAt") },
|
|
137
|
+
enableSorting: false,
|
|
138
|
+
cell: ({ getValue }) => new Date(String(getValue())).toLocaleString(),
|
|
139
|
+
},
|
|
140
|
+
{
|
|
141
|
+
accessorKey: "failedReason",
|
|
142
|
+
header: t("queues.reason"),
|
|
143
|
+
meta: { label: t("queues.reason") },
|
|
144
|
+
enableSorting: false,
|
|
145
|
+
// Cru e por inteiro no `title`: quem lê esta tela é quem vai consertar
|
|
146
|
+
// a causa, e a mensagem truncada some justamente na parte útil.
|
|
147
|
+
cell: ({ getValue }) => {
|
|
148
|
+
const reason = getValue() as string | null;
|
|
149
|
+
return reason ? (
|
|
150
|
+
<span
|
|
151
|
+
className="line-clamp-2 max-w-md font-mono text-xs"
|
|
152
|
+
title={reason}
|
|
153
|
+
>
|
|
154
|
+
{reason}
|
|
155
|
+
</span>
|
|
156
|
+
) : (
|
|
157
|
+
"—"
|
|
158
|
+
);
|
|
159
|
+
},
|
|
160
|
+
},
|
|
161
|
+
{
|
|
162
|
+
id: "actions",
|
|
163
|
+
header: () => <div className="text-center">{t("common.actions")}</div>,
|
|
164
|
+
meta: { shrink: true },
|
|
165
|
+
enableHiding: false,
|
|
166
|
+
enableSorting: false,
|
|
167
|
+
cell: ({ row }) =>
|
|
168
|
+
canManage ? (
|
|
169
|
+
<RowActions>
|
|
170
|
+
<Button
|
|
171
|
+
variant="ghost"
|
|
172
|
+
size="sm"
|
|
173
|
+
onClick={() => void queue.retry(row.original.id)}
|
|
174
|
+
>
|
|
175
|
+
<RotateCcw className="h-4 w-4" />
|
|
176
|
+
{t("queues.retry")}
|
|
177
|
+
</Button>
|
|
178
|
+
<Button
|
|
179
|
+
variant="ghost"
|
|
180
|
+
size="sm"
|
|
181
|
+
className="text-destructive"
|
|
182
|
+
onClick={() => void removeJob(row.original.id)}
|
|
183
|
+
>
|
|
184
|
+
<Trash2 className="h-4 w-4" />
|
|
185
|
+
{t("common.delete")}
|
|
186
|
+
</Button>
|
|
187
|
+
</RowActions>
|
|
188
|
+
) : null,
|
|
189
|
+
},
|
|
190
|
+
],
|
|
191
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
192
|
+
[t, canManage, queue],
|
|
193
|
+
);
|
|
194
|
+
|
|
195
|
+
const pagination: PaginationState = {
|
|
196
|
+
pageIndex: queue.page,
|
|
197
|
+
pageSize: queue.pageSize,
|
|
198
|
+
};
|
|
199
|
+
const onPaginationChange: OnChangeFn<PaginationState> = (updater) => {
|
|
200
|
+
const next = typeof updater === "function" ? updater(pagination) : updater;
|
|
201
|
+
if (next.pageSize !== queue.pageSize) {
|
|
202
|
+
queue.setPageSize(next.pageSize);
|
|
203
|
+
queue.setPage(0);
|
|
204
|
+
} else if (next.pageIndex !== queue.page) {
|
|
205
|
+
queue.setPage(next.pageIndex);
|
|
206
|
+
}
|
|
207
|
+
};
|
|
208
|
+
|
|
209
|
+
return (
|
|
210
|
+
<div>
|
|
211
|
+
<h1 className="mb-1 text-3xl font-bold">{t("queues.title")}</h1>
|
|
212
|
+
<p className="mb-4 text-sm text-muted-foreground">
|
|
213
|
+
{t("queues.subtitle")}
|
|
214
|
+
</p>
|
|
215
|
+
|
|
216
|
+
{/* Os cartões são o resumo E o atalho: clicar troca o estado listado —
|
|
217
|
+
ver "3 falhos" e não poder abri-los seria uma informação morta. */}
|
|
218
|
+
<div className="mb-4 grid grid-cols-2 gap-3 sm:grid-cols-5">
|
|
219
|
+
{Object.values(QueueJobState).map((state) => (
|
|
220
|
+
<Card
|
|
221
|
+
key={state}
|
|
222
|
+
role="button"
|
|
223
|
+
tabIndex={0}
|
|
224
|
+
onClick={() => queue.setState(state)}
|
|
225
|
+
className={`cursor-pointer p-3 transition-colors hover:bg-accent ${
|
|
226
|
+
queue.state === state ? "border-primary" : ""
|
|
227
|
+
}`}
|
|
228
|
+
>
|
|
229
|
+
<p className="text-xs text-muted-foreground">
|
|
230
|
+
{t(STATE_LABEL[state])}
|
|
231
|
+
</p>
|
|
232
|
+
<p className="text-2xl font-bold tabular-nums">
|
|
233
|
+
{queue.counts[state]}
|
|
234
|
+
</p>
|
|
235
|
+
</Card>
|
|
236
|
+
))}
|
|
237
|
+
</div>
|
|
238
|
+
|
|
239
|
+
<DataTable
|
|
240
|
+
columns={columns}
|
|
241
|
+
data={queue.rows}
|
|
242
|
+
getRowId={(r) => r.id}
|
|
243
|
+
storageKey="queues-jobs"
|
|
244
|
+
loading={queue.loading}
|
|
245
|
+
manualPagination
|
|
246
|
+
rowCount={queue.total}
|
|
247
|
+
pagination={pagination}
|
|
248
|
+
onPaginationChange={onPaginationChange}
|
|
249
|
+
enableSorting={false}
|
|
250
|
+
emptyMessage={t("queues.empty")}
|
|
251
|
+
toolbar={
|
|
252
|
+
<SegmentedControl
|
|
253
|
+
value={queue.state}
|
|
254
|
+
onValueChange={(value) => queue.setState(value as QueueJobState)}
|
|
255
|
+
options={stateOptions}
|
|
256
|
+
label={t("queues.state")}
|
|
257
|
+
/>
|
|
258
|
+
}
|
|
259
|
+
/>
|
|
260
|
+
|
|
261
|
+
{confirmDialog}
|
|
262
|
+
</div>
|
|
263
|
+
);
|
|
264
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { api } from "#core/_services/api/axios.factory";
|
|
2
|
+
import type { components } from "#core/_services/api/schema";
|
|
3
|
+
|
|
4
|
+
/** Tipos derivados do `openapi.json` (§5) — não redigitar a forma da resposta. */
|
|
5
|
+
export type QueueJob = components["schemas"]["QueueJobResponse"];
|
|
6
|
+
export type QueueCounts = components["schemas"]["QueueCountsResponse"];
|
|
7
|
+
|
|
8
|
+
export interface Paginated<T> {
|
|
9
|
+
items: T[];
|
|
10
|
+
meta: components["schemas"]["PaginationMetaResponse"];
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Estados que a tela lista.
|
|
15
|
+
*
|
|
16
|
+
* Espelha o `LISTABLE_STATES` do backend. `paused` não está aqui porque é
|
|
17
|
+
* estado da fila inteira, não de um job.
|
|
18
|
+
*/
|
|
19
|
+
export enum QueueJobState {
|
|
20
|
+
WAITING = "waiting",
|
|
21
|
+
ACTIVE = "active",
|
|
22
|
+
COMPLETED = "completed",
|
|
23
|
+
FAILED = "failed",
|
|
24
|
+
DELAYED = "delayed",
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface QueueJobsQuery {
|
|
28
|
+
state: QueueJobState;
|
|
29
|
+
page: number;
|
|
30
|
+
limit: number;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export const queuesService = {
|
|
34
|
+
counts(): Promise<QueueCounts> {
|
|
35
|
+
return api.get<QueueCounts>("/queues/counts").then((r) => r.data);
|
|
36
|
+
},
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Uma página de jobs de **um** estado.
|
|
40
|
+
*
|
|
41
|
+
* O estado é obrigatório: no Redis cada estado é uma estrutura própria, e
|
|
42
|
+
* "todos" não é uma consulta que exista.
|
|
43
|
+
*/
|
|
44
|
+
list(query: QueueJobsQuery): Promise<Paginated<QueueJob>> {
|
|
45
|
+
return api
|
|
46
|
+
.get<Paginated<QueueJob>>("/queues/jobs", { params: query })
|
|
47
|
+
.then((r) => r.data);
|
|
48
|
+
},
|
|
49
|
+
|
|
50
|
+
retry(id: string): Promise<void> {
|
|
51
|
+
return api.post(`/queues/jobs/${id}/retry`).then(() => undefined);
|
|
52
|
+
},
|
|
53
|
+
|
|
54
|
+
remove(id: string): Promise<void> {
|
|
55
|
+
return api.delete(`/queues/jobs/${id}`).then(() => undefined);
|
|
56
|
+
},
|
|
57
|
+
};
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useCallback, useEffect, useState } from "react";
|
|
4
|
+
|
|
5
|
+
import { api } from "#core/_services/api/axios.factory";
|
|
6
|
+
import { useSocket } from "#core/contexts/socket-context";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Eventos que a fila do backend empurra (ver `job.constants.ts` do
|
|
10
|
+
* `rl-core-api`). Saem pela mesma conexão das notificações — o hook não abre
|
|
11
|
+
* socket nenhum.
|
|
12
|
+
*/
|
|
13
|
+
export const JOB_PROGRESS_EVENT = "job:progress";
|
|
14
|
+
export const JOB_COMPLETED_EVENT = "job:completed";
|
|
15
|
+
export const JOB_FAILED_EVENT = "job:failed";
|
|
16
|
+
|
|
17
|
+
export type JobState = "idle" | "running" | "completed" | "failed";
|
|
18
|
+
|
|
19
|
+
interface JobProgressEvent {
|
|
20
|
+
jobId: string;
|
|
21
|
+
name: string;
|
|
22
|
+
processed: number;
|
|
23
|
+
total: number;
|
|
24
|
+
percent: number;
|
|
25
|
+
message?: string;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
interface JobCompletedEvent<TSummary> {
|
|
29
|
+
jobId: string;
|
|
30
|
+
name: string;
|
|
31
|
+
summary: TSummary;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
interface JobFailedEvent {
|
|
35
|
+
jobId: string;
|
|
36
|
+
name: string;
|
|
37
|
+
errorCode: string | null;
|
|
38
|
+
message: string;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** `GET /jobs/:id` — a mesma informação, para quem perdeu os eventos. */
|
|
42
|
+
interface JobStatusResponse<TSummary> {
|
|
43
|
+
id: string;
|
|
44
|
+
name: string;
|
|
45
|
+
state: "waiting" | "active" | "completed" | "failed" | "delayed" | "unknown";
|
|
46
|
+
progress: {
|
|
47
|
+
processed: number;
|
|
48
|
+
total: number;
|
|
49
|
+
percent: number;
|
|
50
|
+
message?: string;
|
|
51
|
+
} | null;
|
|
52
|
+
summary: TSummary | null;
|
|
53
|
+
error: { code: string | null; message: string } | null;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export interface UseJobProgressResult<TSummary> {
|
|
57
|
+
state: JobState;
|
|
58
|
+
percent: number;
|
|
59
|
+
processed: number;
|
|
60
|
+
total: number;
|
|
61
|
+
message: string | null;
|
|
62
|
+
/** Relatório que o processador devolveu — só depois de `completed`. */
|
|
63
|
+
summary: TSummary | null;
|
|
64
|
+
error: { code: string | null; message: string } | null;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const IDLE = {
|
|
68
|
+
state: "idle" as JobState,
|
|
69
|
+
percent: 0,
|
|
70
|
+
processed: 0,
|
|
71
|
+
total: 0,
|
|
72
|
+
message: null,
|
|
73
|
+
summary: null,
|
|
74
|
+
error: null,
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Acompanha um job em segundo plano.
|
|
79
|
+
*
|
|
80
|
+
* ```tsx
|
|
81
|
+
* const [jobId, setJobId] = useState<string | null>(null);
|
|
82
|
+
* const job = useJobProgress<ImportSummary>(jobId);
|
|
83
|
+
* ```
|
|
84
|
+
*
|
|
85
|
+
* Três coisas que o evento sozinho não resolve, e por isso o hook também
|
|
86
|
+
* consulta `GET /jobs/:id`:
|
|
87
|
+
*
|
|
88
|
+
* - o job pode ter terminado **antes** de a tela assinar (arquivo pequeno);
|
|
89
|
+
* - a aba pode ter sido recarregada no meio, com o `jobId` guardado;
|
|
90
|
+
* - o socket pode cair, e o que passou na janela offline não volta sozinho —
|
|
91
|
+
* por isso a releitura também acontece a cada reconexão.
|
|
92
|
+
*/
|
|
93
|
+
export function useJobProgress<TSummary = unknown>(
|
|
94
|
+
jobId: string | null,
|
|
95
|
+
): UseJobProgressResult<TSummary> {
|
|
96
|
+
const { on, reconnectCount } = useSocket();
|
|
97
|
+
const [result, setResult] = useState<UseJobProgressResult<TSummary>>(
|
|
98
|
+
IDLE as UseJobProgressResult<TSummary>,
|
|
99
|
+
);
|
|
100
|
+
|
|
101
|
+
// Job novo começa do zero: sem isto a barra do anterior ficaria em 100%.
|
|
102
|
+
useEffect(() => {
|
|
103
|
+
setResult(IDLE as UseJobProgressResult<TSummary>);
|
|
104
|
+
}, [jobId]);
|
|
105
|
+
|
|
106
|
+
const sync = useCallback(async (): Promise<void> => {
|
|
107
|
+
if (!jobId) {
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
try {
|
|
111
|
+
const { data } = await api.get<JobStatusResponse<TSummary>>(
|
|
112
|
+
`/jobs/${jobId}`,
|
|
113
|
+
);
|
|
114
|
+
setResult({
|
|
115
|
+
state:
|
|
116
|
+
data.state === "completed"
|
|
117
|
+
? "completed"
|
|
118
|
+
: data.state === "failed"
|
|
119
|
+
? "failed"
|
|
120
|
+
: "running",
|
|
121
|
+
percent: data.progress?.percent ?? 0,
|
|
122
|
+
processed: data.progress?.processed ?? 0,
|
|
123
|
+
total: data.progress?.total ?? 0,
|
|
124
|
+
message: data.progress?.message ?? null,
|
|
125
|
+
summary: data.summary,
|
|
126
|
+
error: data.error,
|
|
127
|
+
});
|
|
128
|
+
} catch {
|
|
129
|
+
// Job expirado ou fora de alcance: o socket ainda pode trazer o fim, e
|
|
130
|
+
// derrubar a tela por causa da consulta de apoio seria pior.
|
|
131
|
+
}
|
|
132
|
+
}, [jobId]);
|
|
133
|
+
|
|
134
|
+
useEffect(() => {
|
|
135
|
+
void sync();
|
|
136
|
+
}, [sync, reconnectCount]);
|
|
137
|
+
|
|
138
|
+
useEffect(() => {
|
|
139
|
+
if (!jobId) {
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const offProgress = on<JobProgressEvent>(JOB_PROGRESS_EVENT, (data) => {
|
|
144
|
+
if (data.jobId !== jobId) {
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
setResult((current) => ({
|
|
148
|
+
...current,
|
|
149
|
+
state: "running",
|
|
150
|
+
percent: data.percent,
|
|
151
|
+
processed: data.processed,
|
|
152
|
+
total: data.total,
|
|
153
|
+
message: data.message ?? null,
|
|
154
|
+
}));
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
const offCompleted = on<JobCompletedEvent<TSummary>>(
|
|
158
|
+
JOB_COMPLETED_EVENT,
|
|
159
|
+
(data) => {
|
|
160
|
+
if (data.jobId !== jobId) {
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
163
|
+
setResult((current) => ({
|
|
164
|
+
...current,
|
|
165
|
+
state: "completed",
|
|
166
|
+
percent: 100,
|
|
167
|
+
summary: data.summary,
|
|
168
|
+
error: null,
|
|
169
|
+
}));
|
|
170
|
+
},
|
|
171
|
+
);
|
|
172
|
+
|
|
173
|
+
const offFailed = on<JobFailedEvent>(JOB_FAILED_EVENT, (data) => {
|
|
174
|
+
if (data.jobId !== jobId) {
|
|
175
|
+
return;
|
|
176
|
+
}
|
|
177
|
+
setResult((current) => ({
|
|
178
|
+
...current,
|
|
179
|
+
state: "failed",
|
|
180
|
+
error: { code: data.errorCode, message: data.message },
|
|
181
|
+
}));
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
return () => {
|
|
185
|
+
offProgress();
|
|
186
|
+
offCompleted();
|
|
187
|
+
offFailed();
|
|
188
|
+
};
|
|
189
|
+
}, [jobId, on]);
|
|
190
|
+
|
|
191
|
+
return result;
|
|
192
|
+
}
|
package/src/i18n/messages/en.ts
CHANGED
|
@@ -11,6 +11,7 @@ export const en: Messages = {
|
|
|
11
11
|
profile: "My profile",
|
|
12
12
|
logs: "Logs",
|
|
13
13
|
audit: "Audit",
|
|
14
|
+
queues: "Queues",
|
|
14
15
|
},
|
|
15
16
|
logs: {
|
|
16
17
|
outcomeSuccess: "Success",
|
|
@@ -84,6 +85,31 @@ export const en: Messages = {
|
|
|
84
85
|
permissions: "Permissions",
|
|
85
86
|
roles: "Roles",
|
|
86
87
|
},
|
|
88
|
+
job: {
|
|
89
|
+
running: "Processing…",
|
|
90
|
+
completed: "Processing finished",
|
|
91
|
+
failed: "The processing could not be completed",
|
|
92
|
+
},
|
|
93
|
+
queues: {
|
|
94
|
+
title: "Queues",
|
|
95
|
+
subtitle: "Background processing — what is running and what failed",
|
|
96
|
+
job: "Job",
|
|
97
|
+
state: "State",
|
|
98
|
+
progress: "Progress",
|
|
99
|
+
attempts: "Attempts",
|
|
100
|
+
createdAt: "Created at",
|
|
101
|
+
reason: "Failure reason",
|
|
102
|
+
retry: "Retry",
|
|
103
|
+
removeTitle: "Remove this job?",
|
|
104
|
+
removeDescription:
|
|
105
|
+
"It leaves the queue and can no longer be retried from here.",
|
|
106
|
+
empty: "No jobs in this state",
|
|
107
|
+
stateWaiting: "Waiting",
|
|
108
|
+
stateActive: "Active",
|
|
109
|
+
stateCompleted: "Completed",
|
|
110
|
+
stateFailed: "Failed",
|
|
111
|
+
stateDelayed: "Delayed",
|
|
112
|
+
},
|
|
87
113
|
table: {
|
|
88
114
|
columns: "Columns",
|
|
89
115
|
columnsButton: "Columns",
|
package/src/i18n/messages/pt.ts
CHANGED
|
@@ -9,6 +9,7 @@ export const pt = {
|
|
|
9
9
|
profile: "Meu perfil",
|
|
10
10
|
logs: "Logs",
|
|
11
11
|
audit: "Auditoria",
|
|
12
|
+
queues: "Filas",
|
|
12
13
|
},
|
|
13
14
|
logs: {
|
|
14
15
|
outcomeSuccess: "Sucesso",
|
|
@@ -86,6 +87,31 @@ export const pt = {
|
|
|
86
87
|
permissions: "Permissões",
|
|
87
88
|
roles: "Papéis",
|
|
88
89
|
},
|
|
90
|
+
job: {
|
|
91
|
+
running: "Processando…",
|
|
92
|
+
completed: "Processamento concluído",
|
|
93
|
+
failed: "Não foi possível concluir o processamento",
|
|
94
|
+
},
|
|
95
|
+
queues: {
|
|
96
|
+
title: "Filas",
|
|
97
|
+
subtitle: "Processamento em segundo plano — o que está rodando e o que falhou",
|
|
98
|
+
job: "Job",
|
|
99
|
+
state: "Estado",
|
|
100
|
+
progress: "Progresso",
|
|
101
|
+
attempts: "Tentativas",
|
|
102
|
+
createdAt: "Criado em",
|
|
103
|
+
reason: "Motivo da falha",
|
|
104
|
+
retry: "Reprocessar",
|
|
105
|
+
removeTitle: "Remover este job?",
|
|
106
|
+
removeDescription:
|
|
107
|
+
"Ele sai da fila e não poderá mais ser reprocessado por aqui.",
|
|
108
|
+
empty: "Nenhum job neste estado",
|
|
109
|
+
stateWaiting: "Na fila",
|
|
110
|
+
stateActive: "Rodando",
|
|
111
|
+
stateCompleted: "Concluídos",
|
|
112
|
+
stateFailed: "Falhos",
|
|
113
|
+
stateDelayed: "Agendados",
|
|
114
|
+
},
|
|
89
115
|
table: {
|
|
90
116
|
columns: "Colunas",
|
|
91
117
|
columnsButton: "Colunas",
|
package/src/index.ts
CHANGED
|
@@ -20,6 +20,16 @@ export { NotFoundScreen } from "#core/features/errors/not-found-screen";
|
|
|
20
20
|
export { LoginScreen } from "#core/features/login/login-screen";
|
|
21
21
|
export { LogsScreen } from "#core/features/logs/logs-screen";
|
|
22
22
|
export { ProfileScreen } from "#core/features/profile/profile-screen";
|
|
23
|
+
export { QueuesScreen } from "#core/features/queues/queues-screen";
|
|
24
|
+
export type {
|
|
25
|
+
QueueCounts,
|
|
26
|
+
QueueJob,
|
|
27
|
+
QueueJobsQuery,
|
|
28
|
+
} from "#core/features/queues/services/queues.service";
|
|
29
|
+
export {
|
|
30
|
+
QueueJobState,
|
|
31
|
+
queuesService,
|
|
32
|
+
} from "#core/features/queues/services/queues.service";
|
|
23
33
|
export { RbacScreen } from "#core/features/rbac/rbac-screen";
|
|
24
34
|
export { ForgotPasswordScreen } from "#core/features/recovery/forgot-password-screen";
|
|
25
35
|
export { ResetPasswordScreen } from "#core/features/recovery/reset-password-screen";
|
|
@@ -60,6 +70,16 @@ export type { UseFilterSchemaResult } from "#core/hooks/use-filter-schema";
|
|
|
60
70
|
export { useFilterSchema } from "#core/hooks/use-filter-schema";
|
|
61
71
|
export type { FilterMode, FiltersState } from "#core/hooks/use-filters";
|
|
62
72
|
export { useFilters } from "#core/hooks/use-filters";
|
|
73
|
+
export type {
|
|
74
|
+
JobState,
|
|
75
|
+
UseJobProgressResult,
|
|
76
|
+
} from "#core/hooks/use-job-progress";
|
|
77
|
+
export {
|
|
78
|
+
JOB_COMPLETED_EVENT,
|
|
79
|
+
JOB_FAILED_EVENT,
|
|
80
|
+
JOB_PROGRESS_EVENT,
|
|
81
|
+
useJobProgress,
|
|
82
|
+
} from "#core/hooks/use-job-progress";
|
|
63
83
|
export type { UseListQueryResult } from "#core/hooks/use-list-query";
|
|
64
84
|
export { useListQuery } from "#core/hooks/use-list-query";
|
|
65
85
|
export type { RunOptions, UseRequestResult } from "#core/hooks/use-request";
|