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.
@@ -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 EMPTY_COUNTS: QueueCounts = {
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
- counts: QueueCounts;
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: QueueName;
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: QueueName) => void;
60
+ setQueue: (queue: QueueScope) => void;
40
61
  refresh: () => Promise<void>;
41
- retry: (id: string) => Promise<void>;
42
- remove: (id: string) => Promise<void>;
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` de propósito: quem entra aqui está atrás do que deu errado.
49
- * O que está andando bem não precisa de tela.
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
- QUEUE_NAMES,
75
- QueueName.JOBS,
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
- const fetch = useCallback(async (): Promise<void> => {
83
- // `latest` descarta a resposta que outra busca já passou para trás —
84
- // trocar de estado e de página em sequência devolve as duas fora de ordem.
85
- const result = await latest(() =>
86
- Promise.all([
87
- // O `page` da tabela é base zero; o da API, base um.
88
- run(() =>
89
- queuesService.list({
90
- state,
91
- queue,
92
- page: page + 1,
93
- limit: pageSize,
94
- sortBy: sorting[0]?.id,
95
- sortDir: sorting[0]
96
- ? sorting[0].desc
97
- ? "DESC"
98
- : "ASC"
99
- : undefined,
100
- }),
101
- ),
102
- run(() => queuesService.counts(queue)),
103
- ]),
104
- );
105
- if (!result) {
106
- return;
107
- }
108
- const [list, summary] = result;
109
- if (list) {
110
- setRows(list.items);
111
- setTotal(list.meta.total);
112
- }
113
- if (summary) {
114
- setCounts(summary);
115
- }
116
- }, [latest, run, state, queue, page, pageSize, sorting]);
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 fetch();
157
+ await load();
121
158
  })();
122
- }, [fetch]);
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
- /** Trocar de fila é trocar de conjunto inteiro: página e contadores vão junto. */
132
- const setQueue = (next: QueueName): void => {
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 fetch();
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: fetch,
162
- retry: (id) => act(queuesService.retry(id, queue)),
163
- remove: (id) => act(queuesService.remove(id, queue)),
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
- /** Rótulo de cada estado — a mesma chave serve à barra e aos cartões. */
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
- /** Rótulo de cada fila. A geral e a de relatórios rodam em processos diferentes. */
42
- const QUEUE_LABEL: Record<QueueName, string> = {
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
- Object.values(QueueName).map((value) => ({
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 (id: string): Promise<void> => {
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(id);
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: ({ getValue }) => {
137
- const percent = getValue() as number | null;
138
- return percent == null ? "—" : `${percent}%`;
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
- cell: ({ getValue }) => new Date(String(getValue())).toLocaleString(),
151
- },
152
- {
153
- accessorKey: "payload",
154
- header: t("queues.payload"),
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
- <span
170
- className="line-clamp-2 max-w-md font-mono text-xs"
171
- title={text}
172
- >
173
- {text}
174
- </span>
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
- // Cru e por inteiro no `title`: quem lê esta tela é quem vai consertar
183
- // a causa, e a mensagem truncada some justamente na parte útil.
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
- // eslint-disable-next-line react-hooks/exhaustive-deps
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
- getRowId={(r) => r.id}
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
- <div className="flex flex-wrap items-center gap-3">
299
- <SegmentedControl
300
- value={queue.queue}
301
- onValueChange={(value) => queue.setQueue(value as QueueName)}
302
- options={queueOptions}
303
- label={t("queues.queue")}
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