@arch-cadre/backup-module 1.0.7 → 1.0.9
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/actions.cjs +1 -1
- package/dist/actions.mjs +1 -1
- package/dist/index.cjs +2 -2
- package/dist/index.mjs +2 -2
- package/dist/routes.cjs +1 -1
- package/dist/routes.mjs +1 -1
- package/dist/ui/components/backup-actions.cjs +1 -1
- package/dist/ui/components/backup-actions.mjs +1 -1
- package/dist/ui/components/backup-client.cjs +2 -2
- package/dist/ui/components/backup-client.mjs +2 -2
- package/dist/ui/pages/backup-list.cjs +3 -3
- package/dist/ui/pages/backup-list.mjs +3 -3
- package/package.json +8 -7
- package/src/actions.ts +144 -0
- package/src/docs/getting-started.md +13 -0
- package/src/index.ts +85 -0
- package/src/navigation.ts +15 -0
- package/src/routes.ts +17 -0
- package/src/schema.ts +12 -0
- package/src/ui/components/backup-actions.ts +20 -0
- package/src/ui/components/backup-client.tsx +117 -0
- package/src/ui/pages/backup-list.tsx +117 -0
package/dist/actions.cjs
CHANGED
|
@@ -14,7 +14,7 @@ var _nodePath = _interopRequireDefault(require("node:path"));
|
|
|
14
14
|
var _nodeUtil = require("node:util");
|
|
15
15
|
var _server = require("@arch-cadre/core/server");
|
|
16
16
|
var _drizzleOrm = require("drizzle-orm");
|
|
17
|
-
var _schema = require("./schema.
|
|
17
|
+
var _schema = require("./schema.js");
|
|
18
18
|
function _interopRequireDefault(e) { return e && e.__esModule ? e : { default: e }; }
|
|
19
19
|
const execAsync = (0, _nodeUtil.promisify)(_nodeChild_process.exec);
|
|
20
20
|
const BACKUP_DIR = _nodePath.default.join(process.cwd(), "backups");
|
package/dist/actions.mjs
CHANGED
|
@@ -5,7 +5,7 @@ import path from "node:path";
|
|
|
5
5
|
import { promisify } from "node:util";
|
|
6
6
|
import { db, eventBus, getCurrentSession } from "@arch-cadre/core/server";
|
|
7
7
|
import { desc, eq } from "drizzle-orm";
|
|
8
|
-
import { backupTable } from "./schema.
|
|
8
|
+
import { backupTable } from "./schema.js";
|
|
9
9
|
const execAsync = promisify(exec);
|
|
10
10
|
const BACKUP_DIR = path.join(process.cwd(), "backups");
|
|
11
11
|
export async function getBackups() {
|
package/dist/index.cjs
CHANGED
|
@@ -7,8 +7,8 @@ Object.defineProperty(exports, "__esModule", {
|
|
|
7
7
|
var _server = require("@arch-cadre/core/server");
|
|
8
8
|
var _drizzleOrm = require("drizzle-orm");
|
|
9
9
|
var _manifest = _interopRequireDefault(require("../manifest.json"));
|
|
10
|
-
var _navigation = require("./navigation.
|
|
11
|
-
var _routes = require("./routes.
|
|
10
|
+
var _navigation = require("./navigation.js");
|
|
11
|
+
var _routes = require("./routes.js");
|
|
12
12
|
function _interopRequireDefault(e) { return e && e.__esModule ? e : { default: e }; }
|
|
13
13
|
const BACKUP_PERMISSIONS = [{
|
|
14
14
|
name: "backup:create",
|
package/dist/index.mjs
CHANGED
|
@@ -7,8 +7,8 @@ import {
|
|
|
7
7
|
} from "@arch-cadre/core/server";
|
|
8
8
|
import { inArray, sql } from "drizzle-orm";
|
|
9
9
|
import manifest from "../manifest.json";
|
|
10
|
-
import { navigation } from "./navigation.
|
|
11
|
-
import { privateRoutes } from "./routes.
|
|
10
|
+
import { navigation } from "./navigation.js";
|
|
11
|
+
import { privateRoutes } from "./routes.js";
|
|
12
12
|
const BACKUP_PERMISSIONS = [
|
|
13
13
|
{ name: "backup:create", description: "Allow creating system backups" },
|
|
14
14
|
{ name: "backup:restore", description: "Allow restoring system backups" },
|
package/dist/routes.cjs
CHANGED
|
@@ -4,7 +4,7 @@ Object.defineProperty(exports, "__esModule", {
|
|
|
4
4
|
value: true
|
|
5
5
|
});
|
|
6
6
|
exports.publicRoutes = exports.privateRoutes = void 0;
|
|
7
|
-
var _backupList = _interopRequireDefault(require("./ui/pages/backup-list.
|
|
7
|
+
var _backupList = _interopRequireDefault(require("./ui/pages/backup-list.js"));
|
|
8
8
|
function _interopRequireDefault(e) { return e && e.__esModule ? e : { default: e }; }
|
|
9
9
|
const publicRoutes = exports.publicRoutes = [];
|
|
10
10
|
const privateRoutes = exports.privateRoutes = [{
|
package/dist/routes.mjs
CHANGED
|
@@ -8,7 +8,7 @@ exports.createBackupAction = createBackupAction;
|
|
|
8
8
|
exports.deleteBackupAction = deleteBackupAction;
|
|
9
9
|
exports.restoreBackupAction = restoreBackupAction;
|
|
10
10
|
var _cache = require("next/cache");
|
|
11
|
-
var _actions = require("../../actions.
|
|
11
|
+
var _actions = require("../../actions.js");
|
|
12
12
|
async function createBackupAction() {
|
|
13
13
|
await (0, _actions.createBackup)();
|
|
14
14
|
(0, _cache.revalidatePath)("/module/backup");
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
"use server";
|
|
2
2
|
import { revalidatePath } from "next/cache";
|
|
3
|
-
import { createBackup, deleteBackup, restoreBackup } from "../../actions.
|
|
3
|
+
import { createBackup, deleteBackup, restoreBackup } from "../../actions.js";
|
|
4
4
|
export async function createBackupAction() {
|
|
5
5
|
await createBackup();
|
|
6
6
|
revalidatePath("/module/backup");
|
|
@@ -7,10 +7,10 @@ Object.defineProperty(exports, "__esModule", {
|
|
|
7
7
|
exports.CreateBackupButton = CreateBackupButton;
|
|
8
8
|
exports.DeleteBackupButton = DeleteBackupButton;
|
|
9
9
|
exports.RestoreBackupButton = RestoreBackupButton;
|
|
10
|
+
var _ui = require("@arch-cadre/ui");
|
|
10
11
|
var _react = _interopRequireWildcard(require("react"));
|
|
11
12
|
var React = _react;
|
|
12
|
-
var
|
|
13
|
-
var _backupActions = require("./backup-actions.cjs");
|
|
13
|
+
var _backupActions = require("./backup-actions.js");
|
|
14
14
|
function _getRequireWildcardCache(e) { if ("function" != typeof WeakMap) return null; var r = new WeakMap(), t = new WeakMap(); return (_getRequireWildcardCache = function (e) { return e ? t : r; })(e); }
|
|
15
15
|
function _interopRequireWildcard(e, r) { if (!r && e && e.__esModule) return e; if (null === e || "object" != typeof e && "function" != typeof e) return { default: e }; var t = _getRequireWildcardCache(r); if (t && t.has(e)) return t.get(e); var n = { __proto__: null }, a = Object.defineProperty && Object.getOwnPropertyDescriptor; for (var u in e) if ("default" !== u && {}.hasOwnProperty.call(e, u)) { var i = a ? Object.getOwnPropertyDescriptor(e, u) : null; i && (i.get || i.set) ? Object.defineProperty(n, u, i) : n[u] = e[u]; } return n.default = e, t && t.set(e, n), n; }
|
|
16
16
|
function CreateBackupButton() {
|
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
"use client";
|
|
2
|
-
import * as React from "react";
|
|
3
2
|
import { Button, Icon, toast } from "@arch-cadre/ui";
|
|
3
|
+
import * as React from "react";
|
|
4
4
|
import { useState } from "react";
|
|
5
5
|
import {
|
|
6
6
|
createBackupAction,
|
|
7
7
|
deleteBackupAction,
|
|
8
8
|
restoreBackupAction
|
|
9
|
-
} from "./backup-actions.
|
|
9
|
+
} from "./backup-actions.js";
|
|
10
10
|
export function CreateBackupButton() {
|
|
11
11
|
const [pending, setPending] = useState(false);
|
|
12
12
|
const handleCreate = async () => {
|
|
@@ -4,13 +4,13 @@ Object.defineProperty(exports, "__esModule", {
|
|
|
4
4
|
value: true
|
|
5
5
|
});
|
|
6
6
|
module.exports = BackupListPage;
|
|
7
|
-
var React = _interopRequireWildcard(require("react"));
|
|
8
7
|
var _card = require("@arch-cadre/ui/components/card");
|
|
9
8
|
var _table = require("@arch-cadre/ui/components/table");
|
|
10
9
|
var _dateFns = require("date-fns");
|
|
11
10
|
var _locale = require("date-fns/locale");
|
|
12
|
-
var
|
|
13
|
-
var
|
|
11
|
+
var React = _interopRequireWildcard(require("react"));
|
|
12
|
+
var _actions = require("../../actions.js");
|
|
13
|
+
var _backupClient = require("../components/backup-client.js");
|
|
14
14
|
function _getRequireWildcardCache(e) { if ("function" != typeof WeakMap) return null; var r = new WeakMap(), t = new WeakMap(); return (_getRequireWildcardCache = function (e) { return e ? t : r; })(e); }
|
|
15
15
|
function _interopRequireWildcard(e, r) { if (!r && e && e.__esModule) return e; if (null === e || "object" != typeof e && "function" != typeof e) return { default: e }; var t = _getRequireWildcardCache(r); if (t && t.has(e)) return t.get(e); var n = { __proto__: null }, a = Object.defineProperty && Object.getOwnPropertyDescriptor; for (var u in e) if ("default" !== u && {}.hasOwnProperty.call(e, u)) { var i = a ? Object.getOwnPropertyDescriptor(e, u) : null; i && (i.get || i.set) ? Object.defineProperty(n, u, i) : n[u] = e[u]; } return n.default = e, t && t.set(e, n), n; }
|
|
16
16
|
function formatBytes(bytes, decimals = 2) {
|
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
import * as React from "react";
|
|
2
1
|
import {
|
|
3
2
|
Card,
|
|
4
3
|
CardContent,
|
|
@@ -16,12 +15,13 @@ import {
|
|
|
16
15
|
} from "@arch-cadre/ui/components/table";
|
|
17
16
|
import { formatDistanceToNow } from "date-fns";
|
|
18
17
|
import { pl } from "date-fns/locale";
|
|
19
|
-
import
|
|
18
|
+
import * as React from "react";
|
|
19
|
+
import { getBackups } from "../../actions.js";
|
|
20
20
|
import {
|
|
21
21
|
CreateBackupButton,
|
|
22
22
|
DeleteBackupButton,
|
|
23
23
|
RestoreBackupButton
|
|
24
|
-
} from "../components/backup-client.
|
|
24
|
+
} from "../components/backup-client.js";
|
|
25
25
|
function formatBytes(bytes, decimals = 2) {
|
|
26
26
|
if (bytes === 0) return "0 Bytes";
|
|
27
27
|
const k = 1024;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@arch-cadre/backup-module",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.9",
|
|
4
4
|
"description": "Backup module for Kryo framework",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"exports": {
|
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
},
|
|
14
14
|
"files": [
|
|
15
15
|
"dist",
|
|
16
|
+
"src",
|
|
16
17
|
"locales",
|
|
17
18
|
"manifest.json"
|
|
18
19
|
],
|
|
@@ -25,8 +26,8 @@
|
|
|
25
26
|
"build": "unbuild"
|
|
26
27
|
},
|
|
27
28
|
"dependencies": {
|
|
28
|
-
"@arch-cadre/modules": "^0.0.
|
|
29
|
-
"@arch-cadre/ui": "^0.0.
|
|
29
|
+
"@arch-cadre/modules": "^0.0.79",
|
|
30
|
+
"@arch-cadre/ui": "^0.0.53",
|
|
30
31
|
"@hookform/resolvers": "^3.10.0",
|
|
31
32
|
"date-fns": "^4.1.0",
|
|
32
33
|
"drizzle-orm": "1.0.0-beta.6-4414a19",
|
|
@@ -37,7 +38,7 @@
|
|
|
37
38
|
"zod": "^3.24.1"
|
|
38
39
|
},
|
|
39
40
|
"devDependencies": {
|
|
40
|
-
"@arch-cadre/core": "^0.0.
|
|
41
|
+
"@arch-cadre/core": "^0.0.53",
|
|
41
42
|
"@types/pg": "^8.16.0",
|
|
42
43
|
"@types/react": "^19",
|
|
43
44
|
"next": "16.1.1",
|
|
@@ -46,9 +47,9 @@
|
|
|
46
47
|
"unbuild": "^3.6.1"
|
|
47
48
|
},
|
|
48
49
|
"peerDependencies": {
|
|
49
|
-
"@arch-cadre/core": "^0.0.
|
|
50
|
-
"@arch-cadre/intl": "^0.0.
|
|
51
|
-
"@arch-cadre/ui": "^0.0.
|
|
50
|
+
"@arch-cadre/core": "^0.0.53",
|
|
51
|
+
"@arch-cadre/intl": "^0.0.53",
|
|
52
|
+
"@arch-cadre/ui": "^0.0.53",
|
|
52
53
|
"next": ">=13.0.0",
|
|
53
54
|
"react": "^19.0.0"
|
|
54
55
|
},
|
package/src/actions.ts
ADDED
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
"use server";
|
|
2
|
+
|
|
3
|
+
import { exec } from "node:child_process";
|
|
4
|
+
import fs from "node:fs/promises";
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
import { promisify } from "node:util";
|
|
7
|
+
import { db, eventBus, getCurrentSession } from "@arch-cadre/core/server";
|
|
8
|
+
import { desc, eq } from "drizzle-orm";
|
|
9
|
+
import { backupTable } from "./schema.js";
|
|
10
|
+
|
|
11
|
+
const execAsync = promisify(exec);
|
|
12
|
+
const BACKUP_DIR = path.join(process.cwd(), "backups");
|
|
13
|
+
|
|
14
|
+
export async function getBackups() {
|
|
15
|
+
return await db
|
|
16
|
+
.select()
|
|
17
|
+
.from(backupTable)
|
|
18
|
+
.orderBy(desc(backupTable.createdAt));
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export async function createBackup() {
|
|
22
|
+
const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
|
|
23
|
+
const filename = `backup-${timestamp}.sql`;
|
|
24
|
+
const filePath = path.join(BACKUP_DIR, filename);
|
|
25
|
+
|
|
26
|
+
try {
|
|
27
|
+
// Ensure backup directory exists
|
|
28
|
+
await fs.mkdir(BACKUP_DIR, { recursive: true });
|
|
29
|
+
|
|
30
|
+
const dbUrl = process.env.DATABASE_URL;
|
|
31
|
+
if (!dbUrl) throw new Error("DATABASE_URL is not defined");
|
|
32
|
+
|
|
33
|
+
// Execute pg_dump
|
|
34
|
+
// We use -x to exclude privileges and -O to exclude ownership to make it more portable
|
|
35
|
+
// We add -c (clean) and --if-exists to make restoration easier
|
|
36
|
+
await execAsync(`pg_dump "${dbUrl}" -c --if-exists -x -O > "${filePath}"`);
|
|
37
|
+
|
|
38
|
+
const stats = await fs.stat(filePath);
|
|
39
|
+
|
|
40
|
+
const [backup] = await db
|
|
41
|
+
.insert(backupTable)
|
|
42
|
+
.values({
|
|
43
|
+
filename,
|
|
44
|
+
size: Math.round(stats.size),
|
|
45
|
+
status: "success",
|
|
46
|
+
})
|
|
47
|
+
.returning();
|
|
48
|
+
|
|
49
|
+
const { user } = await getCurrentSession();
|
|
50
|
+
await eventBus.publish(
|
|
51
|
+
"activity.create",
|
|
52
|
+
{
|
|
53
|
+
action: "backup.create",
|
|
54
|
+
description: `Created database backup: ${filename}`,
|
|
55
|
+
userId: user?.id,
|
|
56
|
+
metadata: { filename, size: stats.size, backupId: backup.id },
|
|
57
|
+
},
|
|
58
|
+
"backup",
|
|
59
|
+
);
|
|
60
|
+
|
|
61
|
+
return { success: true, backup };
|
|
62
|
+
} catch (error) {
|
|
63
|
+
console.error("[Backup] Create backup failed:", error);
|
|
64
|
+
return { success: false, error: (error as Error).message };
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export async function restoreBackup(id: string) {
|
|
69
|
+
try {
|
|
70
|
+
const [backup] = await db
|
|
71
|
+
.select()
|
|
72
|
+
.from(backupTable)
|
|
73
|
+
.where(eq(backupTable.id, id));
|
|
74
|
+
if (!backup) throw new Error("Backup not found");
|
|
75
|
+
|
|
76
|
+
const filePath = path.join(BACKUP_DIR, backup.filename);
|
|
77
|
+
const dbUrl = process.env.DATABASE_URL;
|
|
78
|
+
if (!dbUrl) throw new Error("DATABASE_URL is not defined");
|
|
79
|
+
|
|
80
|
+
// Check if file exists
|
|
81
|
+
await fs.access(filePath);
|
|
82
|
+
|
|
83
|
+
// Execute psql restore
|
|
84
|
+
// Note: This will execute the SQL commands in the backup file
|
|
85
|
+
await execAsync(`psql "${dbUrl}" < "${filePath}"`);
|
|
86
|
+
|
|
87
|
+
const { user } = await getCurrentSession();
|
|
88
|
+
await eventBus.publish(
|
|
89
|
+
"activity.create",
|
|
90
|
+
{
|
|
91
|
+
action: "backup.restore",
|
|
92
|
+
description: `Restored database from backup: ${backup.filename}`,
|
|
93
|
+
userId: user?.id,
|
|
94
|
+
metadata: { filename: backup.filename, backupId: backup.id },
|
|
95
|
+
},
|
|
96
|
+
"backup",
|
|
97
|
+
);
|
|
98
|
+
|
|
99
|
+
return { success: true };
|
|
100
|
+
} catch (error) {
|
|
101
|
+
console.error("[Backup] Restore backup failed:", error);
|
|
102
|
+
return { success: false, error: (error as Error).message };
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export async function deleteBackup(id: string) {
|
|
107
|
+
try {
|
|
108
|
+
const [backup] = await db
|
|
109
|
+
.select()
|
|
110
|
+
.from(backupTable)
|
|
111
|
+
.where(eq(backupTable.id, id));
|
|
112
|
+
if (!backup) throw new Error("Backup not found");
|
|
113
|
+
|
|
114
|
+
const filePath = path.join(BACKUP_DIR, backup.filename);
|
|
115
|
+
|
|
116
|
+
// Delete file if exists
|
|
117
|
+
try {
|
|
118
|
+
await fs.unlink(filePath);
|
|
119
|
+
} catch (_e) {
|
|
120
|
+
console.warn(
|
|
121
|
+
`[Backup] File ${filePath} could not be deleted or doesn't exist`,
|
|
122
|
+
);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
await db.delete(backupTable).where(eq(backupTable.id, id));
|
|
126
|
+
|
|
127
|
+
const { user } = await getCurrentSession();
|
|
128
|
+
await eventBus.publish(
|
|
129
|
+
"activity.create",
|
|
130
|
+
{
|
|
131
|
+
action: "backup.delete",
|
|
132
|
+
description: `Deleted database backup: ${backup.filename}`,
|
|
133
|
+
userId: user?.id,
|
|
134
|
+
metadata: { filename: backup.filename, backupId: id },
|
|
135
|
+
},
|
|
136
|
+
"backup",
|
|
137
|
+
);
|
|
138
|
+
|
|
139
|
+
return { success: true };
|
|
140
|
+
} catch (error) {
|
|
141
|
+
console.error("[Backup] Delete backup failed:", error);
|
|
142
|
+
return { success: false, error: (error as Error).message };
|
|
143
|
+
}
|
|
144
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
# Database Backup Module
|
|
2
|
+
|
|
3
|
+
This module allows you to create full SQL snapshots of your database using `pg_dump`.
|
|
4
|
+
|
|
5
|
+
## Features
|
|
6
|
+
- **Create Backup**: Generates a portable `.sql` file with all tables and data.
|
|
7
|
+
- **Restore**: Easily revert your database to a previous state.
|
|
8
|
+
- **Activity Logging**: Every backup action is recorded in the system logs.
|
|
9
|
+
|
|
10
|
+
## Storage
|
|
11
|
+
All backups are stored in the root `/backups` directory of the project.
|
|
12
|
+
|
|
13
|
+
> **Warning**: Restoring a backup will overwrite all current data in the database. Always ensure you have a recent snapshot before performing a restore.
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import {
|
|
2
|
+
assignPermissionToRole,
|
|
3
|
+
createPermission,
|
|
4
|
+
db,
|
|
5
|
+
getRoles,
|
|
6
|
+
permissionsTable,
|
|
7
|
+
} from "@arch-cadre/core/server";
|
|
8
|
+
import type { IModule } from "@arch-cadre/modules";
|
|
9
|
+
import { inArray, sql } from "drizzle-orm";
|
|
10
|
+
import manifest from "../manifest.json";
|
|
11
|
+
import { navigation } from "./navigation.js";
|
|
12
|
+
import { privateRoutes } from "./routes.js";
|
|
13
|
+
|
|
14
|
+
const BACKUP_PERMISSIONS = [
|
|
15
|
+
{ name: "backup:create", description: "Allow creating system backups" },
|
|
16
|
+
{ name: "backup:restore", description: "Allow restoring system backups" },
|
|
17
|
+
{ name: "backup:delete", description: "Allow deleting system backups" },
|
|
18
|
+
{ name: "backup:list", description: "Allow listing system backups" },
|
|
19
|
+
];
|
|
20
|
+
|
|
21
|
+
const backupModule: IModule = {
|
|
22
|
+
manifest,
|
|
23
|
+
|
|
24
|
+
navigation,
|
|
25
|
+
|
|
26
|
+
routes: {
|
|
27
|
+
private: privateRoutes,
|
|
28
|
+
},
|
|
29
|
+
onEnable: async () => {
|
|
30
|
+
console.log("[Backup] enabling and registering permissions...");
|
|
31
|
+
|
|
32
|
+
try {
|
|
33
|
+
// 1. Create permissions
|
|
34
|
+
for (const perm of BACKUP_PERMISSIONS) {
|
|
35
|
+
await createPermission(perm.name, perm.description);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// 2. Assign to admin role
|
|
39
|
+
const roles = await getRoles();
|
|
40
|
+
const adminRole = roles.find((r) => r.name === "admin");
|
|
41
|
+
|
|
42
|
+
if (adminRole) {
|
|
43
|
+
const backupPermNames = BACKUP_PERMISSIONS.map((p) => p.name);
|
|
44
|
+
const backupPerms = await db
|
|
45
|
+
.select()
|
|
46
|
+
.from(permissionsTable)
|
|
47
|
+
.where(inArray(permissionsTable.name, backupPermNames));
|
|
48
|
+
|
|
49
|
+
for (const p of backupPerms) {
|
|
50
|
+
await assignPermissionToRole(adminRole.id, p.id);
|
|
51
|
+
}
|
|
52
|
+
console.log("[Backup] Permissions assigned to admin role.");
|
|
53
|
+
}
|
|
54
|
+
} catch (error) {
|
|
55
|
+
console.error("[Backup] Error during permission registration:", error);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
console.log("[Backup] Module enabled.");
|
|
59
|
+
},
|
|
60
|
+
|
|
61
|
+
onDisable: async () => {
|
|
62
|
+
console.log("[Backup] onDisable: Cleaning up tables and permissions...");
|
|
63
|
+
try {
|
|
64
|
+
// 1. Remove permissions (cascades to role-permission mappings)
|
|
65
|
+
const backupPermNames = BACKUP_PERMISSIONS.map((p) => p.name);
|
|
66
|
+
await db
|
|
67
|
+
.delete(permissionsTable)
|
|
68
|
+
.where(inArray(permissionsTable.name, backupPermNames));
|
|
69
|
+
console.log("[Backup] Permissions and mappings removed.");
|
|
70
|
+
|
|
71
|
+
// 2. Drop tables
|
|
72
|
+
await db.execute(sql.raw(`DROP TABLE IF EXISTS backups CASCADE`));
|
|
73
|
+
console.log("[Backup] Database tables removed.");
|
|
74
|
+
} catch (error) {
|
|
75
|
+
console.error("[Backup] Failed to remove tables or permissions:", error);
|
|
76
|
+
}
|
|
77
|
+
console.log("[Backup] Module disabled.");
|
|
78
|
+
},
|
|
79
|
+
|
|
80
|
+
init: async () => {
|
|
81
|
+
console.log("[Backup] Module initialized.");
|
|
82
|
+
},
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
export default backupModule;
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { ModuleNavigation } from "@arch-cadre/modules";
|
|
2
|
+
|
|
3
|
+
export const navigation: ModuleNavigation = {
|
|
4
|
+
admin: {
|
|
5
|
+
System: [
|
|
6
|
+
{
|
|
7
|
+
title: "Backups",
|
|
8
|
+
url: "/backups",
|
|
9
|
+
icon: "solar:database-bold-duotone",
|
|
10
|
+
roles: ["admin"],
|
|
11
|
+
permissions: ["backup:list"],
|
|
12
|
+
},
|
|
13
|
+
],
|
|
14
|
+
},
|
|
15
|
+
};
|
package/src/routes.ts
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
PrivateRouteDefinition,
|
|
3
|
+
PublicRouteDefinition,
|
|
4
|
+
} from "@arch-cadre/modules";
|
|
5
|
+
import BackupListPage from "./ui/pages/backup-list.js";
|
|
6
|
+
|
|
7
|
+
export const publicRoutes: PublicRouteDefinition[] = [];
|
|
8
|
+
|
|
9
|
+
export const privateRoutes: PrivateRouteDefinition[] = [
|
|
10
|
+
{
|
|
11
|
+
path: "/backups",
|
|
12
|
+
component: BackupListPage,
|
|
13
|
+
auth: true,
|
|
14
|
+
roles: ["admin"],
|
|
15
|
+
permissions: ["backup:list"],
|
|
16
|
+
},
|
|
17
|
+
];
|
package/src/schema.ts
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { integer, pgTable, text, timestamp } from "drizzle-orm/pg-core";
|
|
2
|
+
|
|
3
|
+
export const backupTable = pgTable("backups", {
|
|
4
|
+
id: text("id")
|
|
5
|
+
.$defaultFn(() => crypto.randomUUID())
|
|
6
|
+
.notNull()
|
|
7
|
+
.primaryKey(),
|
|
8
|
+
filename: text("filename").notNull(),
|
|
9
|
+
size: integer("size").notNull(), // in bytes
|
|
10
|
+
createdAt: timestamp("created_at", { precision: 3 }).notNull().defaultNow(),
|
|
11
|
+
status: text("status").notNull(), // 'success', 'failed'
|
|
12
|
+
});
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
"use server";
|
|
2
|
+
|
|
3
|
+
import { revalidatePath } from "next/cache";
|
|
4
|
+
import { createBackup, deleteBackup, restoreBackup } from "../../actions.js";
|
|
5
|
+
|
|
6
|
+
export async function createBackupAction() {
|
|
7
|
+
await createBackup();
|
|
8
|
+
revalidatePath("/module/backup");
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export async function restoreBackupAction(id: string) {
|
|
12
|
+
const result = await restoreBackup(id);
|
|
13
|
+
revalidatePath("/module/backup");
|
|
14
|
+
return result;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export async function deleteBackupAction(id: string) {
|
|
18
|
+
await deleteBackup(id);
|
|
19
|
+
revalidatePath("/module/backup");
|
|
20
|
+
}
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { Button, Icon, toast } from "@arch-cadre/ui";
|
|
4
|
+
import * as React from "react";
|
|
5
|
+
import { useState } from "react";
|
|
6
|
+
import {
|
|
7
|
+
createBackupAction,
|
|
8
|
+
deleteBackupAction,
|
|
9
|
+
restoreBackupAction,
|
|
10
|
+
} from "./backup-actions.js";
|
|
11
|
+
|
|
12
|
+
export function CreateBackupButton() {
|
|
13
|
+
// ... (omitting for brevity in thought, but tool call needs context)
|
|
14
|
+
const [pending, setPending] = useState(false);
|
|
15
|
+
|
|
16
|
+
const handleCreate = async () => {
|
|
17
|
+
setPending(true);
|
|
18
|
+
try {
|
|
19
|
+
await createBackupAction();
|
|
20
|
+
toast.success("Backup created successfully");
|
|
21
|
+
} catch (_error) {
|
|
22
|
+
toast.error("Failed to create backup");
|
|
23
|
+
} finally {
|
|
24
|
+
setPending(false);
|
|
25
|
+
}
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
return (
|
|
29
|
+
<Button onClick={handleCreate} disabled={pending}>
|
|
30
|
+
{pending ? (
|
|
31
|
+
<Icon icon="svg-spinners:180-ring" className="mr-2 h-4 w-4" />
|
|
32
|
+
) : (
|
|
33
|
+
<Icon icon="solar:add-circle-broken" className="mr-2 h-4 w-4" />
|
|
34
|
+
)}
|
|
35
|
+
Create Backup
|
|
36
|
+
</Button>
|
|
37
|
+
);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function RestoreBackupButton({ id }: { id: string }) {
|
|
41
|
+
const [pending, setPending] = useState(false);
|
|
42
|
+
|
|
43
|
+
const handleRestore = async () => {
|
|
44
|
+
if (
|
|
45
|
+
!confirm(
|
|
46
|
+
"WARNING: This will overwrite your current database. Are you sure you want to restore this backup?",
|
|
47
|
+
)
|
|
48
|
+
)
|
|
49
|
+
return;
|
|
50
|
+
|
|
51
|
+
setPending(true);
|
|
52
|
+
try {
|
|
53
|
+
const result = await restoreBackupAction(id);
|
|
54
|
+
if (result.success) {
|
|
55
|
+
toast.success(
|
|
56
|
+
"Database restored successfully. You might need to refresh the page.",
|
|
57
|
+
);
|
|
58
|
+
} else {
|
|
59
|
+
toast.error(`Failed to restore backup: ${result.error}`);
|
|
60
|
+
}
|
|
61
|
+
} catch (_error) {
|
|
62
|
+
toast.error("An unexpected error occurred during restore");
|
|
63
|
+
} finally {
|
|
64
|
+
setPending(false);
|
|
65
|
+
}
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
return (
|
|
69
|
+
<Button
|
|
70
|
+
variant="ghost"
|
|
71
|
+
size="icon"
|
|
72
|
+
onClick={handleRestore}
|
|
73
|
+
disabled={pending}
|
|
74
|
+
title="Restore this backup"
|
|
75
|
+
>
|
|
76
|
+
{pending ? (
|
|
77
|
+
<Icon icon="svg-spinners:180-ring" className="h-4 w-4" />
|
|
78
|
+
) : (
|
|
79
|
+
<Icon icon="solar:restart-broken" className="h-4 w-4" />
|
|
80
|
+
)}
|
|
81
|
+
</Button>
|
|
82
|
+
);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export function DeleteBackupButton({ id }: { id: string }) {
|
|
86
|
+
const [pending, setPending] = useState(false);
|
|
87
|
+
|
|
88
|
+
const handleDelete = async () => {
|
|
89
|
+
if (!confirm("Are you sure you want to delete this backup?")) return;
|
|
90
|
+
|
|
91
|
+
setPending(true);
|
|
92
|
+
try {
|
|
93
|
+
await deleteBackupAction(id);
|
|
94
|
+
toast.success("Backup deleted successfully");
|
|
95
|
+
} catch (_error) {
|
|
96
|
+
toast.error("Failed to delete backup");
|
|
97
|
+
} finally {
|
|
98
|
+
setPending(false);
|
|
99
|
+
}
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
return (
|
|
103
|
+
<Button
|
|
104
|
+
variant="ghost"
|
|
105
|
+
size="icon"
|
|
106
|
+
onClick={handleDelete}
|
|
107
|
+
disabled={pending}
|
|
108
|
+
className="text-destructive hover:text-destructive hover:bg-destructive/10"
|
|
109
|
+
>
|
|
110
|
+
{pending ? (
|
|
111
|
+
<Icon icon="svg-spinners:180-ring" className="h-4 w-4" />
|
|
112
|
+
) : (
|
|
113
|
+
<Icon icon="solar:trash-bin-trash-broken" className="h-4 w-4" />
|
|
114
|
+
)}
|
|
115
|
+
</Button>
|
|
116
|
+
);
|
|
117
|
+
}
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import {
|
|
2
|
+
Card,
|
|
3
|
+
CardContent,
|
|
4
|
+
CardDescription,
|
|
5
|
+
CardHeader,
|
|
6
|
+
CardTitle,
|
|
7
|
+
} from "@arch-cadre/ui/components/card";
|
|
8
|
+
import {
|
|
9
|
+
Table,
|
|
10
|
+
TableBody,
|
|
11
|
+
TableCell,
|
|
12
|
+
TableHead,
|
|
13
|
+
TableHeader,
|
|
14
|
+
TableRow,
|
|
15
|
+
} from "@arch-cadre/ui/components/table";
|
|
16
|
+
import { formatDistanceToNow } from "date-fns";
|
|
17
|
+
import { pl } from "date-fns/locale";
|
|
18
|
+
import * as React from "react";
|
|
19
|
+
import { getBackups } from "../../actions.js";
|
|
20
|
+
import {
|
|
21
|
+
CreateBackupButton,
|
|
22
|
+
DeleteBackupButton,
|
|
23
|
+
RestoreBackupButton,
|
|
24
|
+
} from "../components/backup-client.js";
|
|
25
|
+
|
|
26
|
+
function formatBytes(bytes: number, decimals = 2) {
|
|
27
|
+
if (bytes === 0) return "0 Bytes";
|
|
28
|
+
const k = 1024;
|
|
29
|
+
const dm = decimals < 0 ? 0 : decimals;
|
|
30
|
+
const sizes = ["Bytes", "KB", "MB", "GB", "TB", "PB", "EB", "ZB", "YB"];
|
|
31
|
+
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
|
32
|
+
return parseFloat((bytes / k ** i).toFixed(dm)) + " " + sizes[i];
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export default async function BackupListPage() {
|
|
36
|
+
const backups = await getBackups();
|
|
37
|
+
|
|
38
|
+
return (
|
|
39
|
+
<div className="space-y-6">
|
|
40
|
+
<div className="flex items-center justify-between">
|
|
41
|
+
<div className="space-y-1">
|
|
42
|
+
<h2 className="text-3xl font-bold tracking-tight">
|
|
43
|
+
Database Backups
|
|
44
|
+
</h2>
|
|
45
|
+
<p className="text-muted-foreground text-sm">
|
|
46
|
+
Manage your database snapshots. All backups are stored locally on
|
|
47
|
+
the server.
|
|
48
|
+
</p>
|
|
49
|
+
</div>
|
|
50
|
+
<CreateBackupButton />
|
|
51
|
+
</div>
|
|
52
|
+
|
|
53
|
+
<Card>
|
|
54
|
+
<CardHeader>
|
|
55
|
+
<CardTitle>Available Backups</CardTitle>
|
|
56
|
+
<CardDescription>
|
|
57
|
+
A list of all backups created in the system.
|
|
58
|
+
</CardDescription>
|
|
59
|
+
</CardHeader>
|
|
60
|
+
<CardContent>
|
|
61
|
+
<Table>
|
|
62
|
+
<TableHeader>
|
|
63
|
+
<TableRow>
|
|
64
|
+
<TableHead>Filename</TableHead>
|
|
65
|
+
<TableHead>Size</TableHead>
|
|
66
|
+
<TableHead>Created</TableHead>
|
|
67
|
+
<TableHead>Status</TableHead>
|
|
68
|
+
<TableHead className="text-right">Actions</TableHead>
|
|
69
|
+
</TableRow>
|
|
70
|
+
</TableHeader>
|
|
71
|
+
<TableBody>
|
|
72
|
+
{backups.length === 0 ? (
|
|
73
|
+
<TableRow>
|
|
74
|
+
<TableCell
|
|
75
|
+
colSpan={5}
|
|
76
|
+
className="text-center py-10 text-muted-foreground"
|
|
77
|
+
>
|
|
78
|
+
No backups found. Create your first backup to see it here.
|
|
79
|
+
</TableCell>
|
|
80
|
+
</TableRow>
|
|
81
|
+
) : (
|
|
82
|
+
backups.map((backup) => (
|
|
83
|
+
<TableRow key={backup.id}>
|
|
84
|
+
<TableCell className="font-medium">
|
|
85
|
+
{backup.filename}
|
|
86
|
+
</TableCell>
|
|
87
|
+
<TableCell>{formatBytes(backup.size)}</TableCell>
|
|
88
|
+
<TableCell title={backup.createdAt.toLocaleString()}>
|
|
89
|
+
{formatDistanceToNow(backup.createdAt, {
|
|
90
|
+
addSuffix: true,
|
|
91
|
+
locale: pl,
|
|
92
|
+
})}
|
|
93
|
+
</TableCell>
|
|
94
|
+
<TableCell>
|
|
95
|
+
<span
|
|
96
|
+
className={`px-2 py-1 rounded-full text-xs font-semibold ${backup.status === "success"
|
|
97
|
+
? "bg-green-100 text-green-700"
|
|
98
|
+
: "bg-red-100 text-red-700"
|
|
99
|
+
}`}
|
|
100
|
+
>
|
|
101
|
+
{backup.status}
|
|
102
|
+
</span>
|
|
103
|
+
</TableCell>
|
|
104
|
+
<TableCell className="text-right space-x-1">
|
|
105
|
+
<RestoreBackupButton id={backup.id} />
|
|
106
|
+
<DeleteBackupButton id={backup.id} />
|
|
107
|
+
</TableCell>
|
|
108
|
+
</TableRow>
|
|
109
|
+
))
|
|
110
|
+
)}
|
|
111
|
+
</TableBody>
|
|
112
|
+
</Table>
|
|
113
|
+
</CardContent>
|
|
114
|
+
</Card>
|
|
115
|
+
</div>
|
|
116
|
+
);
|
|
117
|
+
}
|