@riligar/elysia-backup 1.0.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/LICENSE +21 -0
- package/README.md +85 -0
- package/package.json +56 -0
- package/src/elysia-backup.js +993 -0
- package/src/index.js +6 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 RiLiGar
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
# ElysiaJS Backup Plugin
|
|
2
|
+
|
|
3
|
+
Elysia plugin for R2/S3 backup with a built-in UI dashboard. Uses native Bun S3 client for optimal performance.
|
|
4
|
+
|
|
5
|
+
## Features
|
|
6
|
+
|
|
7
|
+
- 📁 Backup local directories to R2/S3
|
|
8
|
+
- 🔄 Scheduled backups with cron expressions
|
|
9
|
+
- 🖥️ Built-in UI dashboard
|
|
10
|
+
- ⬇️ One-click restore
|
|
11
|
+
- 🗑️ Delete remote backups
|
|
12
|
+
- ⚙️ Runtime configuration
|
|
13
|
+
|
|
14
|
+
## Installation
|
|
15
|
+
|
|
16
|
+
```bash
|
|
17
|
+
bun add @riligar/elysia-backup
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
## Usage
|
|
21
|
+
|
|
22
|
+
```javascript
|
|
23
|
+
import { Elysia } from 'elysia'
|
|
24
|
+
import { r2Backup } from '@riligar/elysia-backup'
|
|
25
|
+
|
|
26
|
+
const app = new Elysia()
|
|
27
|
+
.use(
|
|
28
|
+
r2Backup({
|
|
29
|
+
bucket: process.env.R2_BUCKET,
|
|
30
|
+
accessKeyId: process.env.R2_ACCESS_KEY_ID,
|
|
31
|
+
secretAccessKey: process.env.R2_SECRET_ACCESS_KEY,
|
|
32
|
+
endpoint: process.env.R2_ENDPOINT,
|
|
33
|
+
sourceDir: './data',
|
|
34
|
+
prefix: 'backups/',
|
|
35
|
+
extensions: ['.db', '.sqlite'],
|
|
36
|
+
cronSchedule: '0 0 * * *', // Daily at midnight
|
|
37
|
+
cronEnabled: true,
|
|
38
|
+
})
|
|
39
|
+
)
|
|
40
|
+
.listen(3000)
|
|
41
|
+
|
|
42
|
+
console.log('Server running at http://localhost:3000')
|
|
43
|
+
console.log('Backup UI at http://localhost:3000/backup')
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
## Configuration
|
|
47
|
+
|
|
48
|
+
| Option | Type | Required | Description |
|
|
49
|
+
| ----------------- | -------- | -------- | ------------------------------------------------------------- |
|
|
50
|
+
| `bucket` | string | ✅ | R2/S3 bucket name |
|
|
51
|
+
| `accessKeyId` | string | ✅ | R2/S3 Access Key ID |
|
|
52
|
+
| `secretAccessKey` | string | ✅ | R2/S3 Secret Access Key |
|
|
53
|
+
| `endpoint` | string | ✅ | R2/S3 Endpoint URL |
|
|
54
|
+
| `sourceDir` | string | ✅ | Local directory to backup |
|
|
55
|
+
| `prefix` | string | ❌ | Prefix for S3 keys (e.g., 'backups/') |
|
|
56
|
+
| `extensions` | string[] | ❌ | File extensions to include |
|
|
57
|
+
| `cronSchedule` | string | ❌ | Cron expression for scheduled backups |
|
|
58
|
+
| `cronEnabled` | boolean | ❌ | Enable/disable scheduled backups |
|
|
59
|
+
| `configPath` | string | ❌ | Path to save runtime config (default: './backup-config.json') |
|
|
60
|
+
|
|
61
|
+
## API Endpoints
|
|
62
|
+
|
|
63
|
+
The plugin adds the following routes under `/backup`:
|
|
64
|
+
|
|
65
|
+
| Method | Path | Description |
|
|
66
|
+
| ------ | --------------------- | --------------------- |
|
|
67
|
+
| GET | `/backup` | UI Dashboard |
|
|
68
|
+
| POST | `/backup/api/run` | Trigger manual backup |
|
|
69
|
+
| GET | `/backup/api/files` | List remote files |
|
|
70
|
+
| POST | `/backup/api/restore` | Restore a file |
|
|
71
|
+
| POST | `/backup/api/delete` | Delete a remote file |
|
|
72
|
+
| POST | `/backup/api/config` | Update configuration |
|
|
73
|
+
|
|
74
|
+
## Environment Variables Example
|
|
75
|
+
|
|
76
|
+
```env
|
|
77
|
+
R2_BUCKET=your-bucket-name
|
|
78
|
+
R2_ACCESS_KEY_ID=your-access-key
|
|
79
|
+
R2_SECRET_ACCESS_KEY=your-secret-key
|
|
80
|
+
R2_ENDPOINT=https://your-account.r2.cloudflarestorage.com
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
## License
|
|
84
|
+
|
|
85
|
+
MIT
|
package/package.json
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@riligar/elysia-backup",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Elysia plugin for R2/S3 backup with a built-in UI dashboard",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "src/index.js",
|
|
7
|
+
"module": "src/index.js",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": {
|
|
10
|
+
"import": "./src/index.js",
|
|
11
|
+
"default": "./src/index.js"
|
|
12
|
+
}
|
|
13
|
+
},
|
|
14
|
+
"files": [
|
|
15
|
+
"src"
|
|
16
|
+
],
|
|
17
|
+
"publishConfig": {
|
|
18
|
+
"access": "public"
|
|
19
|
+
},
|
|
20
|
+
"repository": {
|
|
21
|
+
"type": "git",
|
|
22
|
+
"url": "git+https://github.com/riligar/elysia-backup.git"
|
|
23
|
+
},
|
|
24
|
+
"homepage": "https://github.com/riligar/elysia-backup#readme",
|
|
25
|
+
"bugs": {
|
|
26
|
+
"url": "https://github.com/riligar/elysia-backup/issues"
|
|
27
|
+
},
|
|
28
|
+
"scripts": {
|
|
29
|
+
"dev": "bun run demo/index.js"
|
|
30
|
+
},
|
|
31
|
+
"keywords": [
|
|
32
|
+
"elysia",
|
|
33
|
+
"backup",
|
|
34
|
+
"r2",
|
|
35
|
+
"s3",
|
|
36
|
+
"cloudflare",
|
|
37
|
+
"bun"
|
|
38
|
+
],
|
|
39
|
+
"author": "RiLiGar",
|
|
40
|
+
"license": "MIT",
|
|
41
|
+
"peerDependencies": {
|
|
42
|
+
"elysia": ">=1.0.0",
|
|
43
|
+
"@elysiajs/html": ">=1.0.0"
|
|
44
|
+
},
|
|
45
|
+
"dependencies": {
|
|
46
|
+
"cron": "^3.1.0"
|
|
47
|
+
},
|
|
48
|
+
"devDependencies": {
|
|
49
|
+
"@elysiajs/html": "^1.1.1",
|
|
50
|
+
"@semantic-release/changelog": "^6.0.3",
|
|
51
|
+
"@semantic-release/git": "^10.0.1",
|
|
52
|
+
"bun-types": "^1.1.34",
|
|
53
|
+
"elysia": "^1.1.26",
|
|
54
|
+
"semantic-release": "^24.2.9"
|
|
55
|
+
}
|
|
56
|
+
}
|
|
@@ -0,0 +1,993 @@
|
|
|
1
|
+
// https://bun.com/docs/runtime/s3
|
|
2
|
+
// https://elysiajs.com/plugins/html
|
|
3
|
+
import { Elysia, t } from "elysia";
|
|
4
|
+
import { S3Client } from "bun";
|
|
5
|
+
import { CronJob } from "cron";
|
|
6
|
+
import { readdir, stat, readFile, writeFile, mkdir } from "node:fs/promises";
|
|
7
|
+
import { readFileSync } from "node:fs";
|
|
8
|
+
import { existsSync } from "node:fs";
|
|
9
|
+
import { join, relative, dirname } from "node:path";
|
|
10
|
+
import { html } from "@elysiajs/html";
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Elysia Plugin for R2/S3 Backup with UI (using native Bun.s3)
|
|
14
|
+
*
|
|
15
|
+
* @param {Object} config
|
|
16
|
+
* @param {string} config.bucket - The R2/S3 bucket name
|
|
17
|
+
* @param {string} config.accessKeyId - R2/S3 Access Key ID
|
|
18
|
+
* @param {string} config.secretAccessKey - R2/S3 Secret Access Key
|
|
19
|
+
* @param {string} config.endpoint - R2/S3 Endpoint URL
|
|
20
|
+
* @param {string} config.sourceDir - Local directory to backup
|
|
21
|
+
* @param {string} [config.prefix] - Optional prefix for S3 keys (e.g. 'backups/')
|
|
22
|
+
* @param {string} [config.cronSchedule] - Cron schedule expression
|
|
23
|
+
* @param {boolean} [config.cronEnabled] - Whether the cron schedule is enabled
|
|
24
|
+
* @param {string} [config.configPath] - Path to save runtime configuration (default: './backup-config.json')
|
|
25
|
+
*/
|
|
26
|
+
export const r2Backup = (initialConfig) => (app) => {
|
|
27
|
+
// State to hold runtime configuration (allows UI updates)
|
|
28
|
+
const configPath = initialConfig.configPath || "./backup-config.json";
|
|
29
|
+
|
|
30
|
+
// Load saved config if exists
|
|
31
|
+
let savedConfig = {};
|
|
32
|
+
if (existsSync(configPath)) {
|
|
33
|
+
try {
|
|
34
|
+
// We use synchronous read here or just init with promise (but top level await is tricky in plugins depending on usage)
|
|
35
|
+
// For simplicity in this context, we'll rely on the fact that this runs once on startup.
|
|
36
|
+
// However, since we are in a function, we can't easily do async await at top level unless the plugin is async.
|
|
37
|
+
// Elysia plugins can be async.
|
|
38
|
+
// Using readFileSync for startup config loading to ensure it's ready.
|
|
39
|
+
const fileContent = readFileSync(configPath, "utf-8");
|
|
40
|
+
savedConfig = JSON.parse(fileContent);
|
|
41
|
+
console.log("Loaded backup config from", configPath);
|
|
42
|
+
} catch (e) {
|
|
43
|
+
console.error("Failed to load backup config:", e);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
let config = { ...initialConfig, ...savedConfig };
|
|
48
|
+
let backupJob = null;
|
|
49
|
+
|
|
50
|
+
const getS3Client = () => {
|
|
51
|
+
// Debug config (masked)
|
|
52
|
+
console.log("S3 Config:", {
|
|
53
|
+
bucket: config.bucket,
|
|
54
|
+
endpoint: config.endpoint,
|
|
55
|
+
accessKeyId: config.accessKeyId
|
|
56
|
+
? "***" + config.accessKeyId.slice(-4)
|
|
57
|
+
: "missing",
|
|
58
|
+
hasSecret: !!config.secretAccessKey,
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
return new S3Client({
|
|
62
|
+
accessKeyId: config.accessKeyId,
|
|
63
|
+
secretAccessKey: config.secretAccessKey,
|
|
64
|
+
endpoint: config.endpoint,
|
|
65
|
+
bucket: config.bucket,
|
|
66
|
+
region: "auto",
|
|
67
|
+
// R2 requires specific region handling or defaults.
|
|
68
|
+
// Bun S3 defaults to us-east-1 which is usually fine for R2 if endpoint is correct.
|
|
69
|
+
});
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
const uploadFile = async (filePath, rootDir, timestampPrefix) => {
|
|
73
|
+
const s3 = getS3Client();
|
|
74
|
+
const fileContent = await readFile(filePath);
|
|
75
|
+
|
|
76
|
+
const relativePath = relative(rootDir, filePath);
|
|
77
|
+
const dir = dirname(relativePath);
|
|
78
|
+
const filename = relativePath.split("/").pop(); // or basename
|
|
79
|
+
|
|
80
|
+
// Format: YYYY-MM-DD_HH-mm-ss_filename.ext (or ISO if not provided)
|
|
81
|
+
const timestamp = timestampPrefix || new Date().toISOString();
|
|
82
|
+
const newFilename = `${timestamp}_${filename}`;
|
|
83
|
+
|
|
84
|
+
// Reconstruct path with new filename
|
|
85
|
+
const finalPath = dir === "." ? newFilename : join(dir, newFilename);
|
|
86
|
+
|
|
87
|
+
const key = config.prefix ? join(config.prefix, finalPath) : finalPath;
|
|
88
|
+
|
|
89
|
+
console.log(`Uploading ${key}...`);
|
|
90
|
+
|
|
91
|
+
// Bun.s3 API: s3.write(key, data)
|
|
92
|
+
await s3.write(key, fileContent);
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
const processDirectory = async (dir, timestampPrefix) => {
|
|
96
|
+
const files = await readdir(dir);
|
|
97
|
+
for (const file of files) {
|
|
98
|
+
const fullPath = join(dir, file);
|
|
99
|
+
const stats = await stat(fullPath);
|
|
100
|
+
if (stats.isDirectory()) {
|
|
101
|
+
await processDirectory(fullPath, timestampPrefix);
|
|
102
|
+
} else {
|
|
103
|
+
// Filter by extension
|
|
104
|
+
const allowedExtensions = config.extensions || [];
|
|
105
|
+
const hasExtension = allowedExtensions.some((ext) =>
|
|
106
|
+
file.endsWith(ext)
|
|
107
|
+
);
|
|
108
|
+
|
|
109
|
+
// Skip files that look like backups to prevent recursion/duplication
|
|
110
|
+
// Matches: YYYY-MM-DD_HH-mm-ss_ OR ISOString_
|
|
111
|
+
const timestampRegex =
|
|
112
|
+
/^(\d{4}-\d{2}-\d{2}_\d{2}-\d{2}-\d{2}|\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z)_/;
|
|
113
|
+
if (timestampRegex.test(file)) {
|
|
114
|
+
console.log(`Skipping backup-like file: ${file}`);
|
|
115
|
+
continue;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
if (allowedExtensions.length === 0 || hasExtension) {
|
|
119
|
+
await uploadFile(fullPath, config.sourceDir, timestampPrefix);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
const setupCron = () => {
|
|
126
|
+
if (backupJob) {
|
|
127
|
+
backupJob.stop();
|
|
128
|
+
backupJob = null;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
if (config.cronSchedule && config.cronEnabled !== false) {
|
|
132
|
+
console.log(`Setting up backup cron: ${config.cronSchedule}`);
|
|
133
|
+
try {
|
|
134
|
+
backupJob = new CronJob(
|
|
135
|
+
config.cronSchedule,
|
|
136
|
+
async () => {
|
|
137
|
+
console.log("Running scheduled backup...");
|
|
138
|
+
try {
|
|
139
|
+
// Generate timestamp similar to manual run
|
|
140
|
+
const now = new Date();
|
|
141
|
+
const timestamp =
|
|
142
|
+
now.getFullYear() +
|
|
143
|
+
"-" +
|
|
144
|
+
String(now.getMonth() + 1).padStart(2, "0") +
|
|
145
|
+
"-" +
|
|
146
|
+
String(now.getDate()).padStart(2, "0") +
|
|
147
|
+
"_" +
|
|
148
|
+
String(now.getHours()).padStart(2, "0") +
|
|
149
|
+
"-" +
|
|
150
|
+
String(now.getMinutes()).padStart(2, "0") +
|
|
151
|
+
"-" +
|
|
152
|
+
String(now.getSeconds()).padStart(2, "0");
|
|
153
|
+
|
|
154
|
+
await processDirectory(config.sourceDir, timestamp);
|
|
155
|
+
console.log("Scheduled backup completed");
|
|
156
|
+
} catch (e) {
|
|
157
|
+
console.error("Scheduled backup failed:", e);
|
|
158
|
+
}
|
|
159
|
+
},
|
|
160
|
+
null,
|
|
161
|
+
true // start
|
|
162
|
+
);
|
|
163
|
+
} catch (e) {
|
|
164
|
+
console.error("Invalid cron schedule:", e.message);
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
};
|
|
168
|
+
|
|
169
|
+
// Initialize cron
|
|
170
|
+
setupCron();
|
|
171
|
+
|
|
172
|
+
const listRemoteFiles = async () => {
|
|
173
|
+
const s3 = getS3Client();
|
|
174
|
+
// Bun.s3 list API
|
|
175
|
+
// Returns a Promise that resolves to the list of files (or object with contents)
|
|
176
|
+
// Based on Bun docs/behavior, list() returns an array of S3File-like objects or similar structure
|
|
177
|
+
try {
|
|
178
|
+
const response = await s3.list({ prefix: config.prefix || "" });
|
|
179
|
+
// If response is array
|
|
180
|
+
if (Array.isArray(response)) {
|
|
181
|
+
return response.map((f) => ({
|
|
182
|
+
Key: f.key || f.name, // Handle potential property names
|
|
183
|
+
Size: f.size,
|
|
184
|
+
LastModified: f.lastModified,
|
|
185
|
+
}));
|
|
186
|
+
}
|
|
187
|
+
// If response has contents (AWS-like)
|
|
188
|
+
if (response.contents) {
|
|
189
|
+
return response.contents.map((f) => ({
|
|
190
|
+
Key: f.key,
|
|
191
|
+
Size: f.size,
|
|
192
|
+
LastModified: f.lastModified,
|
|
193
|
+
}));
|
|
194
|
+
}
|
|
195
|
+
console.log("Unknown list response structure:", response);
|
|
196
|
+
return [];
|
|
197
|
+
} catch (e) {
|
|
198
|
+
console.error("Error listing files with Bun.s3:", e);
|
|
199
|
+
return [];
|
|
200
|
+
}
|
|
201
|
+
};
|
|
202
|
+
|
|
203
|
+
const restoreFile = async (key) => {
|
|
204
|
+
const s3 = getS3Client();
|
|
205
|
+
const file = s3.file(key);
|
|
206
|
+
|
|
207
|
+
if (!(await file.exists())) {
|
|
208
|
+
throw new Error(`File ${key} not found in bucket`);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
const arrayBuffer = await file.arrayBuffer();
|
|
212
|
+
const byteArray = new Uint8Array(arrayBuffer);
|
|
213
|
+
|
|
214
|
+
// Determine local path
|
|
215
|
+
// Remove prefix from key to get relative path
|
|
216
|
+
const relativePath = config.prefix ? key.replace(config.prefix, "") : key;
|
|
217
|
+
// Clean leading slashes if any
|
|
218
|
+
const cleanRelative = relativePath.replace(/^[\/\\]/, "");
|
|
219
|
+
|
|
220
|
+
// Extract directory and filename
|
|
221
|
+
const dir = dirname(cleanRelative);
|
|
222
|
+
const filename = cleanRelative.split("/").pop();
|
|
223
|
+
|
|
224
|
+
// Strip timestamp prefix if present to restore original filename
|
|
225
|
+
// Matches: YYYY-MM-DD_HH-mm-ss_ OR ISOString_
|
|
226
|
+
const timestampRegex =
|
|
227
|
+
/^(\d{4}-\d{2}-\d{2}_\d{2}-\d{2}-\d{2}|\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z)_/;
|
|
228
|
+
const originalFilename = filename.replace(timestampRegex, "");
|
|
229
|
+
|
|
230
|
+
const finalLocalRelativePath =
|
|
231
|
+
dir === "." ? originalFilename : join(dir, originalFilename);
|
|
232
|
+
const localPath = join(config.sourceDir, finalLocalRelativePath);
|
|
233
|
+
|
|
234
|
+
// Ensure directory exists
|
|
235
|
+
await mkdir(dirname(localPath), { recursive: true });
|
|
236
|
+
await writeFile(localPath, byteArray);
|
|
237
|
+
return localPath;
|
|
238
|
+
};
|
|
239
|
+
|
|
240
|
+
const deleteFile = async (key) => {
|
|
241
|
+
const s3 = getS3Client();
|
|
242
|
+
// Bun S3 client delete
|
|
243
|
+
await s3.delete(key);
|
|
244
|
+
};
|
|
245
|
+
|
|
246
|
+
const getJobStatus = () => {
|
|
247
|
+
const isRunning = !!backupJob && config.cronEnabled !== false;
|
|
248
|
+
let nextRun = null;
|
|
249
|
+
if (isRunning && backupJob) {
|
|
250
|
+
try {
|
|
251
|
+
const nextDate = backupJob.nextDate();
|
|
252
|
+
if (nextDate) {
|
|
253
|
+
// cron returns Luxon DateTime, convert to JS Date for consistent ISO string
|
|
254
|
+
nextRun = nextDate.toJSDate().toISOString();
|
|
255
|
+
}
|
|
256
|
+
} catch (e) {
|
|
257
|
+
console.error("Error getting next date", e);
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
return { isRunning, nextRun };
|
|
261
|
+
};
|
|
262
|
+
|
|
263
|
+
return app.use(html()).group("/backup", (app) =>
|
|
264
|
+
app
|
|
265
|
+
// API: Run Backup
|
|
266
|
+
.post(
|
|
267
|
+
"/api/run",
|
|
268
|
+
async ({ body, set }) => {
|
|
269
|
+
try {
|
|
270
|
+
const { timestamp } = body || {};
|
|
271
|
+
console.log(
|
|
272
|
+
`Starting backup of ${config.sourceDir} to ${config.bucket} with timestamp ${timestamp}`
|
|
273
|
+
);
|
|
274
|
+
await processDirectory(config.sourceDir, timestamp);
|
|
275
|
+
return {
|
|
276
|
+
status: "success",
|
|
277
|
+
message: "Backup completed successfully",
|
|
278
|
+
timestamp: new Date().toISOString(),
|
|
279
|
+
};
|
|
280
|
+
} catch (error) {
|
|
281
|
+
console.error("Backup failed:", error);
|
|
282
|
+
set.status = 500;
|
|
283
|
+
return { status: "error", message: error.message };
|
|
284
|
+
}
|
|
285
|
+
},
|
|
286
|
+
{
|
|
287
|
+
body: t.Optional(
|
|
288
|
+
t.Object({
|
|
289
|
+
timestamp: t.Optional(t.String()),
|
|
290
|
+
})
|
|
291
|
+
),
|
|
292
|
+
}
|
|
293
|
+
)
|
|
294
|
+
|
|
295
|
+
// API: List Files
|
|
296
|
+
.get("/api/files", async ({ set }) => {
|
|
297
|
+
try {
|
|
298
|
+
const files = await listRemoteFiles();
|
|
299
|
+
return {
|
|
300
|
+
files: files.map((f) => ({
|
|
301
|
+
key: f.Key,
|
|
302
|
+
size: f.Size,
|
|
303
|
+
lastModified: f.LastModified,
|
|
304
|
+
})),
|
|
305
|
+
};
|
|
306
|
+
} catch (error) {
|
|
307
|
+
set.status = 500;
|
|
308
|
+
return { status: "error", message: error.message };
|
|
309
|
+
}
|
|
310
|
+
})
|
|
311
|
+
|
|
312
|
+
// API: Restore File
|
|
313
|
+
.post(
|
|
314
|
+
"/api/restore",
|
|
315
|
+
async ({ body, set }) => {
|
|
316
|
+
try {
|
|
317
|
+
const { key } = body;
|
|
318
|
+
if (!key) throw new Error("Key is required");
|
|
319
|
+
const localPath = await restoreFile(key);
|
|
320
|
+
return { status: "success", message: `Restored to ${localPath}` };
|
|
321
|
+
} catch (error) {
|
|
322
|
+
set.status = 500;
|
|
323
|
+
return { status: "error", message: error.message };
|
|
324
|
+
}
|
|
325
|
+
},
|
|
326
|
+
{
|
|
327
|
+
body: t.Object({
|
|
328
|
+
key: t.String(),
|
|
329
|
+
}),
|
|
330
|
+
}
|
|
331
|
+
)
|
|
332
|
+
|
|
333
|
+
// API: Delete File
|
|
334
|
+
.post(
|
|
335
|
+
"/api/delete",
|
|
336
|
+
async ({ body, set }) => {
|
|
337
|
+
try {
|
|
338
|
+
const { key } = body;
|
|
339
|
+
if (!key) throw new Error("Key is required");
|
|
340
|
+
await deleteFile(key);
|
|
341
|
+
return { status: "success", message: `Deleted ${key}` };
|
|
342
|
+
} catch (error) {
|
|
343
|
+
set.status = 500;
|
|
344
|
+
return { status: "error", message: error.message };
|
|
345
|
+
}
|
|
346
|
+
},
|
|
347
|
+
{
|
|
348
|
+
body: t.Object({
|
|
349
|
+
key: t.String(),
|
|
350
|
+
}),
|
|
351
|
+
}
|
|
352
|
+
)
|
|
353
|
+
|
|
354
|
+
// API: Update Config
|
|
355
|
+
.post(
|
|
356
|
+
"/api/config",
|
|
357
|
+
async ({ body }) => {
|
|
358
|
+
// Handle extensions if passed as string
|
|
359
|
+
let newConfig = { ...body };
|
|
360
|
+
if (typeof newConfig.extensions === "string") {
|
|
361
|
+
newConfig.extensions = newConfig.extensions
|
|
362
|
+
.split(",")
|
|
363
|
+
.map((e) => e.trim())
|
|
364
|
+
.filter(Boolean);
|
|
365
|
+
}
|
|
366
|
+
config = { ...config, ...newConfig };
|
|
367
|
+
|
|
368
|
+
// Persist config
|
|
369
|
+
try {
|
|
370
|
+
// Don't save secrets in plain text if possible, but for this simple tool we might have to
|
|
371
|
+
// or just save the non-env parts.
|
|
372
|
+
// For now, we save everything that overrides the defaults.
|
|
373
|
+
await writeFile(configPath, JSON.stringify(config, null, 2));
|
|
374
|
+
} catch (e) {
|
|
375
|
+
console.error("Failed to save config:", e);
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
setupCron(); // Restart cron with new config
|
|
379
|
+
return {
|
|
380
|
+
status: "success",
|
|
381
|
+
config: { ...config, secretAccessKey: "***" },
|
|
382
|
+
jobStatus: getJobStatus(),
|
|
383
|
+
};
|
|
384
|
+
},
|
|
385
|
+
{
|
|
386
|
+
body: t.Object({
|
|
387
|
+
bucket: t.String(),
|
|
388
|
+
endpoint: t.String(),
|
|
389
|
+
sourceDir: t.String(),
|
|
390
|
+
prefix: t.Optional(t.String()),
|
|
391
|
+
extensions: t.Optional(t.Union([t.Array(t.String()), t.String()])), // Allow array or comma-separated string
|
|
392
|
+
accessKeyId: t.String(),
|
|
393
|
+
secretAccessKey: t.String(),
|
|
394
|
+
cronSchedule: t.Optional(t.String()),
|
|
395
|
+
cronEnabled: t.Optional(t.Boolean()),
|
|
396
|
+
}),
|
|
397
|
+
}
|
|
398
|
+
)
|
|
399
|
+
|
|
400
|
+
// UI: Dashboard
|
|
401
|
+
.get("/", ({ set }) => {
|
|
402
|
+
set.headers["Content-Type"] = "text/html; charset=utf8";
|
|
403
|
+
const jobStatus = getJobStatus();
|
|
404
|
+
return `
|
|
405
|
+
<!DOCTYPE html>
|
|
406
|
+
<html lang="en">
|
|
407
|
+
<head>
|
|
408
|
+
<meta charset="UTF-8">
|
|
409
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
410
|
+
<title>R2 Backup Manager</title>
|
|
411
|
+
<script src="https://cdn.tailwindcss.com"></script>
|
|
412
|
+
<script>
|
|
413
|
+
tailwind.config = {
|
|
414
|
+
theme: {
|
|
415
|
+
extend: {
|
|
416
|
+
fontFamily: {
|
|
417
|
+
sans: ['Montserrat', 'sans-serif'],
|
|
418
|
+
},
|
|
419
|
+
colors: {
|
|
420
|
+
gray: {
|
|
421
|
+
50: '#F9FAFB',
|
|
422
|
+
100: '#F3F4F6',
|
|
423
|
+
200: '#E5E7EB',
|
|
424
|
+
300: '#D1D5DB',
|
|
425
|
+
400: '#9CA3AF',
|
|
426
|
+
500: '#6B7280',
|
|
427
|
+
600: '#4B5563',
|
|
428
|
+
700: '#374151',
|
|
429
|
+
800: '#1F2937',
|
|
430
|
+
900: '#111827',
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
</script>
|
|
437
|
+
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js"></script>
|
|
438
|
+
<script src="https://unpkg.com/lucide@latest"></script>
|
|
439
|
+
<link href="https://fonts.googleapis.com/css2?family=Montserrat:wght@400;500;600;700&display=swap" rel="stylesheet">
|
|
440
|
+
<style>
|
|
441
|
+
[x-cloak] { display: none !important; }
|
|
442
|
+
</style>
|
|
443
|
+
</head>
|
|
444
|
+
<body class="bg-gray-50 text-gray-900 min-h-screen p-12 antialiased selection:bg-gray-900 selection:text-white">
|
|
445
|
+
<div class="max-w-5xl mx-auto" x-data="backupApp()">
|
|
446
|
+
<!-- Header -->
|
|
447
|
+
<div class="flex flex-col md:flex-row md:items-end justify-between mb-16 gap-6">
|
|
448
|
+
<div>
|
|
449
|
+
<h6 class="text-xs font-bold tracking-widest text-gray-500 uppercase mb-2 flex items-center gap-2">
|
|
450
|
+
<i data-lucide="shield-check" class="w-4 h-4"></i>
|
|
451
|
+
System Administration
|
|
452
|
+
</h6>
|
|
453
|
+
<h1 class="text-4xl font-bold text-gray-900 tracking-tight flex items-center gap-3">
|
|
454
|
+
Backup Manager
|
|
455
|
+
</h1>
|
|
456
|
+
</div>
|
|
457
|
+
|
|
458
|
+
<!-- Tabs -->
|
|
459
|
+
<div class="flex p-1 bg-gray-200/50 rounded-xl">
|
|
460
|
+
<button @click="activeTab = 'dashboard'; $nextTick(() => lucide.createIcons())"
|
|
461
|
+
:class="activeTab === 'dashboard' ? 'bg-white text-gray-900 shadow-sm' : 'text-gray-500 hover:text-gray-700'"
|
|
462
|
+
class="px-6 py-2.5 rounded-lg text-sm font-semibold transition-all duration-200 flex items-center gap-2">
|
|
463
|
+
<i data-lucide="layout-dashboard" class="w-4 h-4"></i>
|
|
464
|
+
Overview
|
|
465
|
+
</button>
|
|
466
|
+
<button @click="activeTab = 'files'; fetchFiles()"
|
|
467
|
+
:class="activeTab === 'files' ? 'bg-white text-gray-900 shadow-sm' : 'text-gray-500 hover:text-gray-700'"
|
|
468
|
+
class="px-6 py-2.5 rounded-lg text-sm font-semibold transition-all duration-200 flex items-center gap-2">
|
|
469
|
+
<i data-lucide="folder-open" class="w-4 h-4"></i>
|
|
470
|
+
Files & Restore
|
|
471
|
+
</button>
|
|
472
|
+
<button @click="activeTab = 'settings'; $nextTick(() => lucide.createIcons())"
|
|
473
|
+
:class="activeTab === 'settings' ? 'bg-white text-gray-900 shadow-sm' : 'text-gray-500 hover:text-gray-700'"
|
|
474
|
+
class="px-6 py-2.5 rounded-lg text-sm font-semibold transition-all duration-200 flex items-center gap-2">
|
|
475
|
+
<i data-lucide="settings" class="w-4 h-4"></i>
|
|
476
|
+
Settings
|
|
477
|
+
</button>
|
|
478
|
+
</div>
|
|
479
|
+
</div>
|
|
480
|
+
|
|
481
|
+
<!-- Dashboard Tab -->
|
|
482
|
+
<div x-show="activeTab === 'dashboard'" class="space-y-12"
|
|
483
|
+
x-transition:enter="transition ease-out duration-300"
|
|
484
|
+
x-transition:enter-start="opacity-0 translate-y-2"
|
|
485
|
+
x-transition:enter-end="opacity-100 translate-y-0">
|
|
486
|
+
|
|
487
|
+
<!-- Status Cards -->
|
|
488
|
+
<div class="grid grid-cols-1 md:grid-cols-4 gap-4">
|
|
489
|
+
<div class="bg-white p-8 rounded-2xl border border-gray-100 shadow-[0_2px_8px_rgba(0,0,0,0.04)]">
|
|
490
|
+
<div class="text-gray-500 text-sm font-medium mb-2 flex items-center gap-2">
|
|
491
|
+
<i data-lucide="folder" class="w-4 h-4"></i>
|
|
492
|
+
Local Source
|
|
493
|
+
</div>
|
|
494
|
+
<div class="text-gray-900 font-semibold truncate" x-text="config.sourceDir"></div>
|
|
495
|
+
</div>
|
|
496
|
+
<div class="bg-white p-8 rounded-2xl border border-gray-100 shadow-[0_2px_8px_rgba(0,0,0,0.04)]">
|
|
497
|
+
<div class="text-gray-500 text-sm font-medium mb-2 flex items-center gap-2">
|
|
498
|
+
<i data-lucide="cloud" class="w-4 h-4"></i>
|
|
499
|
+
Target Bucket
|
|
500
|
+
</div>
|
|
501
|
+
<div class="text-gray-900 font-semibold truncate" x-text="config.bucket"></div>
|
|
502
|
+
</div>
|
|
503
|
+
<div class="bg-white p-8 rounded-2xl border border-gray-100 shadow-[0_2px_8px_rgba(0,0,0,0.04)]">
|
|
504
|
+
<div class="text-gray-500 text-sm font-medium mb-2 flex items-center gap-2">
|
|
505
|
+
<i data-lucide="clock" class="w-4 h-4"></i>
|
|
506
|
+
Last Backup
|
|
507
|
+
</div>
|
|
508
|
+
<div class="text-gray-900 font-semibold" x-text="lastBackup || 'No backup recorded'"></div>
|
|
509
|
+
</div>
|
|
510
|
+
<div class="bg-white p-8 rounded-2xl border border-gray-100 shadow-[0_2px_8px_rgba(0,0,0,0.04)] relative overflow-hidden group">
|
|
511
|
+
<div class="text-gray-500 text-sm font-medium mb-3 flex items-center gap-2">
|
|
512
|
+
<i data-lucide="calendar-clock" class="w-4 h-4"></i>
|
|
513
|
+
Schedule Status
|
|
514
|
+
</div>
|
|
515
|
+
|
|
516
|
+
<template x-if="cronStatus.isRunning">
|
|
517
|
+
<div class="flex items-center gap-3">
|
|
518
|
+
<span class="relative flex h-2.5 w-2.5 shrink-0" title="Active">
|
|
519
|
+
<span class="animate-ping absolute inline-flex h-full w-full rounded-full bg-green-400 opacity-75"></span>
|
|
520
|
+
<span class="relative inline-flex rounded-full h-2.5 w-2.5 bg-green-500"></span>
|
|
521
|
+
</span>
|
|
522
|
+
<div class="flex items-baseline gap-1.5 min-w-0">
|
|
523
|
+
<span class="text-gray-900 font-bold text-xs font-mono truncate" x-text="new Date(cronStatus.nextRun).toLocaleString(undefined, { dateStyle: 'short', timeStyle: 'medium' })"></span>
|
|
524
|
+
</div>
|
|
525
|
+
</div>
|
|
526
|
+
</template>
|
|
527
|
+
|
|
528
|
+
<template x-if="!cronStatus.isRunning">
|
|
529
|
+
<div class="flex items-center gap-3">
|
|
530
|
+
<span class="h-2.5 w-2.5 rounded-full bg-gray-300 shrink-0" title="Stopped"></span>
|
|
531
|
+
<span class="text-gray-400 text-sm italic">Scheduler paused</span>
|
|
532
|
+
</div>
|
|
533
|
+
</template>
|
|
534
|
+
</div>
|
|
535
|
+
</div>
|
|
536
|
+
|
|
537
|
+
<!-- Action Area -->
|
|
538
|
+
<div class="bg-white rounded-2xl border border-gray-100 shadow-[0_2px_8px_rgba(0,0,0,0.04)] overflow-hidden">
|
|
539
|
+
<div class="p-10 flex flex-col items-center justify-center text-center">
|
|
540
|
+
<div class="w-16 h-16 bg-gray-50 rounded-full flex items-center justify-center mb-6 text-gray-400">
|
|
541
|
+
<i data-lucide="save" class="w-8 h-8"></i>
|
|
542
|
+
</div>
|
|
543
|
+
<h3 class="text-xl font-bold text-gray-900 mb-2">Trigger Manual Backup</h3>
|
|
544
|
+
<p class="text-gray-500 max-w-md mb-8 leading-relaxed">Initiate a backup of your local directory to the configured R2 bucket. This will upload all files recursively.</p>
|
|
545
|
+
|
|
546
|
+
<button
|
|
547
|
+
@click="runBackup()"
|
|
548
|
+
:disabled="loading"
|
|
549
|
+
class="group relative inline-flex items-center justify-center px-8 py-3.5 text-base font-semibold text-white transition-all duration-200 bg-gray-900 rounded-xl hover:bg-gray-800 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-gray-900 disabled:opacity-70 disabled:cursor-not-allowed">
|
|
550
|
+
<span x-show="!loading" class="flex items-center gap-2">
|
|
551
|
+
<i data-lucide="play-circle" class="w-5 h-5"></i>
|
|
552
|
+
Start Backup Process
|
|
553
|
+
</span>
|
|
554
|
+
<span x-show="loading" class="flex items-center gap-2">
|
|
555
|
+
<i data-lucide="loader-2" class="w-5 h-5 animate-spin"></i>
|
|
556
|
+
Processing...
|
|
557
|
+
</span>
|
|
558
|
+
</button>
|
|
559
|
+
</div>
|
|
560
|
+
|
|
561
|
+
<!-- Logs -->
|
|
562
|
+
<div class="bg-gray-50 border-t border-gray-100 p-8">
|
|
563
|
+
<h4 class="text-xs font-bold text-gray-400 uppercase tracking-wider mb-4">Activity Log</h4>
|
|
564
|
+
<div class="space-y-3">
|
|
565
|
+
<template x-for="log in logs" :key="log.id">
|
|
566
|
+
<div class="flex items-start gap-4 text-sm">
|
|
567
|
+
<span class="font-mono text-xs text-gray-400 mt-0.5" x-text="log.time"></span>
|
|
568
|
+
<div class="flex items-center gap-2">
|
|
569
|
+
<i :data-lucide="log.type === 'error' ? 'alert-circle' : 'info'"
|
|
570
|
+
:class="log.type === 'error' ? 'text-red-600' : 'text-gray-400'"
|
|
571
|
+
class="w-4 h-4"></i>
|
|
572
|
+
<span :class="log.type === 'error' ? 'text-red-600 font-medium' : 'text-gray-600'" x-text="log.message"></span>
|
|
573
|
+
</div>
|
|
574
|
+
</div>
|
|
575
|
+
</template>
|
|
576
|
+
<div x-show="logs.length === 0" class="text-gray-400 text-sm italic">No recent activity recorded.</div>
|
|
577
|
+
</div>
|
|
578
|
+
</div>
|
|
579
|
+
</div>
|
|
580
|
+
</div>
|
|
581
|
+
|
|
582
|
+
<!-- Files Tab -->
|
|
583
|
+
<div x-show="activeTab === 'files'" class="bg-white rounded-2xl border border-gray-100 shadow-[0_2px_8px_rgba(0,0,0,0.04)] overflow-hidden"
|
|
584
|
+
x-transition:enter="transition ease-out duration-300"
|
|
585
|
+
x-transition:enter-start="opacity-0 translate-y-2"
|
|
586
|
+
x-transition:enter-end="opacity-100 translate-y-0">
|
|
587
|
+
<div class="p-8 border-b border-gray-100 flex justify-between items-center">
|
|
588
|
+
<div>
|
|
589
|
+
<h2 class="text-xl font-bold text-gray-900 flex items-center gap-2">
|
|
590
|
+
<i data-lucide="server" class="w-5 h-5 text-gray-400"></i>
|
|
591
|
+
Remote Files
|
|
592
|
+
</h2>
|
|
593
|
+
<p class="text-gray-500 text-sm mt-1">Files currently stored in your R2 bucket</p>
|
|
594
|
+
</div>
|
|
595
|
+
<button @click="fetchFiles()" class="text-gray-900 hover:text-gray-600 font-semibold text-sm transition-colors flex items-center gap-2">
|
|
596
|
+
<i data-lucide="refresh-cw" class="w-4 h-4"></i>
|
|
597
|
+
Refresh List
|
|
598
|
+
</button>
|
|
599
|
+
</div>
|
|
600
|
+
<div class="overflow-x-auto">
|
|
601
|
+
<table class="w-full text-left">
|
|
602
|
+
<thead>
|
|
603
|
+
<tr class="border-b border-gray-100">
|
|
604
|
+
<th class="px-8 py-4 text-xs font-bold text-gray-400 uppercase tracking-wider">File Key</th>
|
|
605
|
+
<th class="px-8 py-4 text-xs font-bold text-gray-400 uppercase tracking-wider">Size</th>
|
|
606
|
+
<th class="px-8 py-4 text-xs font-bold text-gray-400 uppercase tracking-wider">Last Modified</th>
|
|
607
|
+
<th class="px-8 py-4 text-xs font-bold text-gray-400 uppercase tracking-wider text-right">Action</th>
|
|
608
|
+
</tr>
|
|
609
|
+
</thead>
|
|
610
|
+
<template x-for="group in groups" :key="group.name">
|
|
611
|
+
<tbody class="divide-y divide-gray-50 border-b border-gray-100">
|
|
612
|
+
<!-- Folder Header -->
|
|
613
|
+
<tr class="bg-gray-50 hover:bg-gray-100 cursor-pointer transition-colors select-none"
|
|
614
|
+
@click="group.expanded = !group.expanded; $nextTick(() => lucide.createIcons())">
|
|
615
|
+
<td colspan="4" class="px-8 py-3">
|
|
616
|
+
<div class="flex items-center gap-3">
|
|
617
|
+
<i :data-lucide="group.expanded ? 'folder-open' : 'folder'" class="w-5 h-5 text-gray-400"></i>
|
|
618
|
+
<span class="font-bold text-gray-700 text-sm" x-text="formatDateHeader(group.name)"></span>
|
|
619
|
+
<span class="text-xs font-medium text-gray-500 bg-gray-200 px-2 py-0.5 rounded-full" x-text="group.files.length"></span>
|
|
620
|
+
</div>
|
|
621
|
+
</td>
|
|
622
|
+
</tr>
|
|
623
|
+
|
|
624
|
+
<!-- Files in Group -->
|
|
625
|
+
<template x-for="file in group.files" :key="file.key">
|
|
626
|
+
<tr x-show="group.expanded" class="hover:bg-gray-50 transition-colors duration-150 group bg-white">
|
|
627
|
+
<td class="px-8 py-5 font-medium text-gray-900 text-sm pl-12" x-text="file.key"></td>
|
|
628
|
+
<td class="px-8 py-5 text-gray-500 text-sm" x-text="formatBytes(file.size)"></td>
|
|
629
|
+
<td class="px-8 py-5 text-gray-500 text-sm" x-text="new Date(file.lastModified).toLocaleString()"></td>
|
|
630
|
+
<td class="px-8 py-5 text-right">
|
|
631
|
+
<div class="flex justify-end gap-3 items-center">
|
|
632
|
+
<!-- Restore Button -->
|
|
633
|
+
<button
|
|
634
|
+
x-data="holdButton(() => restoreFile(file.key))"
|
|
635
|
+
@mousedown="start()" @touchstart.prevent="start()"
|
|
636
|
+
@mouseup="stop()" @mouseleave="stop()" @touchend="stop()"
|
|
637
|
+
class="relative overflow-hidden px-4 py-2 rounded-lg text-xs font-bold uppercase tracking-wider text-blue-600 bg-blue-50 hover:bg-blue-100 transition-colors select-none"
|
|
638
|
+
title="Hold 3s to Restore"
|
|
639
|
+
>
|
|
640
|
+
<div class="absolute inset-0 bg-blue-200/50 origin-left transition-all duration-0 ease-linear" :style="'width: ' + progress + '%'"></div>
|
|
641
|
+
<span class="relative z-10 flex items-center gap-2">
|
|
642
|
+
<i data-lucide="rotate-ccw" class="w-3 h-3"></i>
|
|
643
|
+
<span x-text="progress > 0 ? 'Hold...' : 'Restore'"></span>
|
|
644
|
+
</span>
|
|
645
|
+
</button>
|
|
646
|
+
|
|
647
|
+
<!-- Delete Button -->
|
|
648
|
+
<button
|
|
649
|
+
x-data="holdButton(() => deleteFile(file.key))"
|
|
650
|
+
@mousedown="start()" @touchstart.prevent="start()"
|
|
651
|
+
@mouseup="stop()" @mouseleave="stop()" @touchend="stop()"
|
|
652
|
+
class="relative overflow-hidden px-4 py-2 rounded-lg text-xs font-bold uppercase tracking-wider text-red-600 bg-red-50 hover:bg-red-100 transition-colors select-none"
|
|
653
|
+
title="Hold 3s to Delete"
|
|
654
|
+
>
|
|
655
|
+
<div class="absolute inset-0 bg-red-200/50 origin-left transition-all duration-0 ease-linear" :style="'width: ' + progress + '%'"></div>
|
|
656
|
+
<span class="relative z-10 flex items-center gap-2">
|
|
657
|
+
<i data-lucide="trash-2" class="w-3 h-3"></i>
|
|
658
|
+
<span x-text="progress > 0 ? 'Hold...' : 'Delete'"></span>
|
|
659
|
+
</span>
|
|
660
|
+
</button>
|
|
661
|
+
</div>
|
|
662
|
+
</td>
|
|
663
|
+
</tr>
|
|
664
|
+
</template>
|
|
665
|
+
</tbody>
|
|
666
|
+
</template>
|
|
667
|
+
|
|
668
|
+
<!-- Empty State (outside the loop) -->
|
|
669
|
+
<tbody x-show="groups.length === 0">
|
|
670
|
+
<tr>
|
|
671
|
+
<td colspan="4" class="px-8 py-16 text-center">
|
|
672
|
+
<div class="flex flex-col items-center justify-center">
|
|
673
|
+
<span x-show="!loadingFiles" class="text-gray-500 font-medium flex flex-col items-center gap-2">
|
|
674
|
+
<i data-lucide="inbox" class="w-8 h-8 text-gray-300"></i>
|
|
675
|
+
No files found in bucket
|
|
676
|
+
</span>
|
|
677
|
+
<span x-show="loadingFiles" class="text-gray-500 font-medium animate-pulse flex flex-col items-center gap-2">
|
|
678
|
+
<i data-lucide="loader" class="w-8 h-8 animate-spin text-gray-300"></i>
|
|
679
|
+
Loading remote files...
|
|
680
|
+
</span>
|
|
681
|
+
</div>
|
|
682
|
+
</td>
|
|
683
|
+
</tr>
|
|
684
|
+
</tbody>
|
|
685
|
+
</table>
|
|
686
|
+
</div>
|
|
687
|
+
</div>
|
|
688
|
+
|
|
689
|
+
<!-- Settings Tab -->
|
|
690
|
+
<div x-show="activeTab === 'settings'" class="max-w-3xl mx-auto"
|
|
691
|
+
x-transition:enter="transition ease-out duration-300"
|
|
692
|
+
x-transition:enter-start="opacity-0 translate-y-2"
|
|
693
|
+
x-transition:enter-end="opacity-100 translate-y-0">
|
|
694
|
+
<div class="bg-white rounded-2xl border border-gray-100 shadow-[0_2px_8px_rgba(0,0,0,0.04)] p-10">
|
|
695
|
+
<h2 class="text-xl font-bold text-gray-900 mb-8 flex items-center gap-2">
|
|
696
|
+
<i data-lucide="sliders" class="w-5 h-5"></i>
|
|
697
|
+
Configuration
|
|
698
|
+
</h2>
|
|
699
|
+
<form @submit.prevent="saveConfig" class="space-y-8">
|
|
700
|
+
<div class="space-y-6">
|
|
701
|
+
<div>
|
|
702
|
+
<label class="block text-sm font-semibold text-gray-700 mb-2">Endpoint URL</label>
|
|
703
|
+
<input type="text" x-model="configForm.endpoint" class="w-full bg-gray-50 border border-gray-200 rounded-lg px-4 py-3 text-gray-900 focus:ring-2 focus:ring-gray-900 focus:border-transparent outline-none transition-all font-medium">
|
|
704
|
+
</div>
|
|
705
|
+
<div class="grid grid-cols-2 gap-6">
|
|
706
|
+
<div>
|
|
707
|
+
<label class="block text-sm font-semibold text-gray-700 mb-2">Bucket Name</label>
|
|
708
|
+
<input type="text" x-model="configForm.bucket" class="w-full bg-gray-50 border border-gray-200 rounded-lg px-4 py-3 text-gray-900 focus:ring-2 focus:ring-gray-900 focus:border-transparent outline-none transition-all font-medium">
|
|
709
|
+
</div>
|
|
710
|
+
<div>
|
|
711
|
+
<label class="block text-sm font-semibold text-gray-700 mb-2">Prefix (Folder)</label>
|
|
712
|
+
<input type="text" x-model="configForm.prefix" class="w-full bg-gray-50 border border-gray-200 rounded-lg px-4 py-3 text-gray-900 focus:ring-2 focus:ring-gray-900 focus:border-transparent outline-none transition-all font-medium">
|
|
713
|
+
</div>
|
|
714
|
+
</div>
|
|
715
|
+
<div>
|
|
716
|
+
<label class="block text-sm font-semibold text-gray-700 mb-2">Source Directory (Local)</label>
|
|
717
|
+
<input type="text" x-model="configForm.sourceDir" readonly class="w-full bg-gray-100 border border-gray-200 rounded-lg px-4 py-3 text-gray-500 cursor-not-allowed focus:outline-none font-medium">
|
|
718
|
+
<p class="text-xs text-gray-400 mt-1">Defined in server configuration</p>
|
|
719
|
+
</div>
|
|
720
|
+
<div>
|
|
721
|
+
<label class="block text-sm font-semibold text-gray-700 mb-2">Allowed Extensions (comma separated)</label>
|
|
722
|
+
<input type="text" x-model="configForm.extensions" placeholder=".db, .sqlite" class="w-full bg-gray-50 border border-gray-200 rounded-lg px-4 py-3 text-gray-900 focus:ring-2 focus:ring-gray-900 focus:border-transparent outline-none transition-all font-medium">
|
|
723
|
+
</div>
|
|
724
|
+
<div>
|
|
725
|
+
<label class="block text-sm font-semibold text-gray-700 mb-2">Cron Schedule</label>
|
|
726
|
+
<div class="flex gap-4 items-center">
|
|
727
|
+
<div class="relative flex-grow">
|
|
728
|
+
<input type="text" x-model="configForm.cronSchedule" placeholder="0 0 * * *" class="w-full bg-gray-50 border border-gray-200 rounded-lg px-4 py-3 text-gray-900 focus:ring-2 focus:ring-gray-900 focus:border-transparent outline-none transition-all font-medium">
|
|
729
|
+
<div class="absolute right-3 top-1/2 -translate-y-1/2 text-xs text-gray-500">
|
|
730
|
+
<a href="https://crontab.guru/" target="_blank" class="underline hover:text-gray-800">Help</a>
|
|
731
|
+
</div>
|
|
732
|
+
</div>
|
|
733
|
+
<div class="flex gap-2 shrink-0">
|
|
734
|
+
<button
|
|
735
|
+
type="button"
|
|
736
|
+
@click="configForm.cronEnabled = true; saveConfig()"
|
|
737
|
+
:class="configForm.cronEnabled !== false ? 'bg-green-600 text-white shadow-md' : 'bg-gray-100 text-gray-400 hover:bg-gray-200'"
|
|
738
|
+
class="px-4 py-3 rounded-lg font-bold text-sm transition-all flex items-center gap-2"
|
|
739
|
+
>
|
|
740
|
+
<i data-lucide="play" class="w-4 h-4"></i>
|
|
741
|
+
Start
|
|
742
|
+
</button>
|
|
743
|
+
<button
|
|
744
|
+
type="button"
|
|
745
|
+
@click="configForm.cronEnabled = false; saveConfig()"
|
|
746
|
+
:class="configForm.cronEnabled === false ? 'bg-red-600 text-white shadow-md' : 'bg-gray-100 text-gray-400 hover:bg-gray-200'"
|
|
747
|
+
class="px-4 py-3 rounded-lg font-bold text-sm transition-all flex items-center gap-2"
|
|
748
|
+
>
|
|
749
|
+
<i data-lucide="square" class="w-4 h-4"></i>
|
|
750
|
+
Stop
|
|
751
|
+
</button>
|
|
752
|
+
</div>
|
|
753
|
+
</div>
|
|
754
|
+
<p class="text-xs text-gray-400 mt-1">Format: Minute Hour Day Month DayOfWeek (e.g., "0 0 * * *" for daily at midnight)</p>
|
|
755
|
+
</div>
|
|
756
|
+
<div class="grid grid-cols-2 gap-6">
|
|
757
|
+
<div>
|
|
758
|
+
<label class="block text-sm font-semibold text-gray-700 mb-2">Access Key ID</label>
|
|
759
|
+
<input type="text" x-model="configForm.accessKeyId" class="w-full bg-gray-50 border border-gray-200 rounded-lg px-4 py-3 text-gray-900 focus:ring-2 focus:ring-gray-900 focus:border-transparent outline-none transition-all font-medium">
|
|
760
|
+
</div>
|
|
761
|
+
<div>
|
|
762
|
+
<label class="block text-sm font-semibold text-gray-700 mb-2">Secret Access Key</label>
|
|
763
|
+
<input type="password" x-model="configForm.secretAccessKey" class="w-full bg-gray-50 border border-gray-200 rounded-lg px-4 py-3 text-gray-900 focus:ring-2 focus:ring-gray-900 focus:border-transparent outline-none transition-all font-medium">
|
|
764
|
+
</div>
|
|
765
|
+
</div>
|
|
766
|
+
</div>
|
|
767
|
+
|
|
768
|
+
<div class="pt-4 flex justify-end">
|
|
769
|
+
<button type="submit" class="bg-gray-900 hover:bg-gray-800 text-white font-bold py-3 px-8 rounded-xl transition-all shadow-lg hover:shadow-xl transform hover:-translate-y-0.5 flex items-center gap-2">
|
|
770
|
+
<i data-lucide="save" class="w-4 h-4"></i>
|
|
771
|
+
Save Changes
|
|
772
|
+
</button>
|
|
773
|
+
</div>
|
|
774
|
+
</form>
|
|
775
|
+
</div>
|
|
776
|
+
</div>
|
|
777
|
+
</div>
|
|
778
|
+
<script>
|
|
779
|
+
document.addEventListener('alpine:init', () => {
|
|
780
|
+
Alpine.data('holdButton', (action) => ({
|
|
781
|
+
progress: 0,
|
|
782
|
+
interval: null,
|
|
783
|
+
start() {
|
|
784
|
+
this.progress = 0
|
|
785
|
+
// 3 seconds = 3000ms. Update every 30ms. 100 steps.
|
|
786
|
+
// 100% / 100 steps = 1% per step.
|
|
787
|
+
this.interval = setInterval(() => {
|
|
788
|
+
this.progress += 1
|
|
789
|
+
if (this.progress >= 100) {
|
|
790
|
+
this.trigger()
|
|
791
|
+
}
|
|
792
|
+
}, 30)
|
|
793
|
+
},
|
|
794
|
+
stop() {
|
|
795
|
+
clearInterval(this.interval)
|
|
796
|
+
this.progress = 0
|
|
797
|
+
},
|
|
798
|
+
trigger() {
|
|
799
|
+
this.stop()
|
|
800
|
+
action()
|
|
801
|
+
}
|
|
802
|
+
}))
|
|
803
|
+
})
|
|
804
|
+
|
|
805
|
+
function backupApp() {
|
|
806
|
+
return {
|
|
807
|
+
activeTab: 'dashboard',
|
|
808
|
+
loading: false,
|
|
809
|
+
loadingFiles: false,
|
|
810
|
+
lastBackup: null,
|
|
811
|
+
files: [],
|
|
812
|
+
groups: [],
|
|
813
|
+
logs: [],
|
|
814
|
+
config: ${JSON.stringify(config)},
|
|
815
|
+
cronStatus: ${JSON.stringify(jobStatus)},
|
|
816
|
+
configForm: { ...${JSON.stringify(config)} },
|
|
817
|
+
|
|
818
|
+
init() {
|
|
819
|
+
// Initial load if needed
|
|
820
|
+
this.$nextTick(() => {
|
|
821
|
+
lucide.createIcons()
|
|
822
|
+
})
|
|
823
|
+
},
|
|
824
|
+
|
|
825
|
+
addLog(message, type = 'info') {
|
|
826
|
+
this.logs.unshift({
|
|
827
|
+
id: Date.now(),
|
|
828
|
+
message,
|
|
829
|
+
type,
|
|
830
|
+
time: new Date().toLocaleTimeString()
|
|
831
|
+
})
|
|
832
|
+
this.$nextTick(() => lucide.createIcons())
|
|
833
|
+
},
|
|
834
|
+
|
|
835
|
+
formatBytes(bytes, decimals = 2) {
|
|
836
|
+
if (!+bytes) return '0 Bytes'
|
|
837
|
+
const k = 1024
|
|
838
|
+
const dm = decimals < 0 ? 0 : decimals
|
|
839
|
+
const sizes = ['Bytes', 'KiB', 'MiB', 'GiB', 'TiB']
|
|
840
|
+
const i = Math.floor(Math.log(bytes) / Math.log(k))
|
|
841
|
+
return \`\${parseFloat((bytes / Math.pow(k, i)).toFixed(dm))} \${sizes[i]}\`
|
|
842
|
+
},
|
|
843
|
+
|
|
844
|
+
formatDateHeader(dateStr) {
|
|
845
|
+
if (dateStr === 'Others') return 'Others';
|
|
846
|
+
// dateStr is YYYY-MM-DD
|
|
847
|
+
// Create date object treating the string as local time components to avoid timezone shifts
|
|
848
|
+
const [y, m, d] = dateStr.split('-').map(Number);
|
|
849
|
+
const date = new Date(y, m - 1, d);
|
|
850
|
+
return date.toLocaleDateString(undefined, { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric' });
|
|
851
|
+
},
|
|
852
|
+
|
|
853
|
+
async runBackup() {
|
|
854
|
+
this.loading = true
|
|
855
|
+
|
|
856
|
+
// Generate local timestamp: YYYY-MM-DD_HH-mm-ss
|
|
857
|
+
const now = new Date()
|
|
858
|
+
const timestamp = now.getFullYear() + '-' +
|
|
859
|
+
String(now.getMonth() + 1).padStart(2, '0') + '-' +
|
|
860
|
+
String(now.getDate()).padStart(2, '0') + '_' +
|
|
861
|
+
String(now.getHours()).padStart(2, '0') + '-' +
|
|
862
|
+
String(now.getMinutes()).padStart(2, '0') + '-' +
|
|
863
|
+
String(now.getSeconds()).padStart(2, '0')
|
|
864
|
+
|
|
865
|
+
try {
|
|
866
|
+
const res = await fetch('/backup/api/run', {
|
|
867
|
+
method: 'POST',
|
|
868
|
+
headers: { 'Content-Type': 'application/json' },
|
|
869
|
+
body: JSON.stringify({ timestamp })
|
|
870
|
+
})
|
|
871
|
+
const data = await res.json()
|
|
872
|
+
if (data.status === 'success') {
|
|
873
|
+
this.lastBackup = new Date().toLocaleString()
|
|
874
|
+
this.addLog('Backup completed successfully', 'success')
|
|
875
|
+
this.fetchFiles() // Refresh file list
|
|
876
|
+
} else {
|
|
877
|
+
throw new Error(data.message)
|
|
878
|
+
}
|
|
879
|
+
} catch (err) {
|
|
880
|
+
this.addLog('Backup failed: ' + err.message, 'error')
|
|
881
|
+
} finally {
|
|
882
|
+
this.loading = false
|
|
883
|
+
}
|
|
884
|
+
},
|
|
885
|
+
|
|
886
|
+
async fetchFiles() {
|
|
887
|
+
this.loadingFiles = true
|
|
888
|
+
try {
|
|
889
|
+
const res = await fetch('/backup/api/files')
|
|
890
|
+
const data = await res.json()
|
|
891
|
+
if (data.files) {
|
|
892
|
+
// 1. Sort files by Key Descending (Newest first)
|
|
893
|
+
const sortedFiles = data.files.sort((a, b) => b.key.localeCompare(a.key));
|
|
894
|
+
|
|
895
|
+
// 2. Group by Date
|
|
896
|
+
const groupsMap = {};
|
|
897
|
+
sortedFiles.forEach(file => {
|
|
898
|
+
// Extract YYYY-MM-DD from the filename (handles prefixes like 'backups/2025-...')
|
|
899
|
+
// Looks for YYYY-MM-DD followed by underscore or T (for ISO)
|
|
900
|
+
// Note: Backslashes must be double-escaped in this server-side template string
|
|
901
|
+
const match = file.key.match(/(?:^|\\/)(\\d{4}-\\d{2}-\\d{2})[_T]/);
|
|
902
|
+
const dateKey = match ? match[1] : 'Others';
|
|
903
|
+
|
|
904
|
+
if (!groupsMap[dateKey]) {
|
|
905
|
+
groupsMap[dateKey] = [];
|
|
906
|
+
}
|
|
907
|
+
groupsMap[dateKey].push(file);
|
|
908
|
+
});
|
|
909
|
+
|
|
910
|
+
// 3. Convert to array and sort groups descending
|
|
911
|
+
this.groups = Object.keys(groupsMap)
|
|
912
|
+
.sort()
|
|
913
|
+
.reverse()
|
|
914
|
+
.map((dateKey, index) => ({
|
|
915
|
+
name: dateKey,
|
|
916
|
+
files: groupsMap[dateKey],
|
|
917
|
+
expanded: false // Start with all folders collapsed
|
|
918
|
+
}));
|
|
919
|
+
|
|
920
|
+
this.$nextTick(() => lucide.createIcons())
|
|
921
|
+
}
|
|
922
|
+
} catch (err) {
|
|
923
|
+
this.addLog('Failed to fetch files: ' + err.message, 'error')
|
|
924
|
+
} finally {
|
|
925
|
+
this.loadingFiles = false
|
|
926
|
+
}
|
|
927
|
+
},
|
|
928
|
+
|
|
929
|
+
async restoreFile(key) {
|
|
930
|
+
// Removed confirm dialog in favor of hold button
|
|
931
|
+
try {
|
|
932
|
+
const res = await fetch('/backup/api/restore', {
|
|
933
|
+
method: 'POST',
|
|
934
|
+
headers: { 'Content-Type': 'application/json' },
|
|
935
|
+
body: JSON.stringify({ key })
|
|
936
|
+
})
|
|
937
|
+
const data = await res.json()
|
|
938
|
+
if (data.status === 'success') {
|
|
939
|
+
this.addLog(data.message, 'success')
|
|
940
|
+
} else {
|
|
941
|
+
throw new Error(data.message)
|
|
942
|
+
}
|
|
943
|
+
} catch (err) {
|
|
944
|
+
this.addLog('Restore failed: ' + err.message, 'error')
|
|
945
|
+
}
|
|
946
|
+
},
|
|
947
|
+
|
|
948
|
+
async deleteFile(key) {
|
|
949
|
+
try {
|
|
950
|
+
const res = await fetch('/backup/api/delete', {
|
|
951
|
+
method: 'POST',
|
|
952
|
+
headers: { 'Content-Type': 'application/json' },
|
|
953
|
+
body: JSON.stringify({ key })
|
|
954
|
+
})
|
|
955
|
+
const data = await res.json()
|
|
956
|
+
if (data.status === 'success') {
|
|
957
|
+
this.addLog(data.message, 'success')
|
|
958
|
+
this.fetchFiles() // Refresh list
|
|
959
|
+
} else {
|
|
960
|
+
throw new Error(data.message)
|
|
961
|
+
}
|
|
962
|
+
} catch (err) {
|
|
963
|
+
this.addLog('Delete failed: ' + err.message, 'error')
|
|
964
|
+
}
|
|
965
|
+
},
|
|
966
|
+
|
|
967
|
+
async saveConfig() {
|
|
968
|
+
try {
|
|
969
|
+
const res = await fetch('/backup/api/config', {
|
|
970
|
+
method: 'POST',
|
|
971
|
+
headers: { 'Content-Type': 'application/json' },
|
|
972
|
+
body: JSON.stringify(this.configForm)
|
|
973
|
+
})
|
|
974
|
+
const data = await res.json()
|
|
975
|
+
if (data.status === 'success') {
|
|
976
|
+
this.config = data.config
|
|
977
|
+
if (data.jobStatus) this.cronStatus = data.jobStatus
|
|
978
|
+
this.addLog('Configuration updated', 'success')
|
|
979
|
+
this.activeTab = 'dashboard'
|
|
980
|
+
}
|
|
981
|
+
} catch (err) {
|
|
982
|
+
this.addLog('Failed to save config: ' + err.message, 'error')
|
|
983
|
+
}
|
|
984
|
+
}
|
|
985
|
+
}
|
|
986
|
+
}
|
|
987
|
+
</script>
|
|
988
|
+
</body>
|
|
989
|
+
</html>
|
|
990
|
+
`;
|
|
991
|
+
})
|
|
992
|
+
);
|
|
993
|
+
};
|