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