@seedgrid/fe-backup 2026.7.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 +86 -0
- package/dist/backup.d.ts +47 -0
- package/dist/backup.d.ts.map +1 -0
- package/dist/backup.js +3 -0
- package/dist/export-status.d.ts +6 -0
- package/dist/export-status.d.ts.map +1 -0
- package/dist/export-status.js +28 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +6 -0
- package/dist/messages.d.ts +308 -0
- package/dist/messages.d.ts.map +1 -0
- package/dist/messages.js +202 -0
- package/dist/module.d.ts +3 -0
- package/dist/module.d.ts.map +1 -0
- package/dist/module.js +23 -0
- package/dist/module.json +15 -0
- package/dist/paths.d.ts +9 -0
- package/dist/paths.d.ts.map +1 -0
- package/dist/paths.js +11 -0
- package/dist/server/create-backup-server.d.ts +19 -0
- package/dist/server/create-backup-server.d.ts.map +1 -0
- package/dist/server/create-backup-server.js +19 -0
- package/dist/server/index.d.ts +2 -0
- package/dist/server/index.d.ts.map +1 -0
- package/dist/server/index.js +2 -0
- package/package.json +43 -0
- package/scaffold/README.md +69 -0
- package/scaffold/src/app/api/backup/exports/[jobId]/download/route.ts +87 -0
- package/scaffold/src/app/backup/exports/(create)/new/page.test.tsx +182 -0
- package/scaffold/src/app/backup/exports/(create)/new/page.tsx +168 -0
- package/scaffold/src/app/backup/exports/(list)/page.test.tsx +166 -0
- package/scaffold/src/app/backup/exports/(list)/page.tsx +286 -0
- package/scaffold/src/modules/backup/components/ExportStatusBadge.test.tsx +75 -0
- package/scaffold/src/modules/backup/components/ExportStatusBadge.tsx +54 -0
- package/scaffold/src/modules/backup/config.ts +27 -0
- package/scaffold/src/modules/backup/index.ts +19 -0
- package/scaffold/src/modules/backup/services/backup-api.test.ts +122 -0
- package/scaffold/src/modules/backup/services/backup-api.ts +205 -0
- package/scaffold/src/modules/backup/services/backup-service.test.ts +125 -0
- package/scaffold/src/modules/backup/services/backup-service.ts +34 -0
- package/scaffold/src/modules/backup/types.ts +17 -0
- package/scaffold/src/modules/backup/useBackupAppShellSections.test.tsx +61 -0
- package/scaffold/src/modules/backup/useBackupAppShellSections.tsx +50 -0
|
@@ -0,0 +1,286 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
4
|
+
|
|
5
|
+
import {
|
|
6
|
+
SgButton,
|
|
7
|
+
SgConfirmationDialog,
|
|
8
|
+
SgDatatable,
|
|
9
|
+
SgStack,
|
|
10
|
+
sgWhistle,
|
|
11
|
+
} from "@seedgrid/fe-components";
|
|
12
|
+
import { Download, Plus, Trash2 } from "lucide-react";
|
|
13
|
+
import { useRouter } from "next/navigation";
|
|
14
|
+
|
|
15
|
+
import { useI18n } from "@/i18n";
|
|
16
|
+
import { PageFrame, RouteGuard } from "@/modules/security";
|
|
17
|
+
|
|
18
|
+
import { backupConfig } from "@/modules/backup/config";
|
|
19
|
+
import { ExportStatusBadge } from "@/modules/backup/components/ExportStatusBadge";
|
|
20
|
+
import { extractBackupApiMessage } from "@/modules/backup/services/backup-api";
|
|
21
|
+
import { backupService } from "@/modules/backup/services/backup-service";
|
|
22
|
+
import { isExportDownloadable, isExportInProgress } from "@seedgrid/fe-backup";
|
|
23
|
+
import type { ExportResponse } from "@/modules/backup/types";
|
|
24
|
+
|
|
25
|
+
export default function ExportsListPage() {
|
|
26
|
+
return (
|
|
27
|
+
<RouteGuard permissions={["JOB_READ"]}>
|
|
28
|
+
<ExportsListScreen />
|
|
29
|
+
</RouteGuard>
|
|
30
|
+
);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function ExportsListScreen() {
|
|
34
|
+
const router = useRouter();
|
|
35
|
+
const { t } = useI18n();
|
|
36
|
+
|
|
37
|
+
const [exports, setExports] = useState<ExportResponse[]>([]);
|
|
38
|
+
const [page, setPage] = useState(0);
|
|
39
|
+
// <number>: defaultPageSize e literal, sem anotacao o setRows(number)
|
|
40
|
+
// falharia (TS2345 SetStateAction<literal>).
|
|
41
|
+
const [rows, setRows] = useState<number>(backupConfig.pagination.defaultPageSize);
|
|
42
|
+
const [loading, setLoading] = useState(true);
|
|
43
|
+
const [selectedJob, setSelectedJob] = useState<ExportResponse | null>(null);
|
|
44
|
+
const [downloadingId, setDownloadingId] = useState<string | null>(null);
|
|
45
|
+
|
|
46
|
+
const pollingRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
|
47
|
+
|
|
48
|
+
const loadExports = useCallback(
|
|
49
|
+
async (silent = false) => {
|
|
50
|
+
if (!silent) setLoading(true);
|
|
51
|
+
|
|
52
|
+
try {
|
|
53
|
+
const data = await backupService.listExportJobs({ page, size: rows });
|
|
54
|
+
setExports(data);
|
|
55
|
+
} catch (error) {
|
|
56
|
+
if (!silent) {
|
|
57
|
+
sgWhistle.error({
|
|
58
|
+
message:
|
|
59
|
+
extractBackupApiMessage(error) ??
|
|
60
|
+
t("backup.exports.list.load_error"),
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
} finally {
|
|
64
|
+
if (!silent) setLoading(false);
|
|
65
|
+
}
|
|
66
|
+
},
|
|
67
|
+
[page, rows, t]
|
|
68
|
+
);
|
|
69
|
+
|
|
70
|
+
useEffect(() => {
|
|
71
|
+
void loadExports();
|
|
72
|
+
}, [loadExports]);
|
|
73
|
+
|
|
74
|
+
// Poll while any job is in progress
|
|
75
|
+
useEffect(() => {
|
|
76
|
+
const hasInProgress = exports.some((e) => isExportInProgress(e.status));
|
|
77
|
+
|
|
78
|
+
if (hasInProgress) {
|
|
79
|
+
pollingRef.current = setInterval(() => {
|
|
80
|
+
void loadExports(true);
|
|
81
|
+
}, backupConfig.polling.intervalMs);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
return () => {
|
|
85
|
+
if (pollingRef.current) {
|
|
86
|
+
clearInterval(pollingRef.current);
|
|
87
|
+
pollingRef.current = null;
|
|
88
|
+
}
|
|
89
|
+
};
|
|
90
|
+
}, [exports, loadExports]);
|
|
91
|
+
|
|
92
|
+
async function handleDownload(job: ExportResponse) {
|
|
93
|
+
setDownloadingId(job.jobId);
|
|
94
|
+
|
|
95
|
+
try {
|
|
96
|
+
const downloadUrl = `/api/backup/exports/${encodeURIComponent(job.jobId)}/download`;
|
|
97
|
+
const anchor = document.createElement("a");
|
|
98
|
+
anchor.href = downloadUrl;
|
|
99
|
+
anchor.download = "";
|
|
100
|
+
document.body.appendChild(anchor);
|
|
101
|
+
anchor.click();
|
|
102
|
+
document.body.removeChild(anchor);
|
|
103
|
+
} catch (error) {
|
|
104
|
+
sgWhistle.error({
|
|
105
|
+
message:
|
|
106
|
+
extractBackupApiMessage(error) ??
|
|
107
|
+
t("backup.exports.list.download_error"),
|
|
108
|
+
});
|
|
109
|
+
} finally {
|
|
110
|
+
setDownloadingId(null);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
async function handleDelete() {
|
|
115
|
+
if (!selectedJob) return;
|
|
116
|
+
|
|
117
|
+
try {
|
|
118
|
+
await backupService.deleteExportJob(selectedJob.jobId);
|
|
119
|
+
sgWhistle.success({ message: t("backup.exports.list.delete_success") });
|
|
120
|
+
setSelectedJob(null);
|
|
121
|
+
await loadExports();
|
|
122
|
+
} catch (error) {
|
|
123
|
+
sgWhistle.error({
|
|
124
|
+
message:
|
|
125
|
+
extractBackupApiMessage(error) ??
|
|
126
|
+
t("backup.exports.list.delete_error"),
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function formatBytes(bytes?: number | null) {
|
|
132
|
+
if (bytes == null) return "-";
|
|
133
|
+
if (bytes < 1024) return `${bytes} B`;
|
|
134
|
+
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
|
135
|
+
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function formatDate(dateStr?: string) {
|
|
139
|
+
if (!dateStr) return "-";
|
|
140
|
+
try {
|
|
141
|
+
return new Intl.DateTimeFormat(undefined, {
|
|
142
|
+
dateStyle: "short",
|
|
143
|
+
timeStyle: "short",
|
|
144
|
+
}).format(new Date(dateStr));
|
|
145
|
+
} catch {
|
|
146
|
+
return dateStr;
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const columns = useMemo(
|
|
151
|
+
() => [
|
|
152
|
+
{
|
|
153
|
+
field: "jobId",
|
|
154
|
+
header: t("backup.exports.list.column.job_id"),
|
|
155
|
+
body: (row: ExportResponse) => (
|
|
156
|
+
<span className="font-mono text-xs text-slate-500">
|
|
157
|
+
{row.jobId.slice(0, 8)}…
|
|
158
|
+
</span>
|
|
159
|
+
),
|
|
160
|
+
},
|
|
161
|
+
{
|
|
162
|
+
field: "format",
|
|
163
|
+
header: t("backup.exports.list.column.format"),
|
|
164
|
+
body: (row: ExportResponse) =>
|
|
165
|
+
t(`backup.exports.format.${row.format}`),
|
|
166
|
+
},
|
|
167
|
+
{
|
|
168
|
+
field: "scope",
|
|
169
|
+
header: t("backup.exports.list.column.scope"),
|
|
170
|
+
body: (row: ExportResponse) =>
|
|
171
|
+
t(`backup.exports.scope.${row.scope}`),
|
|
172
|
+
},
|
|
173
|
+
{
|
|
174
|
+
field: "status",
|
|
175
|
+
header: t("backup.exports.list.column.status"),
|
|
176
|
+
body: (row: ExportResponse) => (
|
|
177
|
+
<SgStack direction="row" gap={8} align="center">
|
|
178
|
+
<ExportStatusBadge status={row.status} />
|
|
179
|
+
{isExportInProgress(row.status) && row.progress != null ? (
|
|
180
|
+
<span className="text-xs text-slate-500">{row.progress}%</span>
|
|
181
|
+
) : null}
|
|
182
|
+
</SgStack>
|
|
183
|
+
),
|
|
184
|
+
},
|
|
185
|
+
{
|
|
186
|
+
field: "createdAt",
|
|
187
|
+
header: t("backup.exports.list.column.created_at"),
|
|
188
|
+
body: (row: ExportResponse) => formatDate(row.createdAt),
|
|
189
|
+
},
|
|
190
|
+
{
|
|
191
|
+
field: "totalSizeBytes",
|
|
192
|
+
header: t("backup.exports.list.column.size"),
|
|
193
|
+
body: (row: ExportResponse) => formatBytes(row.totalSizeBytes),
|
|
194
|
+
},
|
|
195
|
+
{
|
|
196
|
+
header: t("backup.common.actions"),
|
|
197
|
+
body: (row: ExportResponse) => (
|
|
198
|
+
<SgStack direction="row" gap={8} wrap data-sg-stop-row-select="true">
|
|
199
|
+
{isExportDownloadable(row.status) ? (
|
|
200
|
+
<SgButton
|
|
201
|
+
appearance="ghost"
|
|
202
|
+
severity="success"
|
|
203
|
+
iconOnly
|
|
204
|
+
leftIcon={<Download size={16} />}
|
|
205
|
+
title={t("backup.exports.list.action.download")}
|
|
206
|
+
aria-label={t("backup.exports.list.action.download")}
|
|
207
|
+
loading={downloadingId === row.jobId}
|
|
208
|
+
onClick={() => void handleDownload(row)}
|
|
209
|
+
className="border border-emerald-200/80 bg-emerald-50/45 text-emerald-700 hover:border-emerald-300 hover:bg-emerald-50 hover:text-emerald-800"
|
|
210
|
+
/>
|
|
211
|
+
) : null}
|
|
212
|
+
<SgButton
|
|
213
|
+
appearance="ghost"
|
|
214
|
+
severity="danger"
|
|
215
|
+
iconOnly
|
|
216
|
+
leftIcon={<Trash2 size={16} />}
|
|
217
|
+
title={
|
|
218
|
+
isExportInProgress(row.status)
|
|
219
|
+
? t("backup.exports.list.action.cancel")
|
|
220
|
+
: t("backup.exports.list.action.delete")
|
|
221
|
+
}
|
|
222
|
+
aria-label={t("backup.exports.list.action.delete")}
|
|
223
|
+
onClick={() => setSelectedJob(row)}
|
|
224
|
+
className="border border-rose-200/80 bg-rose-50/45 text-rose-600 hover:border-rose-200 hover:bg-rose-50/70 hover:text-rose-700"
|
|
225
|
+
/>
|
|
226
|
+
</SgStack>
|
|
227
|
+
),
|
|
228
|
+
},
|
|
229
|
+
],
|
|
230
|
+
[t, downloadingId]
|
|
231
|
+
);
|
|
232
|
+
|
|
233
|
+
return (
|
|
234
|
+
<PageFrame
|
|
235
|
+
title={t("backup.exports.list.title")}
|
|
236
|
+
description={t("backup.exports.list.description")}
|
|
237
|
+
actions={
|
|
238
|
+
<SgStack direction="row" gap={8}>
|
|
239
|
+
<SgButton
|
|
240
|
+
appearance="ghost"
|
|
241
|
+
severity="secondary"
|
|
242
|
+
label={t("backup.common.refresh")}
|
|
243
|
+
onClick={() => void loadExports()}
|
|
244
|
+
disabled={loading}
|
|
245
|
+
/>
|
|
246
|
+
<SgButton
|
|
247
|
+
appearance="solid"
|
|
248
|
+
severity="primary"
|
|
249
|
+
shape="rounded"
|
|
250
|
+
leftIcon={<Plus size={16} />}
|
|
251
|
+
label={t("backup.exports.list.new")}
|
|
252
|
+
onClick={() => router.push(backupConfig.routes.newExport)}
|
|
253
|
+
/>
|
|
254
|
+
</SgStack>
|
|
255
|
+
}
|
|
256
|
+
>
|
|
257
|
+
<SgStack gap={16}>
|
|
258
|
+
<SgDatatable<ExportResponse>
|
|
259
|
+
title=" "
|
|
260
|
+
value={exports}
|
|
261
|
+
columns={columns}
|
|
262
|
+
dataKey="jobId"
|
|
263
|
+
lazy
|
|
264
|
+
paginator
|
|
265
|
+
rows={rows}
|
|
266
|
+
first={page * rows}
|
|
267
|
+
loading={loading}
|
|
268
|
+
emptyMessage={t("backup.exports.list.empty")}
|
|
269
|
+
onPage={(event) => {
|
|
270
|
+
setRows(event.rows ?? backupConfig.pagination.defaultPageSize);
|
|
271
|
+
setPage(event.page ?? 0);
|
|
272
|
+
}}
|
|
273
|
+
/>
|
|
274
|
+
</SgStack>
|
|
275
|
+
|
|
276
|
+
<SgConfirmationDialog
|
|
277
|
+
open={Boolean(selectedJob)}
|
|
278
|
+
severity="danger"
|
|
279
|
+
title={t("backup.exports.list.delete_title")}
|
|
280
|
+
message={t("backup.exports.list.delete_message")}
|
|
281
|
+
onCancel={() => setSelectedJob(null)}
|
|
282
|
+
onConfirm={() => void handleDelete()}
|
|
283
|
+
/>
|
|
284
|
+
</PageFrame>
|
|
285
|
+
);
|
|
286
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
// seedgrid:managed
|
|
2
|
+
|
|
3
|
+
import { render, screen } from "@testing-library/react";
|
|
4
|
+
import { describe, expect, it } from "vitest";
|
|
5
|
+
|
|
6
|
+
import { classifyExportStatus, isExportDownloadable, isExportInProgress } from "@seedgrid/fe-backup";
|
|
7
|
+
import type { ExportStatus } from "@seedgrid/fe-backup";
|
|
8
|
+
|
|
9
|
+
describe("classifyExportStatus", () => {
|
|
10
|
+
it("classifies REQUESTED and QUEUED as pending", () => {
|
|
11
|
+
expect(classifyExportStatus("REQUESTED")).toBe("pending");
|
|
12
|
+
expect(classifyExportStatus("QUEUED")).toBe("pending");
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
it("classifies RUNNING as running", () => {
|
|
16
|
+
expect(classifyExportStatus("RUNNING")).toBe("running");
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it("classifies DONE and DONE_NOTIFIED as done", () => {
|
|
20
|
+
expect(classifyExportStatus("DONE")).toBe("done");
|
|
21
|
+
expect(classifyExportStatus("DONE_NOTIFIED")).toBe("done");
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it("classifies FAILED as failed", () => {
|
|
25
|
+
expect(classifyExportStatus("FAILED")).toBe("failed");
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it("classifies EXPIRED and CANCELLED as terminal", () => {
|
|
29
|
+
expect(classifyExportStatus("EXPIRED")).toBe("terminal");
|
|
30
|
+
expect(classifyExportStatus("CANCELLED")).toBe("terminal");
|
|
31
|
+
});
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
describe("isExportInProgress", () => {
|
|
35
|
+
it("returns true for active states", () => {
|
|
36
|
+
const inProgressStates: ExportStatus[] = ["REQUESTED", "QUEUED", "RUNNING"];
|
|
37
|
+
for (const status of inProgressStates) {
|
|
38
|
+
expect(isExportInProgress(status)).toBe(true);
|
|
39
|
+
}
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it("returns false for terminal and done states", () => {
|
|
43
|
+
const notInProgress: ExportStatus[] = [
|
|
44
|
+
"DONE",
|
|
45
|
+
"DONE_NOTIFIED",
|
|
46
|
+
"FAILED",
|
|
47
|
+
"EXPIRED",
|
|
48
|
+
"CANCELLED",
|
|
49
|
+
];
|
|
50
|
+
for (const status of notInProgress) {
|
|
51
|
+
expect(isExportInProgress(status)).toBe(false);
|
|
52
|
+
}
|
|
53
|
+
});
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
describe("isExportDownloadable", () => {
|
|
57
|
+
it("returns true only for DONE and DONE_NOTIFIED", () => {
|
|
58
|
+
expect(isExportDownloadable("DONE")).toBe(true);
|
|
59
|
+
expect(isExportDownloadable("DONE_NOTIFIED")).toBe(true);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it("returns false for all other statuses", () => {
|
|
63
|
+
const nonDownloadable: ExportStatus[] = [
|
|
64
|
+
"REQUESTED",
|
|
65
|
+
"QUEUED",
|
|
66
|
+
"RUNNING",
|
|
67
|
+
"FAILED",
|
|
68
|
+
"EXPIRED",
|
|
69
|
+
"CANCELLED",
|
|
70
|
+
];
|
|
71
|
+
for (const status of nonDownloadable) {
|
|
72
|
+
expect(isExportDownloadable(status)).toBe(false);
|
|
73
|
+
}
|
|
74
|
+
});
|
|
75
|
+
});
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
// seedgrid:managed
|
|
2
|
+
|
|
3
|
+
import { SgBadge } from "@seedgrid/fe-components";
|
|
4
|
+
|
|
5
|
+
import { useI18n } from "@/i18n";
|
|
6
|
+
|
|
7
|
+
import type { ExportStatus } from "@seedgrid/fe-backup";
|
|
8
|
+
import { classifyExportStatus } from "@seedgrid/fe-backup";
|
|
9
|
+
|
|
10
|
+
type SgBadgeSeverity =
|
|
11
|
+
| "primary"
|
|
12
|
+
| "secondary"
|
|
13
|
+
| "success"
|
|
14
|
+
| "warning"
|
|
15
|
+
| "danger"
|
|
16
|
+
| "info"
|
|
17
|
+
| "neutral";
|
|
18
|
+
|
|
19
|
+
function resolveBadgeSeverity(status: ExportStatus): SgBadgeSeverity {
|
|
20
|
+
const group = classifyExportStatus(status);
|
|
21
|
+
|
|
22
|
+
switch (group) {
|
|
23
|
+
case "pending":
|
|
24
|
+
return "info";
|
|
25
|
+
case "running":
|
|
26
|
+
return "primary";
|
|
27
|
+
case "done":
|
|
28
|
+
return "success";
|
|
29
|
+
case "failed":
|
|
30
|
+
return "danger";
|
|
31
|
+
case "terminal":
|
|
32
|
+
return "neutral";
|
|
33
|
+
default:
|
|
34
|
+
return "neutral";
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
interface ExportStatusBadgeProps {
|
|
39
|
+
status: ExportStatus;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function ExportStatusBadge({ status }: ExportStatusBadgeProps) {
|
|
43
|
+
const { t } = useI18n();
|
|
44
|
+
const key = `backup.exports.status.${status}` as const;
|
|
45
|
+
|
|
46
|
+
return (
|
|
47
|
+
<SgBadge
|
|
48
|
+
value={t(key)}
|
|
49
|
+
severity={resolveBadgeSeverity(status)}
|
|
50
|
+
badgeStyle="soft"
|
|
51
|
+
size="sm"
|
|
52
|
+
/>
|
|
53
|
+
);
|
|
54
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
// seedgrid:managed
|
|
2
|
+
|
|
3
|
+
import { appEnvironment } from "@/config/environment";
|
|
4
|
+
|
|
5
|
+
function resolveApiBaseUrl() {
|
|
6
|
+
// Client-side: route through Next.js proxy to avoid CORS on error responses.
|
|
7
|
+
if (globalThis.window !== undefined) {
|
|
8
|
+
return "/api";
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
return appEnvironment.hosts.apiOrigin;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export const backupConfig = {
|
|
15
|
+
apiBaseUrl: resolveApiBaseUrl(),
|
|
16
|
+
pagination: {
|
|
17
|
+
defaultPageSize: 10,
|
|
18
|
+
pageSizeOptions: [10, 20, 50],
|
|
19
|
+
},
|
|
20
|
+
polling: {
|
|
21
|
+
intervalMs: 5000,
|
|
22
|
+
},
|
|
23
|
+
routes: {
|
|
24
|
+
exports: "/backup/exports",
|
|
25
|
+
newExport: "/backup/exports/new",
|
|
26
|
+
},
|
|
27
|
+
} as const;
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
// seedgrid:managed
|
|
2
|
+
|
|
3
|
+
export { backupConfig } from "./config";
|
|
4
|
+
export { useBackupAppShellSections } from "./useBackupAppShellSections";
|
|
5
|
+
export { configureBackupApi, extractBackupApiMessage, isApiClientError } from "./services/backup-api";
|
|
6
|
+
export { backupService } from "./services/backup-service";
|
|
7
|
+
export { ExportStatusBadge } from "./components/ExportStatusBadge";
|
|
8
|
+
export type { ArtifactInfo, BackupFormat, ExportRequest, ExportResponse, ExportScope } from "./types";
|
|
9
|
+
|
|
10
|
+
// Promovidos pra lib versionada (@seedgrid/fe-backup) — ver ADR 0004/0005 e
|
|
11
|
+
// scaffold/README.md pro critério lib vs scaffold usado nesta extração.
|
|
12
|
+
export {
|
|
13
|
+
backupMessages,
|
|
14
|
+
classifyExportStatus,
|
|
15
|
+
isExportDownloadable,
|
|
16
|
+
isExportInProgress,
|
|
17
|
+
type ExportStatus,
|
|
18
|
+
type ExportStatusGroup,
|
|
19
|
+
} from "@seedgrid/fe-backup";
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
// seedgrid:managed
|
|
2
|
+
|
|
3
|
+
import { describe, expect, it } from "vitest";
|
|
4
|
+
|
|
5
|
+
import { ApiClientError } from "@/modules/core";
|
|
6
|
+
|
|
7
|
+
import { extractBackupApiMessage } from "./backup-api";
|
|
8
|
+
|
|
9
|
+
describe("extractBackupApiMessage", () => {
|
|
10
|
+
it("returns the first violation message from backend validation errors", () => {
|
|
11
|
+
const error = new ApiClientError("API request failed with status 400", 400, {
|
|
12
|
+
type: "https://seedgrid.com.br/dados-invalidos",
|
|
13
|
+
title: "Dados invalidos",
|
|
14
|
+
status: 400,
|
|
15
|
+
detail: "Revise os campos informados.",
|
|
16
|
+
devMessage: "",
|
|
17
|
+
instance: "/exports",
|
|
18
|
+
violations: [
|
|
19
|
+
{
|
|
20
|
+
field: "exportRequest.format",
|
|
21
|
+
message: "Formato de exportacao invalido",
|
|
22
|
+
},
|
|
23
|
+
],
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
expect(extractBackupApiMessage(error)).toBe("Formato de exportacao invalido");
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it("returns the rfc7807 detail when the response has no violations", () => {
|
|
30
|
+
const error = new ApiClientError("API request failed with status 403", 403, {
|
|
31
|
+
type: "https://seedgrid.com.br/acesso-negado",
|
|
32
|
+
title: "Acesso negado",
|
|
33
|
+
status: 403,
|
|
34
|
+
detail: "Voce nao tem permissao para criar exportacoes",
|
|
35
|
+
devMessage: "",
|
|
36
|
+
instance: "/exports",
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
expect(extractBackupApiMessage(error)).toBe(
|
|
40
|
+
"Voce nao tem permissao para criar exportacoes"
|
|
41
|
+
);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it("parses stringified problem json responses before extracting the message", () => {
|
|
45
|
+
const error = new ApiClientError(
|
|
46
|
+
"API request failed with status 400",
|
|
47
|
+
400,
|
|
48
|
+
JSON.stringify({
|
|
49
|
+
type: "https://seedgrid.com.br/dados-invalidos",
|
|
50
|
+
title: "Dados invalidos",
|
|
51
|
+
status: 400,
|
|
52
|
+
detail: "Revise os campos informados.",
|
|
53
|
+
devMessage: "",
|
|
54
|
+
instance: "/exports",
|
|
55
|
+
violations: [
|
|
56
|
+
{
|
|
57
|
+
field: "exportRequest.format",
|
|
58
|
+
message: "Formato e obrigatorio",
|
|
59
|
+
},
|
|
60
|
+
{
|
|
61
|
+
field: "exportRequest.scope",
|
|
62
|
+
message: "Escopo e obrigatorio",
|
|
63
|
+
},
|
|
64
|
+
],
|
|
65
|
+
})
|
|
66
|
+
);
|
|
67
|
+
|
|
68
|
+
expect(extractBackupApiMessage(error)).toBe("Formato e obrigatorio");
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it("falls back to the api client message when the response has no readable details", () => {
|
|
72
|
+
const error = new ApiClientError("API request failed with status 500", 500, {
|
|
73
|
+
status: 500,
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
expect(extractBackupApiMessage(error)).toBe(
|
|
77
|
+
"API request failed with status 500"
|
|
78
|
+
);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it("falls back to the api client message for an empty response body", () => {
|
|
82
|
+
// Corpo vazio {} e tao "sem conteudo legivel" quanto { status: 500 } (o
|
|
83
|
+
// teste-irmao acima) — ambos caem no message do ApiClientError, de forma
|
|
84
|
+
// consistente. (Antes este teste esperava null, um contrato divergente do
|
|
85
|
+
// irmao que o codigo nunca honrou.)
|
|
86
|
+
const error = new ApiClientError("API request failed with status 500", 500, {});
|
|
87
|
+
|
|
88
|
+
expect(extractBackupApiMessage(error)).toBe(
|
|
89
|
+
"API request failed with status 500"
|
|
90
|
+
);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it("maps fetch failures to a friendly server connection message", () => {
|
|
94
|
+
const result = extractBackupApiMessage(new Error("fetch failed"));
|
|
95
|
+
// The message is resolved from i18n — verify it is a non-null string
|
|
96
|
+
expect(result).not.toBeNull();
|
|
97
|
+
expect(typeof result).toBe("string");
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it("maps 'failed to fetch' to the server connection message", () => {
|
|
101
|
+
const result = extractBackupApiMessage(new Error("Failed to fetch"));
|
|
102
|
+
expect(result).not.toBeNull();
|
|
103
|
+
expect(typeof result).toBe("string");
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it("returns null for non-Error unknown values", () => {
|
|
107
|
+
expect(extractBackupApiMessage(null)).toBeNull();
|
|
108
|
+
expect(extractBackupApiMessage(undefined)).toBeNull();
|
|
109
|
+
expect(extractBackupApiMessage("string error")).toBeNull();
|
|
110
|
+
expect(extractBackupApiMessage(42)).toBeNull();
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it("returns null when the response body is an HTML page", () => {
|
|
114
|
+
const error = new ApiClientError(
|
|
115
|
+
"API request failed with status 502",
|
|
116
|
+
502,
|
|
117
|
+
"<!DOCTYPE html><html><body>Bad Gateway</body></html>"
|
|
118
|
+
);
|
|
119
|
+
|
|
120
|
+
expect(extractBackupApiMessage(error)).toBeNull();
|
|
121
|
+
});
|
|
122
|
+
});
|