rl-core-front 0.19.8 → 0.19.10
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/app-shell.tsx +16 -4
- 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/hooks/use-tab-state.ts +17 -1
- package/src/hooks/use-url-param.ts +52 -5
- package/src/i18n/messages/en.ts +13 -1
- package/src/i18n/messages/pt.ts +13 -1
- package/src/index.ts +5 -0
|
@@ -1,20 +1,23 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
3
|
import type { SortingState } from "@tanstack/react-table";
|
|
4
|
-
import { useCallback, useEffect, useState } from "react";
|
|
4
|
+
import { useCallback, useEffect, useRef, useState } from "react";
|
|
5
5
|
|
|
6
6
|
import {
|
|
7
|
+
ALL_QUEUES,
|
|
7
8
|
QueueCounts,
|
|
8
9
|
QueueJob,
|
|
9
10
|
QueueJobState,
|
|
10
11
|
QueueName,
|
|
12
|
+
QueueScope,
|
|
11
13
|
queuesService,
|
|
14
|
+
QueueStateCounts,
|
|
12
15
|
} from "#core/features/queues/services/queues.service";
|
|
13
16
|
import { useLatestRequest } from "#core/hooks/use-latest-request";
|
|
14
17
|
import { useRequest } from "#core/hooks/use-request";
|
|
15
18
|
import { useTabState } from "#core/hooks/use-tab-state";
|
|
16
19
|
|
|
17
|
-
const
|
|
20
|
+
const EMPTY_STATE_COUNTS: QueueStateCounts = {
|
|
18
21
|
waiting: 0,
|
|
19
22
|
active: 0,
|
|
20
23
|
completed: 0,
|
|
@@ -22,35 +25,52 @@ const EMPTY_COUNTS: QueueCounts = {
|
|
|
22
25
|
delayed: 0,
|
|
23
26
|
};
|
|
24
27
|
|
|
28
|
+
const EMPTY_COUNTS: QueueCounts = { ...EMPTY_STATE_COUNTS, byQueue: [] };
|
|
29
|
+
|
|
30
|
+
const QUEUE_JOB_STATES = Object.values(QueueJobState);
|
|
31
|
+
const QUEUE_SCOPES: QueueScope[] = [ALL_QUEUES, ...Object.values(QueueName)];
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* De quanto em quanto tempo a lista se relê enquanto há job andando.
|
|
35
|
+
*
|
|
36
|
+
* Existe porque fila é tela de coisa em movimento: sem isto, um job a 40% fica
|
|
37
|
+
* a 40% até alguém apertar F5, e a tela passa a mentir sobre o que está
|
|
38
|
+
* acontecendo. Cinco segundos é o intervalo em que a barra anda visivelmente
|
|
39
|
+
* sem a tela virar um tráfego de requisição.
|
|
40
|
+
*/
|
|
41
|
+
const LIVE_REFRESH_MS = 5000;
|
|
42
|
+
|
|
25
43
|
export interface UseQueueJobsResult {
|
|
26
44
|
rows: QueueJob[];
|
|
27
|
-
|
|
45
|
+
/** O total de cada estado no escopo — os cartões do topo. */
|
|
46
|
+
counts: QueueStateCounts;
|
|
47
|
+
/** Quantos jobs do estado atual cada fila tem — o número ao lado de cada fila. */
|
|
48
|
+
countsByQueue: Record<QueueScope, number>;
|
|
28
49
|
total: number;
|
|
29
50
|
page: number;
|
|
30
51
|
pageSize: number;
|
|
31
52
|
state: QueueJobState;
|
|
32
|
-
queue:
|
|
53
|
+
queue: QueueScope;
|
|
33
54
|
sorting: SortingState;
|
|
34
55
|
loading: boolean;
|
|
35
56
|
setPage: (page: number) => void;
|
|
36
57
|
setPageSize: (size: number) => void;
|
|
37
58
|
setSorting: (sorting: SortingState) => void;
|
|
38
59
|
setState: (state: QueueJobState) => void;
|
|
39
|
-
setQueue: (queue:
|
|
60
|
+
setQueue: (queue: QueueScope) => void;
|
|
40
61
|
refresh: () => Promise<void>;
|
|
41
|
-
retry: (
|
|
42
|
-
remove: (
|
|
62
|
+
retry: (job: QueueJob) => Promise<void>;
|
|
63
|
+
remove: (job: QueueJob) => Promise<void>;
|
|
43
64
|
}
|
|
44
65
|
|
|
45
66
|
/**
|
|
46
67
|
* A fila como a tela de administração a vê.
|
|
47
68
|
*
|
|
48
|
-
* Abre em `failed
|
|
49
|
-
*
|
|
69
|
+
* Abre em **todas as filas** e em `failed`: quem entra aqui está atrás do que
|
|
70
|
+
* deu errado, e não sabe de antemão em qual das três está — abrir numa fila só
|
|
71
|
+
* fazia a tela começar vazia em projeto que não usa a geral, o que é
|
|
72
|
+
* indistinguível de "nada foi enfileirado".
|
|
50
73
|
*/
|
|
51
|
-
const QUEUE_JOB_STATES = Object.values(QueueJobState);
|
|
52
|
-
const QUEUE_NAMES = Object.values(QueueName);
|
|
53
|
-
|
|
54
74
|
export function useQueueJobs(): UseQueueJobsResult {
|
|
55
75
|
const { run, loading } = useRequest();
|
|
56
76
|
const latest = useLatestRequest();
|
|
@@ -69,57 +89,101 @@ export function useQueueJobs(): UseQueueJobsResult {
|
|
|
69
89
|
QueueJobState.FAILED,
|
|
70
90
|
{ param: "state" },
|
|
71
91
|
);
|
|
72
|
-
const { tab: queue, setTab: setQueueValue } = useTabState(
|
|
92
|
+
const { tab: queue, setTab: setQueueValue } = useTabState<QueueScope>(
|
|
73
93
|
"queues-queue",
|
|
74
|
-
|
|
75
|
-
|
|
94
|
+
QUEUE_SCOPES,
|
|
95
|
+
ALL_QUEUES,
|
|
76
96
|
{ param: "queue" },
|
|
77
97
|
);
|
|
78
98
|
// Sem ordem escolhida, a lista sai na ordem do Redis — que é a mais recente
|
|
79
99
|
// primeiro e não custa varredura nenhuma no backend.
|
|
80
100
|
const [sorting, setSorting] = useState<SortingState>([]);
|
|
81
101
|
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
102
|
+
/**
|
|
103
|
+
* Busca a página e os contadores.
|
|
104
|
+
*
|
|
105
|
+
* `quiet` é a releitura do relógio, e ela não passa pelo `useRequest`: o `run`
|
|
106
|
+
* acende o `loading`, e com ele o `DataTable` troca as linhas por um spinner —
|
|
107
|
+
* a cada cinco segundos a lista inteira sumiria e voltaria debaixo do mouse.
|
|
108
|
+
* Também não avisa erro: um Redis fora do ar renderia um toast a cada cinco
|
|
109
|
+
* segundos, e o que já está na tela é a resposta honesta até a tentativa
|
|
110
|
+
* seguinte.
|
|
111
|
+
*/
|
|
112
|
+
const load = useCallback(
|
|
113
|
+
async (quiet = false): Promise<void> => {
|
|
114
|
+
const wrap = quiet
|
|
115
|
+
? <T>(fn: () => Promise<T>): Promise<T | null> => fn().catch(() => null)
|
|
116
|
+
: run;
|
|
117
|
+
|
|
118
|
+
// `latest` descarta a resposta que outra busca já passou para trás —
|
|
119
|
+
// trocar de estado e de página em sequência devolve as duas fora de ordem.
|
|
120
|
+
const result = await latest(() =>
|
|
121
|
+
Promise.all([
|
|
122
|
+
// O `page` da tabela é base zero; o da API, base um.
|
|
123
|
+
wrap(() =>
|
|
124
|
+
queuesService.list({
|
|
125
|
+
state,
|
|
126
|
+
queue,
|
|
127
|
+
page: page + 1,
|
|
128
|
+
limit: pageSize,
|
|
129
|
+
sortBy: sorting[0]?.id,
|
|
130
|
+
sortDir: sorting[0]
|
|
131
|
+
? sorting[0].desc
|
|
132
|
+
? "DESC"
|
|
133
|
+
: "ASC"
|
|
134
|
+
: undefined,
|
|
135
|
+
}),
|
|
136
|
+
),
|
|
137
|
+
wrap(() => queuesService.counts(queue)),
|
|
138
|
+
]),
|
|
139
|
+
);
|
|
140
|
+
if (!result) {
|
|
141
|
+
return;
|
|
142
|
+
}
|
|
143
|
+
const [list, summary] = result;
|
|
144
|
+
if (list) {
|
|
145
|
+
setRows(list.items);
|
|
146
|
+
setTotal(list.meta.total);
|
|
147
|
+
}
|
|
148
|
+
if (summary) {
|
|
149
|
+
setCounts(summary);
|
|
150
|
+
}
|
|
151
|
+
},
|
|
152
|
+
[latest, run, state, queue, page, pageSize, sorting],
|
|
153
|
+
);
|
|
117
154
|
|
|
118
155
|
useEffect(() => {
|
|
119
156
|
void (async (): Promise<void> => {
|
|
120
|
-
await
|
|
157
|
+
await load();
|
|
121
158
|
})();
|
|
122
|
-
}, [
|
|
159
|
+
}, [load]);
|
|
160
|
+
|
|
161
|
+
/*
|
|
162
|
+
Enquanto houver job andando, a tela se relê sozinha.
|
|
163
|
+
|
|
164
|
+
Só nesse caso: uma lista de falhas antigas não muda por si, e recarregá-la
|
|
165
|
+
a cada cinco segundos gastaria três consultas ao Redis para redesenhar o
|
|
166
|
+
mesmo. O `ref` é o que mantém o intervalo com uma função só — recriá-lo a
|
|
167
|
+
cada `load` novo reiniciaria a contagem em todo render.
|
|
168
|
+
*/
|
|
169
|
+
const loadRef = useRef(load);
|
|
170
|
+
|
|
171
|
+
useEffect(() => {
|
|
172
|
+
loadRef.current = load;
|
|
173
|
+
}, [load]);
|
|
174
|
+
|
|
175
|
+
const running = counts.waiting + counts.active > 0;
|
|
176
|
+
|
|
177
|
+
useEffect(() => {
|
|
178
|
+
if (!running) {
|
|
179
|
+
return;
|
|
180
|
+
}
|
|
181
|
+
const timer = window.setInterval(() => {
|
|
182
|
+
void loadRef.current(true);
|
|
183
|
+
}, LIVE_REFRESH_MS);
|
|
184
|
+
|
|
185
|
+
return () => window.clearInterval(timer);
|
|
186
|
+
}, [running]);
|
|
123
187
|
|
|
124
188
|
const setState = (next: QueueJobState): void => {
|
|
125
189
|
setStateValue(next);
|
|
@@ -128,8 +192,8 @@ export function useQueueJobs(): UseQueueJobsResult {
|
|
|
128
192
|
setPage(0);
|
|
129
193
|
};
|
|
130
194
|
|
|
131
|
-
/**
|
|
132
|
-
const setQueue = (next:
|
|
195
|
+
/** Estreitar para uma fila é trocar de conjunto: a página volta ao começo. */
|
|
196
|
+
const setQueue = (next: QueueScope): void => {
|
|
133
197
|
setQueueValue(next);
|
|
134
198
|
setPage(0);
|
|
135
199
|
};
|
|
@@ -140,12 +204,30 @@ export function useQueueJobs(): UseQueueJobsResult {
|
|
|
140
204
|
*/
|
|
141
205
|
const act = async (action: Promise<void>): Promise<void> => {
|
|
142
206
|
await action;
|
|
143
|
-
await
|
|
207
|
+
await load();
|
|
144
208
|
};
|
|
145
209
|
|
|
210
|
+
/*
|
|
211
|
+
Quantos jobs do estado atual cada fila tem.
|
|
212
|
+
|
|
213
|
+
O total do escopo entra como a contagem de "Todas" para o número ao lado de
|
|
214
|
+
cada opção sair do mesmo lugar — somar as filas aqui daria o mesmo valor
|
|
215
|
+
duas vezes, calculado de dois jeitos.
|
|
216
|
+
*/
|
|
217
|
+
const countsByQueue = {
|
|
218
|
+
[ALL_QUEUES]: counts[state],
|
|
219
|
+
...Object.fromEntries(
|
|
220
|
+
Object.values(QueueName).map((name) => [
|
|
221
|
+
name,
|
|
222
|
+
counts.byQueue.find((row) => row.queue === name)?.counts[state] ?? 0,
|
|
223
|
+
]),
|
|
224
|
+
),
|
|
225
|
+
} as Record<QueueScope, number>;
|
|
226
|
+
|
|
146
227
|
return {
|
|
147
228
|
rows,
|
|
148
229
|
counts,
|
|
230
|
+
countsByQueue,
|
|
149
231
|
total,
|
|
150
232
|
page,
|
|
151
233
|
pageSize,
|
|
@@ -158,8 +240,8 @@ export function useQueueJobs(): UseQueueJobsResult {
|
|
|
158
240
|
setSorting,
|
|
159
241
|
setState,
|
|
160
242
|
setQueue,
|
|
161
|
-
refresh:
|
|
162
|
-
retry: (
|
|
163
|
-
remove: (
|
|
243
|
+
refresh: () => load(),
|
|
244
|
+
retry: (job) => act(queuesService.retry(job.id, job.queue)),
|
|
245
|
+
remove: (job) => act(queuesService.remove(job.id, job.queue)),
|
|
164
246
|
};
|
|
165
247
|
}
|
|
@@ -6,30 +6,32 @@ import type {
|
|
|
6
6
|
PaginationState,
|
|
7
7
|
SortingState,
|
|
8
8
|
} from "@tanstack/react-table";
|
|
9
|
-
import { RotateCcw, Trash2 } from "lucide-react";
|
|
10
9
|
import type { JSX } from "react";
|
|
11
10
|
import { useMemo } from "react";
|
|
12
11
|
|
|
12
|
+
import { formatDateTime, formatDuration } from "#core/_utils/format";
|
|
13
13
|
import {
|
|
14
14
|
Badge,
|
|
15
|
-
Button,
|
|
16
15
|
Card,
|
|
17
16
|
DataTable,
|
|
18
17
|
DataTableFeatures,
|
|
19
|
-
RowActions,
|
|
20
18
|
SegmentedControl,
|
|
21
19
|
SegmentedOption,
|
|
22
20
|
} from "#core/components/ui";
|
|
23
21
|
import { useAuth, useI18n } from "#core/contexts";
|
|
22
|
+
import { QueueJobDetail } from "#core/features/queues/components";
|
|
24
23
|
import { useQueueJobs } from "#core/features/queues/hooks/use-queue-jobs";
|
|
25
24
|
import {
|
|
25
|
+
ALL_QUEUES,
|
|
26
26
|
QueueJob,
|
|
27
27
|
QueueJobState,
|
|
28
28
|
QueueName,
|
|
29
|
+
QueueScope,
|
|
29
30
|
} from "#core/features/queues/services/queues.service";
|
|
30
31
|
import { useConfirm } from "#core/hooks/use-confirm";
|
|
32
|
+
import { cn } from "#core/lib/utils";
|
|
31
33
|
|
|
32
|
-
/**
|
|
34
|
+
/** Chave de tradução de cada estado e de cada fila — o texto mora no i18n (§6). */
|
|
33
35
|
const STATE_LABEL: Record<QueueJobState, string> = {
|
|
34
36
|
[QueueJobState.WAITING]: "queues.stateWaiting",
|
|
35
37
|
[QueueJobState.ACTIVE]: "queues.stateActive",
|
|
@@ -38,10 +40,18 @@ const STATE_LABEL: Record<QueueJobState, string> = {
|
|
|
38
40
|
[QueueJobState.DELAYED]: "queues.stateDelayed",
|
|
39
41
|
};
|
|
40
42
|
|
|
41
|
-
|
|
42
|
-
|
|
43
|
+
const QUEUE_LABEL: Record<QueueScope, string> = {
|
|
44
|
+
[ALL_QUEUES]: "queues.queueAll",
|
|
43
45
|
[QueueName.JOBS]: "queues.queueJobs",
|
|
44
46
|
[QueueName.EXPORTS]: "queues.queueExports",
|
|
47
|
+
[QueueName.IMAGES]: "queues.queueImages",
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
/** A cor do ponto de cada fila — o que separa as três de relance na coluna. */
|
|
51
|
+
const QUEUE_DOT: Record<QueueName, string> = {
|
|
52
|
+
[QueueName.JOBS]: "bg-muted-foreground",
|
|
53
|
+
[QueueName.EXPORTS]: "bg-violet-500",
|
|
54
|
+
[QueueName.IMAGES]: "bg-cyan-500",
|
|
45
55
|
};
|
|
46
56
|
|
|
47
57
|
const STATE_VARIANT: Record<
|
|
@@ -55,6 +65,9 @@ const STATE_VARIANT: Record<
|
|
|
55
65
|
[QueueJobState.DELAYED]: "secondary",
|
|
56
66
|
};
|
|
57
67
|
|
|
68
|
+
/** As opções do seletor de fila, com "todas" na frente — é o padrão da tela. */
|
|
69
|
+
const QUEUE_SCOPES: QueueScope[] = [ALL_QUEUES, ...Object.values(QueueName)];
|
|
70
|
+
|
|
58
71
|
/**
|
|
59
72
|
* Fila de processamento — o que está rodando, o que falhou e o que fazer com
|
|
60
73
|
* isso.
|
|
@@ -62,42 +75,46 @@ const STATE_VARIANT: Record<
|
|
|
62
75
|
* Substitui o painel do bull-board: aqui a tela mora no mesmo shell, com a
|
|
63
76
|
* mesma permissão e a mesma tradução do resto do sistema, em vez de um segundo
|
|
64
77
|
* frontend embutido no backend com autenticação própria.
|
|
78
|
+
*
|
|
79
|
+
* A fila **não** é um modo desta tela. São três filas e cinco estados, e escolher
|
|
80
|
+
* uma de cada vez fazia a tela abrir na fila que o projeto talvez nem use — com
|
|
81
|
+
* o upload e o relatório de todo mundo do outro lado de um botão que ninguém
|
|
82
|
+
* sabia que precisava apertar. Aqui ela abre em todas, e o número ao lado de cada
|
|
83
|
+
* uma diz onde está o problema antes de alguém clicar.
|
|
65
84
|
*/
|
|
66
85
|
export function QueuesScreen(): JSX.Element {
|
|
67
|
-
const { t } = useI18n();
|
|
86
|
+
const { t, locale } = useI18n();
|
|
68
87
|
const { hasPermission } = useAuth();
|
|
69
88
|
const { confirm, confirmDialog } = useConfirm();
|
|
70
89
|
const queue = useQueueJobs();
|
|
71
90
|
|
|
72
91
|
const canManage = hasPermission("queues:manage:any");
|
|
73
92
|
|
|
74
|
-
const stateOptions = useMemo<SegmentedOption[]>(
|
|
75
|
-
() =>
|
|
76
|
-
Object.values(QueueJobState).map((value) => ({
|
|
77
|
-
value,
|
|
78
|
-
label: t(STATE_LABEL[value]),
|
|
79
|
-
})),
|
|
80
|
-
[t],
|
|
81
|
-
);
|
|
82
|
-
|
|
83
93
|
const queueOptions = useMemo<SegmentedOption[]>(
|
|
84
94
|
() =>
|
|
85
|
-
|
|
95
|
+
QUEUE_SCOPES.map((value) => ({
|
|
86
96
|
value,
|
|
87
97
|
label: t(QUEUE_LABEL[value]),
|
|
98
|
+
count: queue.countsByQueue[value],
|
|
99
|
+
// Vermelho só onde o número é o problema: em "concluídos" ele é a
|
|
100
|
+
// notícia boa, e pintá-lo de vermelho ensinaria a ignorar a cor.
|
|
101
|
+
countTone:
|
|
102
|
+
queue.state === QueueJobState.FAILED
|
|
103
|
+
? ("danger" as const)
|
|
104
|
+
: ("neutral" as const),
|
|
88
105
|
})),
|
|
89
|
-
[t],
|
|
106
|
+
[t, queue.countsByQueue, queue.state],
|
|
90
107
|
);
|
|
91
108
|
|
|
92
109
|
/** Remover é sem volta: some da fila e não há de onde reprocessar depois. */
|
|
93
|
-
const removeJob = async (
|
|
110
|
+
const removeJob = async (job: QueueJob): Promise<void> => {
|
|
94
111
|
const confirmed = await confirm({
|
|
95
112
|
title: t("queues.removeTitle"),
|
|
96
113
|
description: t("queues.removeDescription"),
|
|
97
114
|
destructive: true,
|
|
98
115
|
});
|
|
99
116
|
if (confirmed) {
|
|
100
|
-
await queue.remove(
|
|
117
|
+
await queue.remove(job);
|
|
101
118
|
}
|
|
102
119
|
};
|
|
103
120
|
|
|
@@ -116,10 +133,31 @@ export function QueuesScreen(): JSX.Element {
|
|
|
116
133
|
</div>
|
|
117
134
|
),
|
|
118
135
|
},
|
|
136
|
+
{
|
|
137
|
+
accessorKey: "queue",
|
|
138
|
+
header: t("queues.queue"),
|
|
139
|
+
meta: { label: t("queues.queue") },
|
|
140
|
+
// O ponto colorido, e não só o nome: com as três filas na mesma lista,
|
|
141
|
+
// é ele que deixa "isto é upload" legível sem ler.
|
|
142
|
+
cell: ({ row }) => (
|
|
143
|
+
<span className="inline-flex items-center gap-2 whitespace-nowrap">
|
|
144
|
+
<span
|
|
145
|
+
className={cn(
|
|
146
|
+
"h-1.5 w-1.5 flex-none rounded-full",
|
|
147
|
+
QUEUE_DOT[row.original.queue],
|
|
148
|
+
)}
|
|
149
|
+
/>
|
|
150
|
+
{t(QUEUE_LABEL[row.original.queue])}
|
|
151
|
+
</span>
|
|
152
|
+
),
|
|
153
|
+
},
|
|
119
154
|
{
|
|
120
155
|
accessorKey: "state",
|
|
121
156
|
header: t("queues.state"),
|
|
122
157
|
meta: { label: t("queues.state") },
|
|
158
|
+
// Sem seta: `state` não está no `SORTABLE_JOB_FIELDS` do backend, e a
|
|
159
|
+
// listagem já é de um estado só — ordenar por ele não mudaria nada.
|
|
160
|
+
enableSorting: false,
|
|
123
161
|
cell: ({ row }) => {
|
|
124
162
|
const state = row.original.state as QueueJobState;
|
|
125
163
|
return (
|
|
@@ -133,100 +171,82 @@ export function QueuesScreen(): JSX.Element {
|
|
|
133
171
|
accessorKey: "percent",
|
|
134
172
|
header: t("queues.progress"),
|
|
135
173
|
meta: { label: t("queues.progress") },
|
|
136
|
-
cell: ({
|
|
137
|
-
const percent
|
|
138
|
-
|
|
174
|
+
cell: ({ row }) => {
|
|
175
|
+
const { percent, state } = row.original;
|
|
176
|
+
if (percent === null) {
|
|
177
|
+
return <span className="text-muted-foreground">—</span>;
|
|
178
|
+
}
|
|
179
|
+
return (
|
|
180
|
+
<div className="flex items-center gap-2">
|
|
181
|
+
<span
|
|
182
|
+
role="img"
|
|
183
|
+
aria-label={`${percent}%`}
|
|
184
|
+
className="h-1.5 w-20 flex-none overflow-hidden rounded-full bg-muted"
|
|
185
|
+
>
|
|
186
|
+
<span
|
|
187
|
+
className={cn(
|
|
188
|
+
"block h-full rounded-full",
|
|
189
|
+
state === QueueJobState.COMPLETED
|
|
190
|
+
? "bg-success"
|
|
191
|
+
: "bg-warning",
|
|
192
|
+
)}
|
|
193
|
+
style={{ width: `${percent}%` }}
|
|
194
|
+
/>
|
|
195
|
+
</span>
|
|
196
|
+
<span className="text-xs tabular-nums text-muted-foreground">
|
|
197
|
+
{percent}%
|
|
198
|
+
</span>
|
|
199
|
+
</div>
|
|
200
|
+
);
|
|
139
201
|
},
|
|
140
202
|
},
|
|
141
203
|
{
|
|
142
204
|
accessorKey: "attemptsMade",
|
|
143
205
|
header: t("queues.attempts"),
|
|
144
206
|
meta: { label: t("queues.attempts") },
|
|
207
|
+
// "2" sozinho não diz se ainda vai tentar; com o teto ao lado, diz.
|
|
208
|
+
cell: ({ row }) => (
|
|
209
|
+
<span className="tabular-nums text-muted-foreground">
|
|
210
|
+
{row.original.attemptsMade}/{row.original.maxAttempts}
|
|
211
|
+
</span>
|
|
212
|
+
),
|
|
145
213
|
},
|
|
146
214
|
{
|
|
147
215
|
accessorKey: "createdAt",
|
|
148
216
|
header: t("queues.createdAt"),
|
|
149
|
-
meta: { label: t("queues.createdAt") },
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
enableSorting: false,
|
|
156
|
-
// O conteúdo do job, cru. É o que responde *qual* pedido falhou, e não
|
|
157
|
-
// só *que* falhou — sem ele, um relatório que quebrou não diz qual
|
|
158
|
-
// recorte quebrou. Sai só aqui porque esta tela é de monitoramento do
|
|
159
|
-
// administrador, onde ver tudo é a função.
|
|
160
|
-
meta: { label: t("queues.payload") },
|
|
161
|
-
cell: ({ getValue }) => {
|
|
162
|
-
const payload = getValue();
|
|
163
|
-
if (payload == null) {
|
|
164
|
-
return "—";
|
|
165
|
-
}
|
|
166
|
-
|
|
167
|
-
const text = JSON.stringify(payload);
|
|
217
|
+
meta: { label: t("queues.createdAt"), wrap: true },
|
|
218
|
+
// A duração embaixo da data: numa lista de falhas, "quebrou depois de
|
|
219
|
+
// 3 minutos" e "quebrou em 4ms" são problemas diferentes, e descobrir
|
|
220
|
+
// qual é exigiria abrir linha por linha.
|
|
221
|
+
cell: ({ row }) => {
|
|
222
|
+
const duration = formatDuration(row.original.startedAt, row.original.finishedAt);
|
|
168
223
|
return (
|
|
169
|
-
<
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
</
|
|
224
|
+
<div className="flex flex-col whitespace-nowrap tabular-nums">
|
|
225
|
+
<span>{formatDateTime(row.original.createdAt, locale)}</span>
|
|
226
|
+
{duration && (
|
|
227
|
+
<span className="text-xs text-muted-foreground">{duration}</span>
|
|
228
|
+
)}
|
|
229
|
+
</div>
|
|
175
230
|
);
|
|
176
231
|
},
|
|
177
232
|
},
|
|
178
233
|
{
|
|
179
234
|
accessorKey: "failedReason",
|
|
180
235
|
header: t("queues.reason"),
|
|
181
|
-
meta: { label: t("queues.reason") },
|
|
182
|
-
//
|
|
183
|
-
//
|
|
236
|
+
meta: { label: t("queues.reason"), maxWidth: "24rem" },
|
|
237
|
+
// Cortado aqui e inteiro no detalhe da linha: numa tabela, a célula que
|
|
238
|
+
// cresce para caber um stacktrace desloca todas as vizinhas.
|
|
184
239
|
cell: ({ getValue }) => {
|
|
185
240
|
const reason = getValue() as string | null;
|
|
186
241
|
return reason ? (
|
|
187
|
-
<span
|
|
188
|
-
className="line-clamp-2 max-w-md font-mono text-xs"
|
|
189
|
-
title={reason}
|
|
190
|
-
>
|
|
191
|
-
{reason}
|
|
192
|
-
</span>
|
|
242
|
+
<span className="text-xs text-destructive">{reason}</span>
|
|
193
243
|
) : (
|
|
194
|
-
"
|
|
244
|
+
<span className="text-muted-foreground">—</span>
|
|
195
245
|
);
|
|
196
246
|
},
|
|
197
247
|
},
|
|
198
|
-
{
|
|
199
|
-
id: "actions",
|
|
200
|
-
header: () => <div className="text-center">{t("common.actions")}</div>,
|
|
201
|
-
enableSorting: false,
|
|
202
|
-
meta: { shrink: true },
|
|
203
|
-
enableHiding: false,
|
|
204
|
-
cell: ({ row }) =>
|
|
205
|
-
canManage ? (
|
|
206
|
-
<RowActions>
|
|
207
|
-
<Button
|
|
208
|
-
variant="ghost"
|
|
209
|
-
size="sm"
|
|
210
|
-
onClick={() => void queue.retry(row.original.id)}
|
|
211
|
-
>
|
|
212
|
-
<RotateCcw className="h-4 w-4" />
|
|
213
|
-
{t("queues.retry")}
|
|
214
|
-
</Button>
|
|
215
|
-
<Button
|
|
216
|
-
variant="ghost"
|
|
217
|
-
size="sm"
|
|
218
|
-
className="text-destructive"
|
|
219
|
-
onClick={() => void removeJob(row.original.id)}
|
|
220
|
-
>
|
|
221
|
-
<Trash2 className="h-4 w-4" />
|
|
222
|
-
{t("common.delete")}
|
|
223
|
-
</Button>
|
|
224
|
-
</RowActions>
|
|
225
|
-
) : null,
|
|
226
|
-
},
|
|
227
248
|
],
|
|
228
|
-
|
|
229
|
-
[t, canManage, queue],
|
|
249
|
+
[t, locale],
|
|
230
250
|
);
|
|
231
251
|
|
|
232
252
|
const pagination: PaginationState = {
|
|
@@ -283,7 +303,9 @@ export function QueuesScreen(): JSX.Element {
|
|
|
283
303
|
<DataTable
|
|
284
304
|
columns={columns}
|
|
285
305
|
data={queue.rows}
|
|
286
|
-
|
|
306
|
+
// A fila entra no id: as três numeram os jobs por conta própria, e o
|
|
307
|
+
// `13` de `exports` e o de `images` seriam a mesma linha para a tabela.
|
|
308
|
+
getRowId={(r) => `${r.queue}:${r.id}`}
|
|
287
309
|
storageKey="queues-jobs"
|
|
288
310
|
loading={queue.loading}
|
|
289
311
|
manualPagination
|
|
@@ -294,21 +316,21 @@ export function QueuesScreen(): JSX.Element {
|
|
|
294
316
|
sorting={queue.sorting}
|
|
295
317
|
onSortingChange={onSortingChange}
|
|
296
318
|
emptyMessage={t("queues.empty")}
|
|
319
|
+
renderRowDetail={(job) => (
|
|
320
|
+
<QueueJobDetail
|
|
321
|
+
job={job}
|
|
322
|
+
canManage={canManage}
|
|
323
|
+
onRetry={(target) => void queue.retry(target)}
|
|
324
|
+
onRemove={(target) => void removeJob(target)}
|
|
325
|
+
/>
|
|
326
|
+
)}
|
|
297
327
|
toolbar={
|
|
298
|
-
<
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
/>
|
|
305
|
-
<SegmentedControl
|
|
306
|
-
value={queue.state}
|
|
307
|
-
onValueChange={(value) => queue.setState(value as QueueJobState)}
|
|
308
|
-
options={stateOptions}
|
|
309
|
-
label={t("queues.state")}
|
|
310
|
-
/>
|
|
311
|
-
</div>
|
|
328
|
+
<SegmentedControl
|
|
329
|
+
value={queue.queue}
|
|
330
|
+
onValueChange={(value) => queue.setQueue(value as QueueScope)}
|
|
331
|
+
options={queueOptions}
|
|
332
|
+
label={t("queues.queue")}
|
|
333
|
+
/>
|
|
312
334
|
}
|
|
313
335
|
/>
|
|
314
336
|
|