rl-core-front 0.19.7 → 0.19.9
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/package.json +1 -1
- package/src/_services/api/schema.d.ts +42 -4
- package/src/_utils/format.ts +37 -0
- package/src/components/ui/data-table.tsx +94 -2
- package/src/components/ui/segmented-control.tsx +33 -0
- package/src/features/queues/components/index.ts +2 -0
- package/src/features/queues/components/queue-job-detail.tsx +224 -0
- package/src/features/queues/hooks/use-queue-jobs.ts +140 -58
- package/src/features/queues/queues-screen.tsx +126 -104
- package/src/features/queues/services/queues.service.ts +43 -8
- package/src/i18n/messages/en.ts +13 -1
- package/src/i18n/messages/pt.ts +13 -1
- package/src/index.ts +5 -0
package/package.json
CHANGED
|
@@ -1746,6 +1746,26 @@ export interface components {
|
|
|
1746
1746
|
/** Format: date-time */
|
|
1747
1747
|
finishedAt: string | null;
|
|
1748
1748
|
};
|
|
1749
|
+
QueueStateCountsResponse: {
|
|
1750
|
+
/** @example 3 */
|
|
1751
|
+
waiting: number;
|
|
1752
|
+
/** @example 1 */
|
|
1753
|
+
active: number;
|
|
1754
|
+
/** @example 128 */
|
|
1755
|
+
completed: number;
|
|
1756
|
+
/** @example 2 */
|
|
1757
|
+
failed: number;
|
|
1758
|
+
/** @example 0 */
|
|
1759
|
+
delayed: number;
|
|
1760
|
+
};
|
|
1761
|
+
QueueCountsRowResponse: {
|
|
1762
|
+
/**
|
|
1763
|
+
* @example exports
|
|
1764
|
+
* @enum {string}
|
|
1765
|
+
*/
|
|
1766
|
+
queue: "jobs" | "exports" | "images";
|
|
1767
|
+
counts: components["schemas"]["QueueStateCountsResponse"];
|
|
1768
|
+
};
|
|
1749
1769
|
QueueCountsResponse: {
|
|
1750
1770
|
/** @example 3 */
|
|
1751
1771
|
waiting: number;
|
|
@@ -1757,10 +1777,16 @@ export interface components {
|
|
|
1757
1777
|
failed: number;
|
|
1758
1778
|
/** @example 0 */
|
|
1759
1779
|
delayed: number;
|
|
1780
|
+
byQueue: components["schemas"]["QueueCountsRowResponse"][];
|
|
1760
1781
|
};
|
|
1761
1782
|
QueueJobResponse: {
|
|
1762
1783
|
/** @example 42 */
|
|
1763
1784
|
id: string;
|
|
1785
|
+
/**
|
|
1786
|
+
* @example exports
|
|
1787
|
+
* @enum {string}
|
|
1788
|
+
*/
|
|
1789
|
+
queue: "jobs" | "exports" | "images";
|
|
1764
1790
|
/** @example funkos:import */
|
|
1765
1791
|
name: string;
|
|
1766
1792
|
/** @example failed */
|
|
@@ -1773,15 +1799,27 @@ export interface components {
|
|
|
1773
1799
|
* "fileName": "funkos_2026-09-04.xlsx"
|
|
1774
1800
|
* }
|
|
1775
1801
|
*/
|
|
1776
|
-
payload:
|
|
1802
|
+
payload: {
|
|
1803
|
+
[key: string]: unknown;
|
|
1804
|
+
} | null;
|
|
1777
1805
|
/** @example 2 */
|
|
1778
1806
|
attemptsMade: number;
|
|
1807
|
+
/** @example 3 */
|
|
1808
|
+
maxAttempts: number;
|
|
1779
1809
|
/** @example 69 */
|
|
1780
1810
|
percent: number | null;
|
|
1811
|
+
/** @example 5000 linha(s) */
|
|
1812
|
+
progressMessage: string | null;
|
|
1781
1813
|
failedReason: string | null;
|
|
1814
|
+
stacktrace: string[] | null;
|
|
1815
|
+
result: {
|
|
1816
|
+
[key: string]: unknown;
|
|
1817
|
+
} | null;
|
|
1782
1818
|
/** Format: date-time */
|
|
1783
1819
|
createdAt: string;
|
|
1784
1820
|
/** Format: date-time */
|
|
1821
|
+
startedAt: string | null;
|
|
1822
|
+
/** Format: date-time */
|
|
1785
1823
|
finishedAt: string | null;
|
|
1786
1824
|
};
|
|
1787
1825
|
PaginatedQueueJobsResponse: {
|
|
@@ -3658,7 +3696,7 @@ export interface operations {
|
|
|
3658
3696
|
QueueAdminController_counts: {
|
|
3659
3697
|
parameters: {
|
|
3660
3698
|
query?: {
|
|
3661
|
-
queue?: "jobs" | "exports" | "images";
|
|
3699
|
+
queue?: "jobs" | "exports" | "images" | "all";
|
|
3662
3700
|
};
|
|
3663
3701
|
header?: never;
|
|
3664
3702
|
path?: never;
|
|
@@ -3682,9 +3720,9 @@ export interface operations {
|
|
|
3682
3720
|
state: "waiting" | "active" | "completed" | "failed" | "delayed";
|
|
3683
3721
|
page: string;
|
|
3684
3722
|
limit: string;
|
|
3685
|
-
sortBy?: "name" | "attemptsMade" | "percent" | "createdAt" | "failedReason";
|
|
3723
|
+
sortBy?: "name" | "queue" | "attemptsMade" | "percent" | "createdAt" | "failedReason";
|
|
3686
3724
|
sortDir?: "ASC" | "DESC";
|
|
3687
|
-
queue?: "jobs" | "exports" | "images";
|
|
3725
|
+
queue?: "jobs" | "exports" | "images" | "all";
|
|
3688
3726
|
};
|
|
3689
3727
|
header?: never;
|
|
3690
3728
|
path?: never;
|
package/src/_utils/format.ts
CHANGED
|
@@ -25,6 +25,7 @@ export function formatDateTime(value: string | Date, locale: string): string {
|
|
|
25
25
|
}).format(date);
|
|
26
26
|
}
|
|
27
27
|
|
|
28
|
+
const SECOND_MS = 1000;
|
|
28
29
|
const MINUTE_MS = 60_000;
|
|
29
30
|
const HOUR_MS = 60 * MINUTE_MS;
|
|
30
31
|
|
|
@@ -83,6 +84,42 @@ export function formatRelativeTime(
|
|
|
83
84
|
}
|
|
84
85
|
|
|
85
86
|
/** Aplica máscara de telefone BR enquanto digita: (00) 00000-0000 */
|
|
87
|
+
/**
|
|
88
|
+
* Quanto durou, entre dois instantes ISO — "162ms", "4.2s", "1m 33s".
|
|
89
|
+
*
|
|
90
|
+
* A ponta final aberta é de propósito: sem ela, conta até agora, que é o que
|
|
91
|
+
* responde "está travado?" sobre algo ainda em andamento. Sem a ponta inicial
|
|
92
|
+
* não há duração — não começou.
|
|
93
|
+
*/
|
|
94
|
+
export function formatDuration(
|
|
95
|
+
startedAt: string | null,
|
|
96
|
+
finishedAt: string | null,
|
|
97
|
+
): string | null {
|
|
98
|
+
if (!startedAt) {
|
|
99
|
+
return null;
|
|
100
|
+
}
|
|
101
|
+
const started = new Date(startedAt).getTime();
|
|
102
|
+
const ended = finishedAt ? new Date(finishedAt).getTime() : Date.now();
|
|
103
|
+
|
|
104
|
+
if (Number.isNaN(started) || Number.isNaN(ended)) {
|
|
105
|
+
return null;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const ms = Math.max(0, ended - started);
|
|
109
|
+
|
|
110
|
+
if (ms < SECOND_MS) {
|
|
111
|
+
return `${ms}ms`;
|
|
112
|
+
}
|
|
113
|
+
if (ms < MINUTE_MS) {
|
|
114
|
+
return `${(ms / SECOND_MS).toFixed(1)}s`;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const minutes = Math.floor(ms / MINUTE_MS);
|
|
118
|
+
const seconds = Math.round((ms % MINUTE_MS) / SECOND_MS);
|
|
119
|
+
|
|
120
|
+
return `${minutes}m ${seconds}s`;
|
|
121
|
+
}
|
|
122
|
+
|
|
86
123
|
export function maskPhoneBR(value: string): string {
|
|
87
124
|
const d = value.replace(/\D/g, "").slice(0, 11);
|
|
88
125
|
if (d.length <= 2) {return d.replace(/(\d{0,2})/, "($1");}
|
|
@@ -299,6 +299,23 @@ interface DataTableProps<T extends RowData> {
|
|
|
299
299
|
* uma parcela dela.
|
|
300
300
|
*/
|
|
301
301
|
highlightGroupKey?: string | null;
|
|
302
|
+
/**
|
|
303
|
+
* O detalhe da linha, aberto embaixo dela no clique.
|
|
304
|
+
*
|
|
305
|
+
* Para o dado que não cabe em célula e não merece uma tela: o motivo inteiro
|
|
306
|
+
* de uma falha, o payload de um job, o rastro de um erro. A alternativa é
|
|
307
|
+
* cortar com reticências e pôr o resto num `title` — que some ao mover o
|
|
308
|
+
* mouse, não dá para copiar e não existe no celular.
|
|
309
|
+
*
|
|
310
|
+
* Uma linha aberta por vez, de propósito: abrir a segunda fecha a primeira.
|
|
311
|
+
* Duas abertas deslocam a lista para os dois lados e a comparação que
|
|
312
|
+
* justificaria isso se faz melhor voltando à tabela.
|
|
313
|
+
*
|
|
314
|
+
* Sem esta prop nada muda — tabela sem detalhe não ganha coluna nem clique.
|
|
315
|
+
* Com ela, a linha inteira é o gatilho, menos o que já é clicável dentro
|
|
316
|
+
* dela: o menu de ações continua sendo o menu de ações.
|
|
317
|
+
*/
|
|
318
|
+
renderRowDetail?: (row: T) => React.ReactNode;
|
|
302
319
|
}
|
|
303
320
|
|
|
304
321
|
/** Uma seção da listagem, como o cabeçalho de grupo a recebe. */
|
|
@@ -606,9 +623,12 @@ export function DataTable<T extends RowData>({
|
|
|
606
623
|
defaultCollapsedGroups,
|
|
607
624
|
highlightRowId = null,
|
|
608
625
|
highlightGroupKey = null,
|
|
626
|
+
renderRowDetail,
|
|
609
627
|
}: DataTableProps<T>): JSX.Element {
|
|
610
628
|
const { t } = useI18n();
|
|
611
629
|
const [colsOpen, setColsOpen] = useState(false);
|
|
630
|
+
/** A linha aberta, por id do TanStack. Uma por vez — ver `renderRowDetail`. */
|
|
631
|
+
const [openRow, setOpenRow] = useState<string | null>(null);
|
|
612
632
|
/** As seções fechadas, por chave. */
|
|
613
633
|
const [collapsedGroups, setCollapsedGroups] = useState<string[]>(
|
|
614
634
|
() => defaultCollapsedGroups ?? [],
|
|
@@ -898,8 +918,31 @@ export function DataTable<T extends RowData>({
|
|
|
898
918
|
? atual.filter((fechada) => fechada !== key)
|
|
899
919
|
: [...atual, key],
|
|
900
920
|
);
|
|
921
|
+
const expandable = Boolean(renderRowDetail);
|
|
901
922
|
const colSpan =
|
|
902
|
-
(table.getVisibleLeafColumns().length || 1) +
|
|
923
|
+
(table.getVisibleLeafColumns().length || 1) +
|
|
924
|
+
(reorderable ? 1 : 0) +
|
|
925
|
+
(expandable ? 1 : 0);
|
|
926
|
+
|
|
927
|
+
const toggleRow = (rowId: string): void =>
|
|
928
|
+
setOpenRow((atual) => (atual === rowId ? null : rowId));
|
|
929
|
+
|
|
930
|
+
/*
|
|
931
|
+
O clique na linha abre o detalhe — menos quando nasceu num controle.
|
|
932
|
+
|
|
933
|
+
A linha carrega botões de verdade (o menu de ações, um link para o registro
|
|
934
|
+
de origem), e sem esta guarda clicar em "Remover" também abriria o detalhe:
|
|
935
|
+
o diálogo de confirmação subiria com a linha se expandindo atrás dele.
|
|
936
|
+
*/
|
|
937
|
+
const onRowClick =
|
|
938
|
+
(rowId: string) =>
|
|
939
|
+
(event: ReactMouseEvent<HTMLTableRowElement>): void => {
|
|
940
|
+
const target = event.target as HTMLElement;
|
|
941
|
+
if (target.closest("button, a, input, select, textarea, [role='menuitem']")) {
|
|
942
|
+
return;
|
|
943
|
+
}
|
|
944
|
+
toggleRow(rowId);
|
|
945
|
+
};
|
|
903
946
|
|
|
904
947
|
const startRowDrag =
|
|
905
948
|
(rowId: string) =>
|
|
@@ -1184,6 +1227,11 @@ export function DataTable<T extends RowData>({
|
|
|
1184
1227
|
<span className="sr-only">{t("table.reorder")}</span>
|
|
1185
1228
|
</TableHead>
|
|
1186
1229
|
)}
|
|
1230
|
+
{expandable && (
|
|
1231
|
+
<TableHead className="w-px">
|
|
1232
|
+
<span className="sr-only">{t("table.detail")}</span>
|
|
1233
|
+
</TableHead>
|
|
1234
|
+
)}
|
|
1187
1235
|
{hg.headers.map((header) => {
|
|
1188
1236
|
const canSort = header.column.getCanSort();
|
|
1189
1237
|
const sorted = header.column.getIsSorted();
|
|
@@ -1293,12 +1341,20 @@ export function DataTable<T extends RowData>({
|
|
|
1293
1341
|
const index = section.offset + indexNaSecao;
|
|
1294
1342
|
const dragging = draggedRow === row.id;
|
|
1295
1343
|
const shift = dragging ? 0 : rowShift(index);
|
|
1344
|
+
const open = expandable && openRow === row.id;
|
|
1296
1345
|
|
|
1297
1346
|
return (
|
|
1347
|
+
<Fragment key={row.id}>
|
|
1298
1348
|
<TableRow
|
|
1299
|
-
key={row.id}
|
|
1300
1349
|
data-row-id={row.id}
|
|
1350
|
+
aria-expanded={expandable ? open : undefined}
|
|
1351
|
+
onClick={expandable ? onRowClick(row.id) : undefined}
|
|
1301
1352
|
className={cn(
|
|
1353
|
+
expandable && "cursor-pointer",
|
|
1354
|
+
// A linha aberta acesa e sem borda de baixo: é o
|
|
1355
|
+
// que a amarra ao próprio detalhe, em vez de os
|
|
1356
|
+
// dois parecerem dois registros.
|
|
1357
|
+
open && "bg-primary/[0.07] hover:bg-primary/[0.07] [&>td]:border-b-0",
|
|
1302
1358
|
// A linha viaja com o dedo, destacada do resto da tabela —
|
|
1303
1359
|
// é o que diz que o arrasto pegou, e qual linha pegou.
|
|
1304
1360
|
dragging &&
|
|
@@ -1373,6 +1429,24 @@ export function DataTable<T extends RowData>({
|
|
|
1373
1429
|
</button>
|
|
1374
1430
|
</TableCell>
|
|
1375
1431
|
)}
|
|
1432
|
+
{expandable && (
|
|
1433
|
+
<TableCell className="w-px pr-0">
|
|
1434
|
+
<button
|
|
1435
|
+
type="button"
|
|
1436
|
+
aria-label={t("table.detail")}
|
|
1437
|
+
aria-expanded={open}
|
|
1438
|
+
className="flex items-center text-muted-foreground hover:text-foreground"
|
|
1439
|
+
onClick={() => toggleRow(row.id)}
|
|
1440
|
+
>
|
|
1441
|
+
<ChevronRight
|
|
1442
|
+
className={cn(
|
|
1443
|
+
"h-4 w-4 transition-transform",
|
|
1444
|
+
open && "rotate-90",
|
|
1445
|
+
)}
|
|
1446
|
+
/>
|
|
1447
|
+
</button>
|
|
1448
|
+
</TableCell>
|
|
1449
|
+
)}
|
|
1376
1450
|
{row.getVisibleCells().map((cell) => (
|
|
1377
1451
|
<TableCell
|
|
1378
1452
|
key={cell.id}
|
|
@@ -1385,6 +1459,23 @@ export function DataTable<T extends RowData>({
|
|
|
1385
1459
|
</TableCell>
|
|
1386
1460
|
))}
|
|
1387
1461
|
</TableRow>
|
|
1462
|
+
|
|
1463
|
+
{/* O detalhe atravessa a tabela inteira: dentro de uma
|
|
1464
|
+
coluna ele herdaria a largura dela, e o texto que
|
|
1465
|
+
justifica abri-lo é justamente o que não cabe. */}
|
|
1466
|
+
{open && renderRowDetail && (
|
|
1467
|
+
<TableRow className="hover:bg-transparent">
|
|
1468
|
+
<TableCell
|
|
1469
|
+
colSpan={colSpan}
|
|
1470
|
+
className="bg-muted/40 p-0 align-top"
|
|
1471
|
+
>
|
|
1472
|
+
<div className="px-4 py-4">
|
|
1473
|
+
{renderRowDetail(row.original)}
|
|
1474
|
+
</div>
|
|
1475
|
+
</TableCell>
|
|
1476
|
+
</TableRow>
|
|
1477
|
+
)}
|
|
1478
|
+
</Fragment>
|
|
1388
1479
|
);
|
|
1389
1480
|
})}
|
|
1390
1481
|
</Fragment>
|
|
@@ -1398,6 +1489,7 @@ export function DataTable<T extends RowData>({
|
|
|
1398
1489
|
{/* A coluna da alça não tem fechamento, mas precisa da
|
|
1399
1490
|
célula: sem ela a linha inteira sobe uma casa. */}
|
|
1400
1491
|
{reorderable && <TableCell className="w-px" />}
|
|
1492
|
+
{expandable && <TableCell className="w-px" />}
|
|
1401
1493
|
{grupo.headers.map((header) => (
|
|
1402
1494
|
<TableCell
|
|
1403
1495
|
key={header.id}
|
|
@@ -9,6 +9,20 @@ import { TRACK_CLASS, TRACK_ITEM_CLASS } from "./track.styles";
|
|
|
9
9
|
export interface SegmentedOption {
|
|
10
10
|
value: string;
|
|
11
11
|
label: string;
|
|
12
|
+
/**
|
|
13
|
+
* Quantos itens a opção tem — uma pílula depois do rótulo.
|
|
14
|
+
*
|
|
15
|
+
* Para o controle que escolhe *onde olhar*: numa tela de filas, o número ao
|
|
16
|
+
* lado de cada fila diz qual tem falha antes de alguém clicar, e sem ele
|
|
17
|
+
* descobrir isso é abrir uma por uma. Não vale como enfeite — opção sem
|
|
18
|
+
* quantidade própria fica sem a pílula, e não com zero.
|
|
19
|
+
*/
|
|
20
|
+
count?: number;
|
|
21
|
+
/**
|
|
22
|
+
* `danger` quando o número é o problema, e não só o tamanho: falha, pendência
|
|
23
|
+
* vencida. `neutral` é o padrão.
|
|
24
|
+
*/
|
|
25
|
+
countTone?: "neutral" | "danger";
|
|
12
26
|
}
|
|
13
27
|
|
|
14
28
|
export interface SegmentedControlProps
|
|
@@ -69,6 +83,25 @@ export const SegmentedControl = ({
|
|
|
69
83
|
className={cn(TRACK_ITEM_CLASS, fullWidth && "flex-1")}
|
|
70
84
|
>
|
|
71
85
|
{option.label}
|
|
86
|
+
{option.count !== undefined && (
|
|
87
|
+
<>
|
|
88
|
+
{/* O espaço é do nome acessível, não do desenho: sem ele o leitor
|
|
89
|
+
de tela anuncia "Imagens2" — a pílula encosta no rótulo porque
|
|
90
|
+
são dois nós de texto vizinhos. O afastamento visual é o
|
|
91
|
+
`ml-1.5`. */}
|
|
92
|
+
{" "}
|
|
93
|
+
<span
|
|
94
|
+
className={cn(
|
|
95
|
+
"ml-1.5 rounded-full px-1.5 text-[11px] font-semibold tabular-nums",
|
|
96
|
+
option.countTone === "danger" && option.count > 0
|
|
97
|
+
? "bg-destructive/15 text-destructive"
|
|
98
|
+
: "bg-muted text-muted-foreground",
|
|
99
|
+
)}
|
|
100
|
+
>
|
|
101
|
+
{option.count}
|
|
102
|
+
</span>
|
|
103
|
+
</>
|
|
104
|
+
)}
|
|
72
105
|
</button>
|
|
73
106
|
))}
|
|
74
107
|
</div>
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { Copy, RotateCcw, Trash2 } from "lucide-react";
|
|
4
|
+
import type { JSX } from "react";
|
|
5
|
+
|
|
6
|
+
import { formatDateTime, formatDuration } from "#core/_utils/format";
|
|
7
|
+
import { Button } from "#core/components/ui/button";
|
|
8
|
+
import { useI18n, useToast } from "#core/contexts";
|
|
9
|
+
import {
|
|
10
|
+
QueueJob,
|
|
11
|
+
QueueJobState,
|
|
12
|
+
} from "#core/features/queues/services/queues.service";
|
|
13
|
+
|
|
14
|
+
export interface QueueJobDetailProps {
|
|
15
|
+
job: QueueJob;
|
|
16
|
+
/** Quem não tem `queues:manage:any` lê o detalhe, mas não age sobre ele. */
|
|
17
|
+
canManage: boolean;
|
|
18
|
+
onRetry: (job: QueueJob) => void;
|
|
19
|
+
onRemove: (job: QueueJob) => void;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* O job por inteiro, aberto embaixo da linha.
|
|
24
|
+
*
|
|
25
|
+
* Existe porque a tabela cortava o motivo da falha em duas linhas e deixava o
|
|
26
|
+
* texto inteiro num `title` do navegador — que some ao mover o mouse, não dá
|
|
27
|
+
* para copiar e não existe no celular. E o `stacktrace`, que o BullMQ grava no
|
|
28
|
+
* Redis, não chegava a aparecer: sem ele a tela diz *que* quebrou e nunca
|
|
29
|
+
* *onde*, sendo que quem opera a fila é exatamente quem vai consertar a causa.
|
|
30
|
+
*
|
|
31
|
+
* A fila não repete aqui: a linha continua visível logo acima, com o chip dela.
|
|
32
|
+
*/
|
|
33
|
+
export function QueueJobDetail({
|
|
34
|
+
job,
|
|
35
|
+
canManage,
|
|
36
|
+
onRetry,
|
|
37
|
+
onRemove,
|
|
38
|
+
}: QueueJobDetailProps): JSX.Element {
|
|
39
|
+
const { t, locale } = useI18n();
|
|
40
|
+
const { notify } = useToast();
|
|
41
|
+
|
|
42
|
+
/** O que se cola num chamado: identidade do job, motivo e rastro. */
|
|
43
|
+
const copyError = async (): Promise<void> => {
|
|
44
|
+
const text = [
|
|
45
|
+
`${job.name} #${job.id} (${job.queue})`,
|
|
46
|
+
job.failedReason,
|
|
47
|
+
job.stacktrace?.join("\n\n"),
|
|
48
|
+
]
|
|
49
|
+
.filter(Boolean)
|
|
50
|
+
.join("\n\n");
|
|
51
|
+
|
|
52
|
+
/*
|
|
53
|
+
A área de transferência não existe em toda origem: fora de HTTPS o
|
|
54
|
+
`navigator.clipboard` é `undefined`, e o erro engolido deixaria o botão
|
|
55
|
+
indistinguível de um botão quebrado. Avisar é o mínimo — o texto continua
|
|
56
|
+
na tela, selecionável.
|
|
57
|
+
*/
|
|
58
|
+
try {
|
|
59
|
+
await navigator.clipboard.writeText(text);
|
|
60
|
+
notify(t("queues.copied"), "success");
|
|
61
|
+
} catch {
|
|
62
|
+
notify(t("queues.copyFailed"), "error");
|
|
63
|
+
}
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
const duration = formatDuration(job.startedAt, job.finishedAt);
|
|
67
|
+
|
|
68
|
+
return (
|
|
69
|
+
<div className="grid gap-4">
|
|
70
|
+
<dl className="grid grid-cols-2 gap-x-6 gap-y-3 sm:grid-cols-4">
|
|
71
|
+
<Fact
|
|
72
|
+
label={t("queues.attempts")}
|
|
73
|
+
value={t("queues.attemptsOf", {
|
|
74
|
+
made: String(job.attemptsMade),
|
|
75
|
+
max: String(job.maxAttempts),
|
|
76
|
+
})}
|
|
77
|
+
/>
|
|
78
|
+
<Fact
|
|
79
|
+
label={t("queues.createdAt")}
|
|
80
|
+
value={formatDateTime(job.createdAt, locale)}
|
|
81
|
+
/>
|
|
82
|
+
<Fact
|
|
83
|
+
label={t("queues.finishedAt")}
|
|
84
|
+
value={job.finishedAt ? formatDateTime(job.finishedAt, locale) : "—"}
|
|
85
|
+
/>
|
|
86
|
+
<Fact label={t("queues.duration")} value={duration ?? "—"} />
|
|
87
|
+
</dl>
|
|
88
|
+
|
|
89
|
+
{job.failedReason && (
|
|
90
|
+
<DetailField label={t("queues.reason")}>
|
|
91
|
+
<p className="rounded-md border border-destructive/30 bg-destructive/[0.08] px-3 py-2.5 text-sm font-medium text-destructive">
|
|
92
|
+
{job.failedReason}
|
|
93
|
+
</p>
|
|
94
|
+
</DetailField>
|
|
95
|
+
)}
|
|
96
|
+
|
|
97
|
+
{job.progressMessage && (
|
|
98
|
+
<DetailField label={t("queues.progress")}>
|
|
99
|
+
<p className="text-sm">
|
|
100
|
+
{job.percent === null
|
|
101
|
+
? job.progressMessage
|
|
102
|
+
: `${job.percent}% — ${job.progressMessage}`}
|
|
103
|
+
</p>
|
|
104
|
+
</DetailField>
|
|
105
|
+
)}
|
|
106
|
+
|
|
107
|
+
<DetailField label={t("queues.payload")}>
|
|
108
|
+
<Block text={JSON.stringify(job.payload, null, 2)} />
|
|
109
|
+
</DetailField>
|
|
110
|
+
|
|
111
|
+
{job.result !== null && (
|
|
112
|
+
<DetailField label={t("queues.result")}>
|
|
113
|
+
<Block text={JSON.stringify(job.result, null, 2)} />
|
|
114
|
+
</DetailField>
|
|
115
|
+
)}
|
|
116
|
+
|
|
117
|
+
{/* Um rastro por tentativa, do mais recente para o mais antigo — é o que
|
|
118
|
+
distingue "quebrou três vezes pelo mesmo motivo" de "quebrou por
|
|
119
|
+
outro depois de reprocessar". */}
|
|
120
|
+
{job.stacktrace?.map((trace, index) => (
|
|
121
|
+
<DetailField
|
|
122
|
+
key={index}
|
|
123
|
+
label={
|
|
124
|
+
job.stacktrace && job.stacktrace.length > 1
|
|
125
|
+
? t("queues.stacktraceAttempt", { n: String(index + 1) })
|
|
126
|
+
: t("queues.stacktrace")
|
|
127
|
+
}
|
|
128
|
+
>
|
|
129
|
+
<Block text={trace} tone="bad" />
|
|
130
|
+
</DetailField>
|
|
131
|
+
))}
|
|
132
|
+
|
|
133
|
+
<div className="flex flex-wrap gap-2">
|
|
134
|
+
{job.failedReason && (
|
|
135
|
+
<Button variant="outline" size="sm" onClick={() => void copyError()}>
|
|
136
|
+
<Copy className="h-4 w-4" />
|
|
137
|
+
{t("queues.copyError")}
|
|
138
|
+
</Button>
|
|
139
|
+
)}
|
|
140
|
+
{canManage && (
|
|
141
|
+
<>
|
|
142
|
+
{/* Só o que falhou. O `retry()` do BullMQ tira o job do conjunto
|
|
143
|
+
`failed`, e pedi-lo para um job concluído ou ainda na fila
|
|
144
|
+
estoura no próprio Redis — o job não está lá. */}
|
|
145
|
+
{job.state === QueueJobState.FAILED && (
|
|
146
|
+
<Button variant="outline" size="sm" onClick={() => onRetry(job)}>
|
|
147
|
+
<RotateCcw className="h-4 w-4" />
|
|
148
|
+
{t("queues.retry")}
|
|
149
|
+
</Button>
|
|
150
|
+
)}
|
|
151
|
+
<Button
|
|
152
|
+
variant="outline"
|
|
153
|
+
size="sm"
|
|
154
|
+
className="text-destructive"
|
|
155
|
+
onClick={() => onRemove(job)}
|
|
156
|
+
>
|
|
157
|
+
<Trash2 className="h-4 w-4" />
|
|
158
|
+
{t("common.delete")}
|
|
159
|
+
</Button>
|
|
160
|
+
</>
|
|
161
|
+
)}
|
|
162
|
+
</div>
|
|
163
|
+
</div>
|
|
164
|
+
);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
const CAPTION_CLASS =
|
|
168
|
+
"text-[10.5px] font-semibold uppercase tracking-wider text-muted-foreground";
|
|
169
|
+
|
|
170
|
+
/** Um par rótulo/valor da grade do topo. */
|
|
171
|
+
const Fact = ({
|
|
172
|
+
label,
|
|
173
|
+
value,
|
|
174
|
+
}: {
|
|
175
|
+
label: string;
|
|
176
|
+
value: string;
|
|
177
|
+
}): JSX.Element => (
|
|
178
|
+
<div className="grid gap-0.5">
|
|
179
|
+
<dt className={CAPTION_CLASS}>{label}</dt>
|
|
180
|
+
<dd className="text-sm tabular-nums">{value}</dd>
|
|
181
|
+
</div>
|
|
182
|
+
);
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Legenda em cima de um bloco qualquer.
|
|
186
|
+
*
|
|
187
|
+
* Não é o `Field` de `components/ui`: aquele veste controle de formulário, com
|
|
188
|
+
* `Label` e linha de erro. Este é leitura.
|
|
189
|
+
*/
|
|
190
|
+
const DetailField = ({
|
|
191
|
+
label,
|
|
192
|
+
children,
|
|
193
|
+
}: {
|
|
194
|
+
label: string;
|
|
195
|
+
children: React.ReactNode;
|
|
196
|
+
}): JSX.Element => (
|
|
197
|
+
<div className="grid gap-1.5">
|
|
198
|
+
<span className={CAPTION_CLASS}>{label}</span>
|
|
199
|
+
{children}
|
|
200
|
+
</div>
|
|
201
|
+
);
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* Texto técnico como ele é: fonte mono, quebra de linha preservada e rolagem
|
|
205
|
+
* própria. O teto de altura existe para um rastro de vinte linhas não empurrar
|
|
206
|
+
* o resto da lista para fora da tela.
|
|
207
|
+
*/
|
|
208
|
+
const Block = ({
|
|
209
|
+
text,
|
|
210
|
+
tone,
|
|
211
|
+
}: {
|
|
212
|
+
text: string;
|
|
213
|
+
tone?: "bad";
|
|
214
|
+
}): JSX.Element => (
|
|
215
|
+
<pre
|
|
216
|
+
className={
|
|
217
|
+
tone === "bad"
|
|
218
|
+
? "max-h-52 overflow-auto rounded-md border border-destructive/30 bg-destructive/[0.06] px-3 py-2.5 font-mono text-[11.5px] leading-relaxed"
|
|
219
|
+
: "max-h-52 overflow-auto rounded-md border bg-card px-3 py-2.5 font-mono text-[11.5px] leading-relaxed"
|
|
220
|
+
}
|
|
221
|
+
>
|
|
222
|
+
{text}
|
|
223
|
+
</pre>
|
|
224
|
+
);
|