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
|
@@ -5,6 +5,7 @@ import {
|
|
|
5
5
|
ChevronRight,
|
|
6
6
|
History,
|
|
7
7
|
LayoutDashboard,
|
|
8
|
+
ListChecks,
|
|
8
9
|
LogOut,
|
|
9
10
|
Menu,
|
|
10
11
|
ScrollText,
|
|
@@ -51,6 +52,23 @@ interface NavItem {
|
|
|
51
52
|
perm: string | string[] | null;
|
|
52
53
|
}
|
|
53
54
|
|
|
55
|
+
/**
|
|
56
|
+
* Item de menu que abre e fecha em vez de navegar.
|
|
57
|
+
*
|
|
58
|
+
* O "Administração" era isto escrito à mão dentro do componente; agora é só o
|
|
59
|
+
* primeiro cliente do grupo genérico, e o projeto declara os dele por
|
|
60
|
+
* `AppNavItem.children`.
|
|
61
|
+
*/
|
|
62
|
+
interface NavGroup {
|
|
63
|
+
key: string;
|
|
64
|
+
icon: React.ComponentType<{ className?: string }>;
|
|
65
|
+
children: NavItem[];
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
type NavEntry = NavItem | NavGroup;
|
|
69
|
+
|
|
70
|
+
const isGroup = (entry: NavEntry): entry is NavGroup => "children" in entry;
|
|
71
|
+
|
|
54
72
|
/**
|
|
55
73
|
* Item de menu declarado pelo projeto.
|
|
56
74
|
*
|
|
@@ -69,7 +87,14 @@ export interface AppNavItem {
|
|
|
69
87
|
* funcionam — o projeto só precisa do dicionário quando for bilíngue.
|
|
70
88
|
*/
|
|
71
89
|
label: string;
|
|
72
|
-
|
|
90
|
+
/**
|
|
91
|
+
* Rota da tela. Opcional **só** em item com `children`: o pai de um grupo
|
|
92
|
+
* abre e fecha, não navega.
|
|
93
|
+
*
|
|
94
|
+
* Pai que navega e expande fica ambíguo no celular, onde o toque faz as duas
|
|
95
|
+
* coisas — por isso o grupo não herda rota de filho nenhum.
|
|
96
|
+
*/
|
|
97
|
+
href?: string;
|
|
73
98
|
icon: React.ComponentType<{ className?: string }>;
|
|
74
99
|
/**
|
|
75
100
|
* Permissão exigida para o item aparecer. Ausente, aparece para todos.
|
|
@@ -78,8 +103,34 @@ export interface AppNavItem {
|
|
|
78
103
|
* atender dois escopos, como `users:read:any` e `users:read:team`.
|
|
79
104
|
*/
|
|
80
105
|
permission?: string | string[] | null;
|
|
106
|
+
/**
|
|
107
|
+
* Telas filhas, recolhidas sob o pai — o mesmo comportamento que o grupo de
|
|
108
|
+
* administração já tinha.
|
|
109
|
+
*
|
|
110
|
+
* ```tsx
|
|
111
|
+
* { label: "nav.funkos", icon: Package, children: [
|
|
112
|
+
* { label: "nav.funkosCollection", href: "/funkos", icon: Boxes },
|
|
113
|
+
* { label: "nav.funkosCatalogs", href: "/funkos/catalogos", icon: Tags },
|
|
114
|
+
* ] }
|
|
115
|
+
* ```
|
|
116
|
+
*
|
|
117
|
+
* A permissão continua valendo por item: cada filho é filtrado pelo que
|
|
118
|
+
* declarou, e pai que ficou sem nenhum filho visível não é desenhado — grupo
|
|
119
|
+
* vazio é uma seta que não abre nada.
|
|
120
|
+
*
|
|
121
|
+
* Um nível só. Filho de filho não é menu lateral, é sitemap.
|
|
122
|
+
*/
|
|
123
|
+
children?: AppNavItem[];
|
|
81
124
|
}
|
|
82
125
|
|
|
126
|
+
/** Item declarado pelo projeto no formato interno do menu. */
|
|
127
|
+
const toNavItem = (item: AppNavItem & { href: string }): NavItem => ({
|
|
128
|
+
key: item.label,
|
|
129
|
+
href: item.href,
|
|
130
|
+
icon: item.icon,
|
|
131
|
+
perm: item.permission ?? null,
|
|
132
|
+
});
|
|
133
|
+
|
|
83
134
|
const DASHBOARD: NavItem = {
|
|
84
135
|
key: "nav.dashboard",
|
|
85
136
|
href: "/dashboard",
|
|
@@ -103,6 +154,14 @@ const ADMIN_CHILDREN: NavItem[] = [
|
|
|
103
154
|
},
|
|
104
155
|
{ key: "nav.rbac", href: "/rbac", icon: ShieldCheck, perm: "roles:read:any" },
|
|
105
156
|
{ key: "nav.logs", href: "/logs", icon: ScrollText, perm: "audit:read:any" },
|
|
157
|
+
// Só aparece para quem opera a fila — em ambiente sem Redis ninguém tem a
|
|
158
|
+
// permissão, e o item some sozinho.
|
|
159
|
+
{
|
|
160
|
+
key: "nav.queues",
|
|
161
|
+
href: "/filas",
|
|
162
|
+
icon: ListChecks,
|
|
163
|
+
perm: "queues:read:any",
|
|
164
|
+
},
|
|
106
165
|
// Restrito a administradores — só quem tem audit:read-trail:any (só Super Admin) vê este item.
|
|
107
166
|
{
|
|
108
167
|
key: "nav.audit",
|
|
@@ -157,18 +216,39 @@ export function AppShell({
|
|
|
157
216
|
[user],
|
|
158
217
|
);
|
|
159
218
|
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
219
|
+
const adminGroup: NavGroup = {
|
|
220
|
+
key: "nav.admin",
|
|
221
|
+
icon: Settings,
|
|
222
|
+
children: adminChildren,
|
|
223
|
+
};
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* Os itens do projeto passam pelo mesmo filtro de permissão dos do core: um
|
|
227
|
+
* menu que mostra o que o usuário não pode abrir só entrega 403.
|
|
228
|
+
*
|
|
229
|
+
* O filtro é por item, inclusive dentro do grupo: um pai cujo último filho
|
|
230
|
+
* visível sumiu não é desenhado, e um pai sem `href` também não vira link
|
|
231
|
+
* solto — grupo vazio é uma seta que não abre nada.
|
|
232
|
+
*/
|
|
233
|
+
const projectEntries = useMemo<NavEntry[]>(
|
|
163
234
|
() =>
|
|
164
235
|
(navItems ?? [])
|
|
165
236
|
.filter((item) => allows(item.permission ?? null))
|
|
166
|
-
.map((item) =>
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
237
|
+
.map((item): NavEntry | null => {
|
|
238
|
+
if (item.children?.length) {
|
|
239
|
+
const children = item.children
|
|
240
|
+
.filter((child) => allows(child.permission ?? null))
|
|
241
|
+
.filter((child): child is AppNavItem & { href: string } =>
|
|
242
|
+
Boolean(child.href),
|
|
243
|
+
)
|
|
244
|
+
.map(toNavItem);
|
|
245
|
+
return children.length > 0
|
|
246
|
+
? { key: item.label, icon: item.icon, children }
|
|
247
|
+
: null;
|
|
248
|
+
}
|
|
249
|
+
return item.href ? toNavItem({ ...item, href: item.href }) : null;
|
|
250
|
+
})
|
|
251
|
+
.filter((entry): entry is NavEntry => entry !== null),
|
|
172
252
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
173
253
|
[user, navItems],
|
|
174
254
|
);
|
|
@@ -190,20 +270,39 @@ export function AppShell({
|
|
|
190
270
|
DASHBOARD,
|
|
191
271
|
PROFILE,
|
|
192
272
|
...ADMIN_CHILDREN,
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
273
|
+
// Achata pai e filhos: a rota de um filho precisa ser barrada como
|
|
274
|
+
// qualquer outra, e o pai de grupo não tem rota para checar.
|
|
275
|
+
...(navItems ?? [])
|
|
276
|
+
.flatMap((item) => [item, ...(item.children ?? [])])
|
|
277
|
+
.filter((item): item is AppNavItem & { href: string } =>
|
|
278
|
+
Boolean(item.href),
|
|
279
|
+
)
|
|
280
|
+
.map(toNavItem),
|
|
199
281
|
];
|
|
200
282
|
const current = known.find((item) => item.href === pathname);
|
|
201
283
|
return current ? !allows(current.perm) : false;
|
|
202
284
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
203
285
|
}, [user, navItems, pathname]);
|
|
204
286
|
|
|
205
|
-
|
|
206
|
-
|
|
287
|
+
/**
|
|
288
|
+
* Aberto/fechado por grupo, e não um booleano só como era com o
|
|
289
|
+
* "Administração" sozinho.
|
|
290
|
+
*
|
|
291
|
+
* Sem entrada no mapa, o grupo nasce aberto quando a rota atual é de um
|
|
292
|
+
* filho — o que o `adminActive` já fazia. A entrada aparece no primeiro
|
|
293
|
+
* clique: a partir daí manda a escolha do usuário, não a rota.
|
|
294
|
+
*/
|
|
295
|
+
const [openGroups, setOpenGroups] = useState<Record<string, boolean>>({});
|
|
296
|
+
|
|
297
|
+
const isGroupOpen = (group: NavGroup): boolean =>
|
|
298
|
+
openGroups[group.key] ??
|
|
299
|
+
group.children.some((child) => pathname === child.href);
|
|
300
|
+
|
|
301
|
+
const toggleGroup = (group: NavGroup): void =>
|
|
302
|
+
setOpenGroups((current) => ({
|
|
303
|
+
...current,
|
|
304
|
+
[group.key]: !isGroupOpen(group),
|
|
305
|
+
}));
|
|
207
306
|
|
|
208
307
|
useEffect(() => {
|
|
209
308
|
if (!loading && !user) {router.replace("/login");}
|
|
@@ -241,17 +340,29 @@ export function AppShell({
|
|
|
241
340
|
item,
|
|
242
341
|
mini,
|
|
243
342
|
nested,
|
|
343
|
+
parentKey,
|
|
244
344
|
}: {
|
|
245
345
|
item: NavItem;
|
|
246
346
|
mini: boolean;
|
|
247
347
|
nested?: boolean;
|
|
348
|
+
/**
|
|
349
|
+
* Grupo a que o item pertence, só para o tooltip do modo recolhido.
|
|
350
|
+
*
|
|
351
|
+
* Ali os filhos aparecem soltos, sem o pai: "Catálogos" sozinho, ao lado de
|
|
352
|
+
* "Tarefas", não diria de quê. O caminho no tooltip resolve isso sem
|
|
353
|
+
* obrigar o rótulo a repetir o nome da feature no menu aberto.
|
|
354
|
+
*/
|
|
355
|
+
parentKey?: string;
|
|
248
356
|
}): JSX.Element => {
|
|
249
357
|
const active = pathname === item.href;
|
|
250
358
|
const Icon = item.icon;
|
|
359
|
+
const tooltip = parentKey
|
|
360
|
+
? `${t(parentKey)} › ${t(item.key)}`
|
|
361
|
+
: t(item.key);
|
|
251
362
|
return (
|
|
252
363
|
<button
|
|
253
364
|
onClick={() => go(item.href)}
|
|
254
|
-
title={mini ?
|
|
365
|
+
title={mini ? tooltip : undefined}
|
|
255
366
|
className={cn(
|
|
256
367
|
"flex w-full items-center gap-3 rounded-lg px-3 py-2 text-sm font-medium transition-colors",
|
|
257
368
|
mini && "justify-center px-2",
|
|
@@ -267,46 +378,70 @@ export function AppShell({
|
|
|
267
378
|
);
|
|
268
379
|
};
|
|
269
380
|
|
|
381
|
+
/**
|
|
382
|
+
* Um grupo do menu — o pai que abre e fecha, e os filhos indentados.
|
|
383
|
+
*
|
|
384
|
+
* Com a barra recolhida não há onde desenhar seta nem rótulo, então os
|
|
385
|
+
* filhos aparecem soltos, separados do resto; é o que o "Administração" já
|
|
386
|
+
* fazia, e agora vale para qualquer grupo.
|
|
387
|
+
*/
|
|
388
|
+
const NavGroupBlock = ({
|
|
389
|
+
group,
|
|
390
|
+
mini,
|
|
391
|
+
}: {
|
|
392
|
+
group: NavGroup;
|
|
393
|
+
mini: boolean;
|
|
394
|
+
}): JSX.Element => {
|
|
395
|
+
const Icon = group.icon;
|
|
396
|
+
const open = isGroupOpen(group);
|
|
397
|
+
if (mini) {
|
|
398
|
+
return (
|
|
399
|
+
<>
|
|
400
|
+
<Separator className="my-2" />
|
|
401
|
+
{group.children.map((child) => (
|
|
402
|
+
<NavLink key={child.href} item={child} mini parentKey={group.key} />
|
|
403
|
+
))}
|
|
404
|
+
</>
|
|
405
|
+
);
|
|
406
|
+
}
|
|
407
|
+
return (
|
|
408
|
+
<div className="mt-2">
|
|
409
|
+
<button
|
|
410
|
+
onClick={() => toggleGroup(group)}
|
|
411
|
+
className="flex w-full items-center gap-3 rounded-lg px-3 py-2 text-sm font-medium text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
|
|
412
|
+
>
|
|
413
|
+
<Icon className="h-5 w-5 shrink-0" />
|
|
414
|
+
<span className="flex-1 text-left">{t(group.key)}</span>
|
|
415
|
+
<ChevronRight
|
|
416
|
+
className={cn("h-4 w-4 transition-transform", open && "rotate-90")}
|
|
417
|
+
/>
|
|
418
|
+
</button>
|
|
419
|
+
{open && (
|
|
420
|
+
<div className="mt-1 flex flex-col gap-1">
|
|
421
|
+
{group.children.map((child) => (
|
|
422
|
+
<NavLink key={child.href} item={child} mini={false} nested />
|
|
423
|
+
))}
|
|
424
|
+
</div>
|
|
425
|
+
)}
|
|
426
|
+
</div>
|
|
427
|
+
);
|
|
428
|
+
};
|
|
429
|
+
|
|
270
430
|
const NavContent = ({ mini }: { mini: boolean }): JSX.Element => (
|
|
271
431
|
<div className="flex flex-1 flex-col gap-1 overflow-y-auto overflow-x-hidden p-3">
|
|
272
432
|
<NavLink item={DASHBOARD} mini={mini} />
|
|
273
433
|
|
|
274
|
-
{
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
{adminChildren.length > 0 &&
|
|
279
|
-
(mini ? (
|
|
280
|
-
<>
|
|
281
|
-
<Separator className="my-2" />
|
|
282
|
-
{adminChildren.map((c) => (
|
|
283
|
-
<NavLink key={c.href} item={c} mini />
|
|
284
|
-
))}
|
|
285
|
-
</>
|
|
434
|
+
{projectEntries.map((entry) =>
|
|
435
|
+
isGroup(entry) ? (
|
|
436
|
+
<NavGroupBlock key={entry.key} group={entry} mini={mini} />
|
|
286
437
|
) : (
|
|
287
|
-
<
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
<ChevronRight
|
|
295
|
-
className={cn(
|
|
296
|
-
"h-4 w-4 transition-transform",
|
|
297
|
-
adminOpen && "rotate-90",
|
|
298
|
-
)}
|
|
299
|
-
/>
|
|
300
|
-
</button>
|
|
301
|
-
{adminOpen && (
|
|
302
|
-
<div className="mt-1 flex flex-col gap-1">
|
|
303
|
-
{adminChildren.map((c) => (
|
|
304
|
-
<NavLink key={c.href} item={c} mini={false} nested />
|
|
305
|
-
))}
|
|
306
|
-
</div>
|
|
307
|
-
)}
|
|
308
|
-
</div>
|
|
309
|
-
))}
|
|
438
|
+
<NavLink key={entry.href} item={entry} mini={mini} />
|
|
439
|
+
),
|
|
440
|
+
)}
|
|
441
|
+
|
|
442
|
+
{adminChildren.length > 0 && (
|
|
443
|
+
<NavGroupBlock group={adminGroup} mini={mini} />
|
|
444
|
+
)}
|
|
310
445
|
|
|
311
446
|
<div className="mt-1">
|
|
312
447
|
<NavLink item={PROFILE} mini={mini} />
|
|
@@ -111,7 +111,15 @@ const DialogFooter = ({
|
|
|
111
111
|
}: React.HTMLAttributes<HTMLDivElement>): React.JSX.Element => (
|
|
112
112
|
<div
|
|
113
113
|
className={cn(
|
|
114
|
-
|
|
114
|
+
// O `-mb-2` é correção óptica, não capricho: o espaçamento em volta das
|
|
115
|
+
// duas linhas já é simétrico (16px de cada lado), e as bordas do modal
|
|
116
|
+
// também (24px). O rodapé parece mais alto porque um botão tem 40px e o
|
|
117
|
+
// título tem 18 — e o botão ainda carrega ar interno próprio, que o
|
|
118
|
+
// título não tem. Comer 8px da borda de baixo aproxima os dois blocos
|
|
119
|
+
// sem encostar o botão na linha, que é o que aconteceria mexendo no
|
|
120
|
+
// `pt-4`. Fica no rodapé, e não no `p-6` do conteúdo, para modal sem
|
|
121
|
+
// rodapé seguir com a borda inteira.
|
|
122
|
+
"-mb-2 flex shrink-0 flex-col-reverse gap-2 border-t border-border pt-4 sm:flex-row sm:justify-end",
|
|
115
123
|
className,
|
|
116
124
|
)}
|
|
117
125
|
{...props}
|
|
@@ -266,11 +274,14 @@ const DialogForm = React.forwardRef<
|
|
|
266
274
|
>
|
|
267
275
|
{/* `min-h-0` é o que permite encolher dentro do flex e ativar o scroll:
|
|
268
276
|
sem ele o corpo assume a altura do conteúdo e estoura o modal. */}
|
|
269
|
-
{/* `
|
|
270
|
-
desenhado fora da borda do input
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
277
|
+
{/* `p-1` reserva a folga que o anel de foco ocupa: o `ring` é
|
|
278
|
+
desenhado fora da borda do input (`ring-2` + `ring-offset-1` = 3px),
|
|
279
|
+
e sem essa margem o container de scroll corta o anel.
|
|
280
|
+
Nos dois eixos: na horizontal cortava o lado dos campos, e na
|
|
281
|
+
vertical cortava a borda de baixo do último campo — que é o que
|
|
282
|
+
aparece em todo modal com um campo colado no rodapé.
|
|
283
|
+
`-m-1` devolve o recuo para o conteúdo seguir alinhado. */}
|
|
284
|
+
<div className="-m-1 flex min-h-0 flex-1 flex-col gap-4 overflow-y-auto p-1 [scrollbar-width:thin]">
|
|
274
285
|
{hasFooter ? items.slice(0, footerAt) : items}
|
|
275
286
|
</div>
|
|
276
287
|
{hasFooter && items.slice(footerAt)}
|
|
@@ -17,6 +17,7 @@ export * from "./filter-group";
|
|
|
17
17
|
export * from "./filter-rule";
|
|
18
18
|
export * from "./filter-sheet";
|
|
19
19
|
export * from "./input";
|
|
20
|
+
export * from "./job-progress";
|
|
20
21
|
export * from "./label";
|
|
21
22
|
export * from "./password-requirements";
|
|
22
23
|
export * from "./row-actions";
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { AlertTriangle, CheckCircle2 } from "lucide-react";
|
|
4
|
+
import type { JSX } from "react";
|
|
5
|
+
|
|
6
|
+
import { Alert } from "#core/components/ui/alert";
|
|
7
|
+
import { Spinner } from "#core/components/ui/spinner";
|
|
8
|
+
import { useI18n } from "#core/contexts";
|
|
9
|
+
import type { JobState } from "#core/hooks/use-job-progress";
|
|
10
|
+
import { cn } from "#core/lib/utils";
|
|
11
|
+
|
|
12
|
+
export interface JobProgressProps {
|
|
13
|
+
state: JobState;
|
|
14
|
+
/** Inteiro de 0 a 100. */
|
|
15
|
+
percent: number;
|
|
16
|
+
processed: number;
|
|
17
|
+
total: number;
|
|
18
|
+
/** O que o processador está fazendo agora, quando ele diz. */
|
|
19
|
+
message?: string | null;
|
|
20
|
+
error?: { code: string | null; message: string } | null;
|
|
21
|
+
/** Substitui o texto de conclusão — a feature costuma ter algo melhor a dizer. */
|
|
22
|
+
successMessage?: string;
|
|
23
|
+
className?: string;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Barra de um job em segundo plano.
|
|
28
|
+
*
|
|
29
|
+
* Existe no core para que cada feature com importação, exportação ou relatório
|
|
30
|
+
* não redesenhe o mesmo par barra + contagem. Não conhece job nenhum: recebe o
|
|
31
|
+
* que o `useJobProgress` devolve.
|
|
32
|
+
*/
|
|
33
|
+
export function JobProgress({
|
|
34
|
+
state,
|
|
35
|
+
percent,
|
|
36
|
+
processed,
|
|
37
|
+
total,
|
|
38
|
+
message,
|
|
39
|
+
error,
|
|
40
|
+
successMessage,
|
|
41
|
+
className,
|
|
42
|
+
}: JobProgressProps): JSX.Element | null {
|
|
43
|
+
const { t } = useI18n();
|
|
44
|
+
|
|
45
|
+
if (state === "idle") {
|
|
46
|
+
return null;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
if (state === "failed") {
|
|
50
|
+
return (
|
|
51
|
+
<Alert
|
|
52
|
+
variant="error"
|
|
53
|
+
icon={<AlertTriangle />}
|
|
54
|
+
className={className}
|
|
55
|
+
>
|
|
56
|
+
{error?.message ?? t("job.failed")}
|
|
57
|
+
</Alert>
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
if (state === "completed") {
|
|
62
|
+
return (
|
|
63
|
+
<Alert
|
|
64
|
+
variant="success"
|
|
65
|
+
icon={<CheckCircle2 />}
|
|
66
|
+
className={className}
|
|
67
|
+
>
|
|
68
|
+
{successMessage ?? t("job.completed")}
|
|
69
|
+
</Alert>
|
|
70
|
+
);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
return (
|
|
74
|
+
<div className={cn("space-y-2", className)}>
|
|
75
|
+
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
|
76
|
+
<Spinner className="h-4 w-4" />
|
|
77
|
+
<span className="flex-1 truncate">{message ?? t("job.running")}</span>
|
|
78
|
+
{/* Contagem ao lado do percentual: em importação, "120 de 175" diz
|
|
79
|
+
mais sobre o que falta do que "69%". */}
|
|
80
|
+
{total > 0 && (
|
|
81
|
+
<span className="tabular-nums">
|
|
82
|
+
{processed} / {total}
|
|
83
|
+
</span>
|
|
84
|
+
)}
|
|
85
|
+
</div>
|
|
86
|
+
<div
|
|
87
|
+
className="h-2 w-full overflow-hidden rounded-full bg-muted"
|
|
88
|
+
role="progressbar"
|
|
89
|
+
aria-valuenow={percent}
|
|
90
|
+
aria-valuemin={0}
|
|
91
|
+
aria-valuemax={100}
|
|
92
|
+
>
|
|
93
|
+
<div
|
|
94
|
+
className="h-full rounded-full bg-primary transition-[width] duration-300"
|
|
95
|
+
style={{ width: `${percent}%` }}
|
|
96
|
+
/>
|
|
97
|
+
</div>
|
|
98
|
+
</div>
|
|
99
|
+
);
|
|
100
|
+
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useCallback, useEffect, useState } from "react";
|
|
4
|
+
|
|
5
|
+
import {
|
|
6
|
+
QueueCounts,
|
|
7
|
+
QueueJob,
|
|
8
|
+
QueueJobState,
|
|
9
|
+
queuesService,
|
|
10
|
+
} from "#core/features/queues/services/queues.service";
|
|
11
|
+
import { useRequest } from "#core/hooks/use-request";
|
|
12
|
+
|
|
13
|
+
const EMPTY_COUNTS: QueueCounts = {
|
|
14
|
+
waiting: 0,
|
|
15
|
+
active: 0,
|
|
16
|
+
completed: 0,
|
|
17
|
+
failed: 0,
|
|
18
|
+
delayed: 0,
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
export interface UseQueueJobsResult {
|
|
22
|
+
rows: QueueJob[];
|
|
23
|
+
counts: QueueCounts;
|
|
24
|
+
total: number;
|
|
25
|
+
page: number;
|
|
26
|
+
pageSize: number;
|
|
27
|
+
state: QueueJobState;
|
|
28
|
+
loading: boolean;
|
|
29
|
+
setPage: (page: number) => void;
|
|
30
|
+
setPageSize: (size: number) => void;
|
|
31
|
+
setState: (state: QueueJobState) => void;
|
|
32
|
+
refresh: () => Promise<void>;
|
|
33
|
+
retry: (id: string) => Promise<void>;
|
|
34
|
+
remove: (id: string) => Promise<void>;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* A fila como a tela de administração a vê.
|
|
39
|
+
*
|
|
40
|
+
* Abre em `failed` de propósito: quem entra aqui está atrás do que deu errado.
|
|
41
|
+
* O que está andando bem não precisa de tela.
|
|
42
|
+
*/
|
|
43
|
+
export function useQueueJobs(): UseQueueJobsResult {
|
|
44
|
+
const { run, loading } = useRequest();
|
|
45
|
+
|
|
46
|
+
const [rows, setRows] = useState<QueueJob[]>([]);
|
|
47
|
+
const [counts, setCounts] = useState<QueueCounts>(EMPTY_COUNTS);
|
|
48
|
+
const [total, setTotal] = useState(0);
|
|
49
|
+
const [page, setPage] = useState(0);
|
|
50
|
+
const [pageSize, setPageSize] = useState(20);
|
|
51
|
+
const [state, setStateValue] = useState<QueueJobState>(QueueJobState.FAILED);
|
|
52
|
+
|
|
53
|
+
const fetch = useCallback(async (): Promise<void> => {
|
|
54
|
+
const [list, summary] = await Promise.all([
|
|
55
|
+
// O `page` da tabela é base zero; o da API, base um.
|
|
56
|
+
run(() => queuesService.list({ state, page: page + 1, limit: pageSize })),
|
|
57
|
+
run(() => queuesService.counts()),
|
|
58
|
+
]);
|
|
59
|
+
if (list) {
|
|
60
|
+
setRows(list.items);
|
|
61
|
+
setTotal(list.meta.total);
|
|
62
|
+
}
|
|
63
|
+
if (summary) {
|
|
64
|
+
setCounts(summary);
|
|
65
|
+
}
|
|
66
|
+
}, [run, state, page, pageSize]);
|
|
67
|
+
|
|
68
|
+
useEffect(() => {
|
|
69
|
+
void fetch();
|
|
70
|
+
}, [fetch]);
|
|
71
|
+
|
|
72
|
+
const setState = (next: QueueJobState): void => {
|
|
73
|
+
setStateValue(next);
|
|
74
|
+
// Página 3 de "falhos" não existe em "concluídos": trocar de estado sem
|
|
75
|
+
// voltar ao começo abriria uma página vazia.
|
|
76
|
+
setPage(0);
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Depois de reprocessar ou remover, relê a lista **e** os contadores: o job
|
|
81
|
+
* mudou de estado, e a tela ficaria mostrando o que já não está ali.
|
|
82
|
+
*/
|
|
83
|
+
const act = async (action: Promise<void>): Promise<void> => {
|
|
84
|
+
await action;
|
|
85
|
+
await fetch();
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
return {
|
|
89
|
+
rows,
|
|
90
|
+
counts,
|
|
91
|
+
total,
|
|
92
|
+
page,
|
|
93
|
+
pageSize,
|
|
94
|
+
state,
|
|
95
|
+
loading,
|
|
96
|
+
setPage,
|
|
97
|
+
setPageSize,
|
|
98
|
+
setState,
|
|
99
|
+
refresh: fetch,
|
|
100
|
+
retry: (id) => act(queuesService.retry(id)),
|
|
101
|
+
remove: (id) => act(queuesService.remove(id)),
|
|
102
|
+
};
|
|
103
|
+
}
|