mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-07-26 11:49:16 +00:00
feat(cloud-backup): mirror fleet snapshots to S3-compatible storage (#782)
* feat(cloud-backup): mirror fleet snapshots to S3-compatible storage
Add an Admiral-tier Cloud Backup feature that replicates every fleet
snapshot to off-site storage, with two provider modes that share the
same `@aws-sdk/client-s3` code path:
- Sencho Cloud Backup: zero-config, 500 MB allowance backed by
Cloudflare R2, provisioned via the sencho.io worker against the
user's Lemon Squeezy license.
- Custom S3 (BYOB): any S3-compatible bucket (AWS, MinIO, Backblaze
B2, Wasabi, R2 with own keys), with credentials encrypted via
`CryptoService` before storage.
API-triggered snapshots upload fire-and-forget so the UI returns
immediately; scheduled snapshots block on the upload so the task's
success/failure reflects cloud durability. Object keys include the
instance_id segment to prevent collisions when the same Admiral
license is activated on multiple Sencho instances.
* fix(cloud-backup): drop ES2022-only Error cause arg breaking ES2020 build
The backend tsconfig pins lib to ES2020. The two-argument
`Error(message, { cause })` form requires ES2022, so tsc rejected it
with TS2554. Revert to single-argument throw to match the
convention used elsewhere in the backend services.
This commit is contained in:
Generated
+1016
-393
File diff suppressed because it is too large
Load Diff
@@ -28,6 +28,7 @@
|
||||
"@types/multer": "^2.1.0",
|
||||
"@types/node": "^25.3.0",
|
||||
"@types/supertest": "^7.2.0",
|
||||
"@types/tar-stream": "^3.1.4",
|
||||
"@types/yaml": "^1.9.6",
|
||||
"eslint": "^10.1.0",
|
||||
"nodemon": "^3.1.13",
|
||||
@@ -39,6 +40,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-ecr": "^3.1019.0",
|
||||
"@aws-sdk/client-s3": "^3.1037.0",
|
||||
"@types/compression": "^1.8.1",
|
||||
"@types/cors": "^2.8.19",
|
||||
"@types/dockerode": "^4.0.1",
|
||||
@@ -69,6 +71,7 @@
|
||||
"otplib": "^12.0.1",
|
||||
"semver": "^7.7.4",
|
||||
"systeminformation": "^5.31.1",
|
||||
"tar-stream": "^3.1.8",
|
||||
"ws": "^8.19.0",
|
||||
"yaml": "^2.8.2",
|
||||
"zod": "^4.3.6"
|
||||
|
||||
@@ -0,0 +1,193 @@
|
||||
/**
|
||||
* Tests for /api/cloud-backup routes — tier gating (community/skipper/admiral),
|
||||
* admin gating, config CRUD round-trip with secret encryption, audit logging.
|
||||
* The S3 SDK is mocked at the module level so no network calls happen.
|
||||
*/
|
||||
import { describe, it, expect, beforeAll, afterAll, beforeEach, vi } from 'vitest';
|
||||
import request from 'supertest';
|
||||
import { setupTestDb, cleanupTestDb, loginAsTestAdmin } from './helpers/setupTestDb';
|
||||
|
||||
const sentSpy = vi.fn();
|
||||
|
||||
vi.mock('@aws-sdk/client-s3', () => {
|
||||
class S3Client { async send(cmd: { name: string; input: Record<string, unknown> }) { return sentSpy(cmd); } }
|
||||
class PutObjectCommand { name = 'PutObjectCommand'; constructor(public input: Record<string, unknown>) {} }
|
||||
class GetObjectCommand { name = 'GetObjectCommand'; constructor(public input: Record<string, unknown>) {} }
|
||||
class ListObjectsV2Command { name = 'ListObjectsV2Command'; constructor(public input: Record<string, unknown>) {} }
|
||||
class DeleteObjectCommand { name = 'DeleteObjectCommand'; constructor(public input: Record<string, unknown>) {} }
|
||||
class HeadBucketCommand { name = 'HeadBucketCommand'; constructor(public input: Record<string, unknown>) {} }
|
||||
return { S3Client, PutObjectCommand, GetObjectCommand, ListObjectsV2Command, DeleteObjectCommand, HeadBucketCommand };
|
||||
});
|
||||
|
||||
let tmpDir: string;
|
||||
let app: import('express').Express;
|
||||
let DatabaseService: typeof import('../services/DatabaseService').DatabaseService;
|
||||
let LicenseService: typeof import('../services/LicenseService').LicenseService;
|
||||
let authCookie: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
tmpDir = await setupTestDb();
|
||||
({ DatabaseService } = await import('../services/DatabaseService'));
|
||||
({ LicenseService } = await import('../services/LicenseService'));
|
||||
|
||||
vi.spyOn(LicenseService.getInstance(), 'getTier').mockReturnValue('paid');
|
||||
vi.spyOn(LicenseService.getInstance(), 'getVariant').mockReturnValue('admiral');
|
||||
|
||||
({ app } = await import('../index'));
|
||||
authCookie = await loginAsTestAdmin(app);
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
cleanupTestDb(tmpDir);
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
sentSpy.mockReset();
|
||||
const db = DatabaseService.getInstance();
|
||||
for (const k of [
|
||||
'cloud_backup_provider',
|
||||
'cloud_backup_endpoint',
|
||||
'cloud_backup_region',
|
||||
'cloud_backup_bucket',
|
||||
'cloud_backup_access_key',
|
||||
'cloud_backup_secret_key',
|
||||
'cloud_backup_path_prefix',
|
||||
'cloud_backup_auto_upload',
|
||||
]) {
|
||||
db.updateGlobalSetting(k, '');
|
||||
}
|
||||
});
|
||||
|
||||
describe('Cloud backup tier gating', () => {
|
||||
it('rejects community tier with PAID_REQUIRED', async () => {
|
||||
vi.spyOn(LicenseService.getInstance(), 'getTier').mockReturnValueOnce('community');
|
||||
const res = await request(app).get('/api/cloud-backup/config').set('Cookie', authCookie);
|
||||
expect(res.status).toBe(403);
|
||||
expect(res.body.code).toBe('PAID_REQUIRED');
|
||||
});
|
||||
|
||||
it('rejects skipper tier with ADMIRAL_REQUIRED', async () => {
|
||||
vi.spyOn(LicenseService.getInstance(), 'getTier').mockReturnValueOnce('paid');
|
||||
vi.spyOn(LicenseService.getInstance(), 'getVariant').mockReturnValueOnce('skipper');
|
||||
const res = await request(app).get('/api/cloud-backup/config').set('Cookie', authCookie);
|
||||
expect(res.status).toBe(403);
|
||||
expect(res.body.code).toBe('ADMIRAL_REQUIRED');
|
||||
});
|
||||
|
||||
it('admiral tier reaches the handler', async () => {
|
||||
const res = await request(app).get('/api/cloud-backup/config').set('Cookie', authCookie);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toHaveProperty('provider', 'disabled');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Cloud backup config CRUD', () => {
|
||||
it('redacts secret_key on read; persists encrypted ciphertext', async () => {
|
||||
const putRes = await request(app)
|
||||
.put('/api/cloud-backup/config')
|
||||
.set('Cookie', authCookie)
|
||||
.send({
|
||||
provider: 'custom',
|
||||
custom: {
|
||||
endpoint: 'https://s3.example.com',
|
||||
region: 'us-east-1',
|
||||
bucket: 'b',
|
||||
access_key: 'AKIA1234',
|
||||
secret_key: 'super-secret',
|
||||
path_prefix: 'sencho/',
|
||||
auto_upload: true,
|
||||
},
|
||||
});
|
||||
expect(putRes.status).toBe(204);
|
||||
|
||||
const stored = DatabaseService.getInstance().getGlobalSettings().cloud_backup_secret_key;
|
||||
expect(stored.startsWith('enc:')).toBe(true);
|
||||
expect(stored.includes('super-secret')).toBe(false);
|
||||
|
||||
const getRes = await request(app).get('/api/cloud-backup/config').set('Cookie', authCookie);
|
||||
expect(getRes.status).toBe(200);
|
||||
expect(getRes.body.provider).toBe('custom');
|
||||
expect(getRes.body.custom.secret_key).toBe('***');
|
||||
expect(getRes.body.custom.bucket).toBe('b');
|
||||
});
|
||||
|
||||
it('preserves saved secret when client sends "***"', async () => {
|
||||
const db = DatabaseService.getInstance();
|
||||
await request(app)
|
||||
.put('/api/cloud-backup/config')
|
||||
.set('Cookie', authCookie)
|
||||
.send({
|
||||
provider: 'custom',
|
||||
custom: { endpoint: 'https://e', region: 'r', bucket: 'b', access_key: 'a', secret_key: 'first-secret', path_prefix: 's/', auto_upload: false },
|
||||
});
|
||||
const firstStored = db.getGlobalSettings().cloud_backup_secret_key;
|
||||
|
||||
await request(app)
|
||||
.put('/api/cloud-backup/config')
|
||||
.set('Cookie', authCookie)
|
||||
.send({
|
||||
provider: 'custom',
|
||||
custom: { endpoint: 'https://e', region: 'r2', bucket: 'b', access_key: 'a', secret_key: '***', path_prefix: 's/', auto_upload: true },
|
||||
});
|
||||
const secondStored = db.getGlobalSettings().cloud_backup_secret_key;
|
||||
expect(secondStored).toBe(firstStored);
|
||||
expect(db.getGlobalSettings().cloud_backup_region).toBe('r2');
|
||||
expect(db.getGlobalSettings().cloud_backup_auto_upload).toBe('1');
|
||||
});
|
||||
|
||||
it('rejects invalid provider value', async () => {
|
||||
const res = await request(app)
|
||||
.put('/api/cloud-backup/config')
|
||||
.set('Cookie', authCookie)
|
||||
.send({ provider: 'bogus' });
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it('rejects custom config missing required fields', async () => {
|
||||
const res = await request(app)
|
||||
.put('/api/cloud-backup/config')
|
||||
.set('Cookie', authCookie)
|
||||
.send({ provider: 'custom', custom: { endpoint: '', bucket: '', access_key: '' } });
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Cloud backup audit log', () => {
|
||||
it('writes audit row with the cloud-backup summary on PUT /config', async () => {
|
||||
await request(app)
|
||||
.put('/api/cloud-backup/config')
|
||||
.set('Cookie', authCookie)
|
||||
.send({
|
||||
provider: 'custom',
|
||||
custom: { endpoint: 'https://s3.example.com', region: 'r', bucket: 'b', access_key: 'a', secret_key: 's', path_prefix: 'p/', auto_upload: false },
|
||||
});
|
||||
const { entries } = DatabaseService.getInstance().getAuditLogs({ limit: 50 });
|
||||
const cloudEntry = entries.find(e => e.path.includes('/cloud-backup/config') && e.method === 'PUT');
|
||||
expect(cloudEntry).toBeDefined();
|
||||
expect(cloudEntry!.summary).toBe('Updated cloud backup config');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Cloud backup test endpoint', () => {
|
||||
it('reports failure when no provider is configured', async () => {
|
||||
const res = await request(app).post('/api/cloud-backup/test').set('Cookie', authCookie).send({});
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.success).toBe(false);
|
||||
});
|
||||
|
||||
it('reports success when HeadBucketCommand resolves', async () => {
|
||||
const db = DatabaseService.getInstance();
|
||||
const { CryptoService } = await import('../services/CryptoService');
|
||||
db.updateGlobalSetting('cloud_backup_provider', 'custom');
|
||||
db.updateGlobalSetting('cloud_backup_endpoint', 'https://s3.example.com');
|
||||
db.updateGlobalSetting('cloud_backup_region', 'us-east-1');
|
||||
db.updateGlobalSetting('cloud_backup_bucket', 'b');
|
||||
db.updateGlobalSetting('cloud_backup_access_key', 'a');
|
||||
db.updateGlobalSetting('cloud_backup_secret_key', CryptoService.getInstance().encrypt('s'));
|
||||
|
||||
sentSpy.mockResolvedValueOnce({});
|
||||
const res = await request(app).post('/api/cloud-backup/test').set('Cookie', authCookie).send({});
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.success).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,242 @@
|
||||
/**
|
||||
* Tests for CloudBackupService — provider resolution, encryption round-trip,
|
||||
* archive format, and S3 client invocation. The S3 SDK is mocked at the
|
||||
* module level so no network calls happen.
|
||||
*/
|
||||
import { describe, it, expect, beforeAll, afterAll, beforeEach, vi } from 'vitest';
|
||||
import * as zlib from 'zlib';
|
||||
import * as tar from 'tar-stream';
|
||||
import { Readable } from 'stream';
|
||||
import { setupTestDb, cleanupTestDb } from './helpers/setupTestDb';
|
||||
|
||||
const sentSpy = vi.fn();
|
||||
const s3ClientCtorSpy = vi.fn();
|
||||
|
||||
vi.mock('@aws-sdk/client-s3', () => {
|
||||
class S3Client {
|
||||
constructor(opts: unknown) { s3ClientCtorSpy(opts); }
|
||||
async send(cmd: { name: string; input: Record<string, unknown> }) { return sentSpy(cmd); }
|
||||
}
|
||||
class PutObjectCommand { name = 'PutObjectCommand'; constructor(public input: Record<string, unknown>) {} }
|
||||
class GetObjectCommand { name = 'GetObjectCommand'; constructor(public input: Record<string, unknown>) {} }
|
||||
class ListObjectsV2Command { name = 'ListObjectsV2Command'; constructor(public input: Record<string, unknown>) {} }
|
||||
class DeleteObjectCommand { name = 'DeleteObjectCommand'; constructor(public input: Record<string, unknown>) {} }
|
||||
class HeadBucketCommand { name = 'HeadBucketCommand'; constructor(public input: Record<string, unknown>) {} }
|
||||
return { S3Client, PutObjectCommand, GetObjectCommand, ListObjectsV2Command, DeleteObjectCommand, HeadBucketCommand };
|
||||
});
|
||||
|
||||
let tmpDir: string;
|
||||
let DatabaseService: typeof import('../services/DatabaseService').DatabaseService;
|
||||
let CryptoService: typeof import('../services/CryptoService').CryptoService;
|
||||
let CloudBackupService: typeof import('../services/CloudBackupService').CloudBackupService;
|
||||
|
||||
beforeAll(async () => {
|
||||
tmpDir = await setupTestDb();
|
||||
({ DatabaseService } = await import('../services/DatabaseService'));
|
||||
({ CryptoService } = await import('../services/CryptoService'));
|
||||
({ CloudBackupService } = await import('../services/CloudBackupService'));
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
CloudBackupService.getInstance().stop();
|
||||
cleanupTestDb(tmpDir);
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
sentSpy.mockReset();
|
||||
s3ClientCtorSpy.mockReset();
|
||||
const db = DatabaseService.getInstance();
|
||||
// Reset all cloud-backup-related settings between tests.
|
||||
for (const k of [
|
||||
'cloud_backup_provider',
|
||||
'cloud_backup_endpoint',
|
||||
'cloud_backup_region',
|
||||
'cloud_backup_bucket',
|
||||
'cloud_backup_access_key',
|
||||
'cloud_backup_secret_key',
|
||||
'cloud_backup_path_prefix',
|
||||
'cloud_backup_auto_upload',
|
||||
]) {
|
||||
db.updateGlobalSetting(k, '');
|
||||
}
|
||||
for (const k of [
|
||||
'sencho_cloud_backup_endpoint',
|
||||
'sencho_cloud_backup_bucket',
|
||||
'sencho_cloud_backup_access_key',
|
||||
'sencho_cloud_backup_secret_key',
|
||||
'sencho_cloud_backup_path_prefix',
|
||||
'sencho_cloud_backup_quota_bytes',
|
||||
'sencho_cloud_backup_provisioned_at',
|
||||
]) {
|
||||
db.setSystemState(k, '');
|
||||
}
|
||||
db.setSystemState('instance_id', 'test-instance-id');
|
||||
});
|
||||
|
||||
describe('CloudBackupService — provider resolution', () => {
|
||||
it('returns "disabled" when no provider is set', () => {
|
||||
expect(CloudBackupService.getInstance().getProvider()).toBe('disabled');
|
||||
expect(CloudBackupService.getInstance().isEnabled()).toBe(false);
|
||||
expect(CloudBackupService.getInstance().getResolvedConfig()).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null config for custom provider when fields are missing', () => {
|
||||
const db = DatabaseService.getInstance();
|
||||
db.updateGlobalSetting('cloud_backup_provider', 'custom');
|
||||
db.updateGlobalSetting('cloud_backup_endpoint', 'https://s3.example.com');
|
||||
// bucket, access_key, secret_key still missing
|
||||
expect(CloudBackupService.getInstance().getResolvedConfig()).toBeNull();
|
||||
});
|
||||
|
||||
it('decrypts custom secret_key on read', () => {
|
||||
const db = DatabaseService.getInstance();
|
||||
const crypto = CryptoService.getInstance();
|
||||
db.updateGlobalSetting('cloud_backup_provider', 'custom');
|
||||
db.updateGlobalSetting('cloud_backup_endpoint', 'https://s3.example.com');
|
||||
db.updateGlobalSetting('cloud_backup_region', 'us-east-1');
|
||||
db.updateGlobalSetting('cloud_backup_bucket', 'my-bucket');
|
||||
db.updateGlobalSetting('cloud_backup_access_key', 'AKIA1234');
|
||||
db.updateGlobalSetting('cloud_backup_secret_key', crypto.encrypt('plaintext-secret'));
|
||||
db.updateGlobalSetting('cloud_backup_auto_upload', '1');
|
||||
|
||||
const cfg = CloudBackupService.getInstance().getResolvedConfig();
|
||||
expect(cfg).not.toBeNull();
|
||||
expect(cfg!.provider).toBe('custom');
|
||||
expect(cfg!.secretKey).toBe('plaintext-secret');
|
||||
expect(cfg!.autoUpload).toBe(true);
|
||||
});
|
||||
|
||||
it('resolves sencho provider from system_state and forces auto_upload on', () => {
|
||||
const db = DatabaseService.getInstance();
|
||||
const crypto = CryptoService.getInstance();
|
||||
db.updateGlobalSetting('cloud_backup_provider', 'sencho');
|
||||
db.setSystemState('sencho_cloud_backup_endpoint', 'https://r2.example.com');
|
||||
db.setSystemState('sencho_cloud_backup_bucket', 'sencho-cloud-backups');
|
||||
db.setSystemState('sencho_cloud_backup_access_key', 'R2-ACCESS');
|
||||
db.setSystemState('sencho_cloud_backup_secret_key', crypto.encrypt('R2-SECRET'));
|
||||
db.setSystemState('sencho_cloud_backup_path_prefix', 'tenants/123/');
|
||||
db.setSystemState('sencho_cloud_backup_quota_bytes', '524288000');
|
||||
|
||||
const cfg = CloudBackupService.getInstance().getResolvedConfig();
|
||||
expect(cfg!.provider).toBe('sencho');
|
||||
expect(cfg!.region).toBe('auto');
|
||||
expect(cfg!.secretKey).toBe('R2-SECRET');
|
||||
expect(cfg!.autoUpload).toBe(true);
|
||||
expect(cfg!.quotaBytes).toBe(524_288_000);
|
||||
});
|
||||
});
|
||||
|
||||
describe('CloudBackupService — uploadSnapshot', () => {
|
||||
function seedCustomProvider() {
|
||||
const db = DatabaseService.getInstance();
|
||||
const crypto = CryptoService.getInstance();
|
||||
db.updateGlobalSetting('cloud_backup_provider', 'custom');
|
||||
db.updateGlobalSetting('cloud_backup_endpoint', 'https://s3.example.com');
|
||||
db.updateGlobalSetting('cloud_backup_region', 'us-east-1');
|
||||
db.updateGlobalSetting('cloud_backup_bucket', 'my-bucket');
|
||||
db.updateGlobalSetting('cloud_backup_access_key', 'AKIA1234');
|
||||
db.updateGlobalSetting('cloud_backup_secret_key', crypto.encrypt('test-secret'));
|
||||
db.updateGlobalSetting('cloud_backup_path_prefix', 'sencho/');
|
||||
db.updateGlobalSetting('cloud_backup_auto_upload', '1');
|
||||
}
|
||||
|
||||
it('uploads a snapshot with correct object key and gzipped tar archive', async () => {
|
||||
seedCustomProvider();
|
||||
const db = DatabaseService.getInstance();
|
||||
|
||||
const snapshotId = db.createSnapshot('Test backup', 'admin', 1, 1, '[]');
|
||||
db.insertSnapshotFiles(snapshotId, [
|
||||
{ nodeId: 1, nodeName: 'gateway', stackName: 'web', filename: 'compose.yaml', content: 'services: {}\n' },
|
||||
{ nodeId: 1, nodeName: 'gateway', stackName: 'web', filename: '.env', content: 'KEY=value\n' },
|
||||
]);
|
||||
|
||||
sentSpy.mockResolvedValue({});
|
||||
await CloudBackupService.getInstance().uploadSnapshot(snapshotId);
|
||||
|
||||
expect(s3ClientCtorSpy).toHaveBeenCalledWith(expect.objectContaining({
|
||||
endpoint: 'https://s3.example.com',
|
||||
region: 'us-east-1',
|
||||
forcePathStyle: true,
|
||||
credentials: { accessKeyId: 'AKIA1234', secretAccessKey: 'test-secret' },
|
||||
}));
|
||||
|
||||
const putCall = sentSpy.mock.calls.find(c => c[0].name === 'PutObjectCommand');
|
||||
expect(putCall).toBeDefined();
|
||||
const input = putCall![0].input as { Bucket: string; Key: string; Body: Buffer; ContentType: string };
|
||||
expect(input.Bucket).toBe('my-bucket');
|
||||
expect(input.Key).toContain('sencho/instances/test-instance-id/snapshots/');
|
||||
expect(input.Key).toMatch(/\.tar\.gz$/);
|
||||
expect(input.ContentType).toBe('application/gzip');
|
||||
expect(Buffer.isBuffer(input.Body)).toBe(true);
|
||||
expect(input.Body.byteLength).toBeGreaterThan(0);
|
||||
|
||||
const decompressed = zlib.gunzipSync(input.Body);
|
||||
const entries: Array<{ name: string; content: string }> = await new Promise((resolve, reject) => {
|
||||
const extract = tar.extract();
|
||||
const list: Array<{ name: string; content: string }> = [];
|
||||
extract.on('entry', (header, stream, next) => {
|
||||
const chunks: Buffer[] = [];
|
||||
stream.on('data', (c: Buffer) => chunks.push(c));
|
||||
stream.on('end', () => { list.push({ name: header.name, content: Buffer.concat(chunks).toString('utf-8') }); next(); });
|
||||
stream.resume();
|
||||
});
|
||||
extract.on('finish', () => resolve(list));
|
||||
extract.on('error', reject);
|
||||
Readable.from(decompressed).pipe(extract);
|
||||
});
|
||||
|
||||
const meta = entries.find(e => e.name === 'metadata.json');
|
||||
expect(meta).toBeDefined();
|
||||
const parsed = JSON.parse(meta!.content);
|
||||
expect(parsed.id).toBe(snapshotId);
|
||||
expect(parsed.instance_id).toBe('test-instance-id');
|
||||
expect(parsed.archive_version).toBe(1);
|
||||
|
||||
expect(entries.find(e => e.name === 'nodes/1_gateway/web/compose.yaml')).toBeDefined();
|
||||
expect(entries.find(e => e.name === 'nodes/1_gateway/web/.env')).toBeDefined();
|
||||
|
||||
expect(CloudBackupService.getInstance().getUploadStatus(snapshotId).status).toBe('success');
|
||||
});
|
||||
|
||||
it('records failure status when upload throws', async () => {
|
||||
seedCustomProvider();
|
||||
const db = DatabaseService.getInstance();
|
||||
const snapshotId = db.createSnapshot('Failing', 'admin', 0, 0, '[]');
|
||||
|
||||
sentSpy.mockRejectedValueOnce(new Error('AccessDenied: bad creds'));
|
||||
await expect(CloudBackupService.getInstance().uploadSnapshot(snapshotId)).rejects.toThrow(/bad creds/);
|
||||
|
||||
const status = CloudBackupService.getInstance().getUploadStatus(snapshotId);
|
||||
expect(status.status).toBe('failed');
|
||||
expect(status.error).toContain('bad creds');
|
||||
});
|
||||
|
||||
it('throws when no provider is configured', async () => {
|
||||
await expect(CloudBackupService.getInstance().uploadSnapshot(999)).rejects.toThrow(/not configured/i);
|
||||
});
|
||||
});
|
||||
|
||||
describe('CloudBackupService — listCloudSnapshots', () => {
|
||||
it('parses snapshot ID from object key and sorts by lastModified desc', async () => {
|
||||
const db = DatabaseService.getInstance();
|
||||
const crypto = CryptoService.getInstance();
|
||||
db.updateGlobalSetting('cloud_backup_provider', 'custom');
|
||||
db.updateGlobalSetting('cloud_backup_endpoint', 'https://s3.example.com');
|
||||
db.updateGlobalSetting('cloud_backup_region', 'us-east-1');
|
||||
db.updateGlobalSetting('cloud_backup_bucket', 'b');
|
||||
db.updateGlobalSetting('cloud_backup_access_key', 'a');
|
||||
db.updateGlobalSetting('cloud_backup_secret_key', crypto.encrypt('s'));
|
||||
db.updateGlobalSetting('cloud_backup_path_prefix', 'sencho/');
|
||||
|
||||
sentSpy.mockResolvedValueOnce({
|
||||
Contents: [
|
||||
{ Key: 'sencho/instances/test-instance-id/snapshots/3_2026-01-01_a.tar.gz', Size: 100, LastModified: new Date('2026-01-01T00:00:00Z') },
|
||||
{ Key: 'sencho/instances/test-instance-id/snapshots/7_2026-04-01_b.tar.gz', Size: 200, LastModified: new Date('2026-04-01T00:00:00Z') },
|
||||
],
|
||||
});
|
||||
const list = await CloudBackupService.getInstance().listCloudSnapshots();
|
||||
expect(list).toHaveLength(2);
|
||||
expect(list[0].snapshotId).toBe(7);
|
||||
expect(list[1].snapshotId).toBe(3);
|
||||
});
|
||||
});
|
||||
@@ -145,6 +145,16 @@ vi.mock('../services/NotificationService', () => ({
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('../services/CloudBackupService', () => ({
|
||||
CloudBackupService: {
|
||||
getInstance: () => ({
|
||||
isEnabled: () => false,
|
||||
isAutoUploadOn: () => false,
|
||||
uploadSnapshot: vi.fn().mockResolvedValue(undefined),
|
||||
}),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('../services/NodeRegistry', () => ({
|
||||
NodeRegistry: {
|
||||
getInstance: () => ({
|
||||
|
||||
@@ -18,6 +18,7 @@ import { webhooksRouter } from './routes/webhooks';
|
||||
import { usersRouter } from './routes/users';
|
||||
import { gitSourcesRouter, stackGitSourceRouter } from './routes/gitSources';
|
||||
import { fleetRouter } from './routes/fleet';
|
||||
import { cloudBackupRouter } from './routes/cloudBackup';
|
||||
import { permissionsRouter } from './routes/permissions';
|
||||
import { convertRouter } from './routes/convert';
|
||||
import { alertsRouter } from './routes/alerts';
|
||||
@@ -91,6 +92,7 @@ app.use('/api/stacks', stackLabelsRouter);
|
||||
app.use('/api/api-tokens', apiTokensRouter);
|
||||
app.use('/api/audit-log', auditLogRouter);
|
||||
app.use('/api/fleet', fleetRouter);
|
||||
app.use('/api/cloud-backup', cloudBackupRouter);
|
||||
app.use('/api/webhooks', webhooksRouter);
|
||||
app.use('/api/users', usersRouter);
|
||||
app.use('/api/git-sources', gitSourcesRouter);
|
||||
|
||||
@@ -0,0 +1,243 @@
|
||||
import { Router, type Request, type Response } from 'express';
|
||||
import { CloudBackupService } from '../services/CloudBackupService';
|
||||
import { DatabaseService } from '../services/DatabaseService';
|
||||
import { CryptoService } from '../services/CryptoService';
|
||||
import { requireAdmin, requireAdmiral } from '../middleware/tierGates';
|
||||
import { rejectApiTokenScope } from '../middleware/apiTokenScope';
|
||||
import { getErrorMessage } from '../utils/errors';
|
||||
|
||||
const SCOPE_MESSAGE = 'API tokens cannot manage cloud backup configuration.';
|
||||
const SECRET_REDACTED = '***';
|
||||
const VALID_PROVIDERS = new Set(['disabled', 'sencho', 'custom']);
|
||||
|
||||
function parseSnapshotIdParam(req: Request, res: Response): number | null {
|
||||
const raw = req.params.id as string | undefined;
|
||||
const parsed = parseInt(raw ?? '', 10);
|
||||
if (isNaN(parsed) || parsed <= 0) {
|
||||
res.status(400).json({ error: 'Invalid snapshot ID' });
|
||||
return null;
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function decodeObjectKey(req: Request, res: Response): string | null {
|
||||
const raw = req.params.keyB64 as string | undefined;
|
||||
if (!raw) {
|
||||
res.status(400).json({ error: 'Missing object key' });
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
const decoded = Buffer.from(raw, 'base64url').toString('utf-8');
|
||||
if (!decoded || decoded.includes('..') || decoded.startsWith('/')) {
|
||||
res.status(400).json({ error: 'Invalid object key' });
|
||||
return null;
|
||||
}
|
||||
return decoded;
|
||||
} catch {
|
||||
res.status(400).json({ error: 'Invalid object key encoding' });
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export const cloudBackupRouter = Router();
|
||||
|
||||
cloudBackupRouter.get('/config', (req: Request, res: Response): void => {
|
||||
if (rejectApiTokenScope(req, res, SCOPE_MESSAGE)) return;
|
||||
if (!requireAdmiral(req, res)) return;
|
||||
try {
|
||||
const db = DatabaseService.getInstance();
|
||||
const settings = db.getGlobalSettings();
|
||||
const provider = CloudBackupService.getInstance().getProvider();
|
||||
const senchoProvisioned = !!db.getSystemState('sencho_cloud_backup_provisioned_at');
|
||||
res.json({
|
||||
provider,
|
||||
sencho_provisioned: senchoProvisioned,
|
||||
sencho_provisioned_at: db.getSystemState('sencho_cloud_backup_provisioned_at'),
|
||||
custom: {
|
||||
endpoint: settings.cloud_backup_endpoint || '',
|
||||
region: settings.cloud_backup_region || '',
|
||||
bucket: settings.cloud_backup_bucket || '',
|
||||
access_key: settings.cloud_backup_access_key || '',
|
||||
secret_key: settings.cloud_backup_secret_key ? SECRET_REDACTED : '',
|
||||
path_prefix: settings.cloud_backup_path_prefix || 'sencho/',
|
||||
auto_upload: settings.cloud_backup_auto_upload === '1',
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('[CloudBackup] config get error:', error);
|
||||
res.status(500).json({ error: 'Failed to load cloud backup config' });
|
||||
}
|
||||
});
|
||||
|
||||
cloudBackupRouter.put('/config', (req: Request, res: Response): void => {
|
||||
if (rejectApiTokenScope(req, res, SCOPE_MESSAGE)) return;
|
||||
if (!requireAdmin(req, res)) return;
|
||||
if (!requireAdmiral(req, res)) return;
|
||||
try {
|
||||
const body = req.body ?? {};
|
||||
const provider = body.provider as string | undefined;
|
||||
if (!provider || !VALID_PROVIDERS.has(provider)) {
|
||||
res.status(400).json({ error: 'provider must be one of: disabled, sencho, custom' });
|
||||
return;
|
||||
}
|
||||
const db = DatabaseService.getInstance();
|
||||
const crypto = CryptoService.getInstance();
|
||||
db.updateGlobalSetting('cloud_backup_provider', provider);
|
||||
|
||||
if (provider === 'custom') {
|
||||
const c = body.custom ?? {};
|
||||
const endpoint = typeof c.endpoint === 'string' ? c.endpoint.trim() : '';
|
||||
const region = typeof c.region === 'string' ? c.region.trim() : '';
|
||||
const bucket = typeof c.bucket === 'string' ? c.bucket.trim() : '';
|
||||
const accessKey = typeof c.access_key === 'string' ? c.access_key.trim() : '';
|
||||
const pathPrefix = typeof c.path_prefix === 'string' ? c.path_prefix.trim() : 'sencho/';
|
||||
const autoUpload = c.auto_upload === true || c.auto_upload === '1' ? '1' : '0';
|
||||
|
||||
if (!endpoint || !bucket || !accessKey) {
|
||||
res.status(400).json({ error: 'endpoint, bucket, and access_key are required for custom S3.' });
|
||||
return;
|
||||
}
|
||||
if (!/^https?:\/\//i.test(endpoint)) {
|
||||
res.status(400).json({ error: 'endpoint must start with http:// or https://' });
|
||||
return;
|
||||
}
|
||||
|
||||
db.updateGlobalSetting('cloud_backup_endpoint', endpoint);
|
||||
db.updateGlobalSetting('cloud_backup_region', region);
|
||||
db.updateGlobalSetting('cloud_backup_bucket', bucket);
|
||||
db.updateGlobalSetting('cloud_backup_access_key', accessKey);
|
||||
db.updateGlobalSetting('cloud_backup_path_prefix', pathPrefix);
|
||||
db.updateGlobalSetting('cloud_backup_auto_upload', autoUpload);
|
||||
|
||||
const incomingSecret = typeof c.secret_key === 'string' ? c.secret_key : '';
|
||||
if (incomingSecret && incomingSecret !== SECRET_REDACTED) {
|
||||
db.updateGlobalSetting('cloud_backup_secret_key', crypto.encrypt(incomingSecret));
|
||||
}
|
||||
}
|
||||
|
||||
res.status(204).send();
|
||||
} catch (error) {
|
||||
console.error('[CloudBackup] config update error:', error);
|
||||
res.status(500).json({ error: 'Failed to save cloud backup config' });
|
||||
}
|
||||
});
|
||||
|
||||
cloudBackupRouter.post('/test', async (req: Request, res: Response): Promise<void> => {
|
||||
if (rejectApiTokenScope(req, res, SCOPE_MESSAGE)) return;
|
||||
if (!requireAdmin(req, res)) return;
|
||||
if (!requireAdmiral(req, res)) return;
|
||||
try {
|
||||
const result = await CloudBackupService.getInstance().testConnection();
|
||||
res.json(result);
|
||||
} catch (error) {
|
||||
console.error('[CloudBackup] test error:', error);
|
||||
res.status(500).json({ success: false, error: getErrorMessage(error, 'Connection test failed') });
|
||||
}
|
||||
});
|
||||
|
||||
cloudBackupRouter.post('/provision', async (req: Request, res: Response): Promise<void> => {
|
||||
if (rejectApiTokenScope(req, res, SCOPE_MESSAGE)) return;
|
||||
if (!requireAdmin(req, res)) return;
|
||||
if (!requireAdmiral(req, res)) return;
|
||||
try {
|
||||
const result = await CloudBackupService.getInstance().provisionSenchoCloudBackup();
|
||||
if (!result.success) {
|
||||
res.status(400).json({ error: result.error || 'Provisioning failed' });
|
||||
return;
|
||||
}
|
||||
res.json({ success: true, quota_bytes: result.quotaBytes });
|
||||
} catch (error) {
|
||||
console.error('[CloudBackup] provision error:', error);
|
||||
res.status(500).json({ error: 'Failed to provision Sencho Cloud Backup' });
|
||||
}
|
||||
});
|
||||
|
||||
cloudBackupRouter.get('/usage', async (req: Request, res: Response): Promise<void> => {
|
||||
if (rejectApiTokenScope(req, res, SCOPE_MESSAGE)) return;
|
||||
if (!requireAdmiral(req, res)) return;
|
||||
try {
|
||||
const svc = CloudBackupService.getInstance();
|
||||
if (svc.getProvider() !== 'sencho') {
|
||||
res.status(400).json({ error: 'Usage is only available for Sencho Cloud Backup' });
|
||||
return;
|
||||
}
|
||||
const usage = await svc.getSenchoCloudBackupUsage();
|
||||
res.json(usage);
|
||||
} catch (error) {
|
||||
console.error('[CloudBackup] usage error:', error);
|
||||
res.status(502).json({ error: getErrorMessage(error, 'Failed to fetch usage') });
|
||||
}
|
||||
});
|
||||
|
||||
cloudBackupRouter.get('/snapshots', async (req: Request, res: Response): Promise<void> => {
|
||||
if (rejectApiTokenScope(req, res, SCOPE_MESSAGE)) return;
|
||||
if (!requireAdmiral(req, res)) return;
|
||||
try {
|
||||
const entries = await CloudBackupService.getInstance().listCloudSnapshots();
|
||||
res.json(entries);
|
||||
} catch (error) {
|
||||
console.error('[CloudBackup] list error:', error);
|
||||
res.status(502).json({ error: getErrorMessage(error, 'Failed to list cloud snapshots') });
|
||||
}
|
||||
});
|
||||
|
||||
cloudBackupRouter.post('/upload/:id', async (req: Request, res: Response): Promise<void> => {
|
||||
if (rejectApiTokenScope(req, res, SCOPE_MESSAGE)) return;
|
||||
if (!requireAdmin(req, res)) return;
|
||||
if (!requireAdmiral(req, res)) return;
|
||||
const id = parseSnapshotIdParam(req, res);
|
||||
if (id == null) return;
|
||||
try {
|
||||
const db = DatabaseService.getInstance();
|
||||
if (!db.getSnapshot(id)) {
|
||||
res.status(404).json({ error: 'Snapshot not found' });
|
||||
return;
|
||||
}
|
||||
await CloudBackupService.getInstance().uploadSnapshot(id);
|
||||
res.status(202).json({ status: 'success', snapshot_id: id });
|
||||
} catch (error) {
|
||||
console.error('[CloudBackup] upload error:', error);
|
||||
res.status(502).json({ error: getErrorMessage(error, 'Cloud upload failed') });
|
||||
}
|
||||
});
|
||||
|
||||
cloudBackupRouter.get('/status/:id', (req: Request, res: Response): void => {
|
||||
if (rejectApiTokenScope(req, res, SCOPE_MESSAGE)) return;
|
||||
if (!requireAdmiral(req, res)) return;
|
||||
const id = parseSnapshotIdParam(req, res);
|
||||
if (id == null) return;
|
||||
res.json(CloudBackupService.getInstance().getUploadStatus(id));
|
||||
});
|
||||
|
||||
cloudBackupRouter.get('/object/:keyB64/download', async (req: Request, res: Response): Promise<void> => {
|
||||
if (rejectApiTokenScope(req, res, SCOPE_MESSAGE)) return;
|
||||
if (!requireAdmiral(req, res)) return;
|
||||
const objectKey = decodeObjectKey(req, res);
|
||||
if (!objectKey) return;
|
||||
try {
|
||||
const buffer = await CloudBackupService.getInstance().downloadSnapshot(objectKey);
|
||||
const filename = objectKey.split('/').pop() || 'snapshot.tar.gz';
|
||||
res.setHeader('Content-Type', 'application/gzip');
|
||||
res.setHeader('Content-Disposition', `attachment; filename="${filename}"`);
|
||||
res.setHeader('Content-Length', String(buffer.byteLength));
|
||||
res.end(buffer);
|
||||
} catch (error) {
|
||||
console.error('[CloudBackup] download error:', error);
|
||||
res.status(502).json({ error: getErrorMessage(error, 'Failed to download cloud snapshot') });
|
||||
}
|
||||
});
|
||||
|
||||
cloudBackupRouter.delete('/object/:keyB64', async (req: Request, res: Response): Promise<void> => {
|
||||
if (rejectApiTokenScope(req, res, SCOPE_MESSAGE)) return;
|
||||
if (!requireAdmin(req, res)) return;
|
||||
if (!requireAdmiral(req, res)) return;
|
||||
const objectKey = decodeObjectKey(req, res);
|
||||
if (!objectKey) return;
|
||||
try {
|
||||
await CloudBackupService.getInstance().deleteCloudSnapshot(objectKey);
|
||||
res.status(204).send();
|
||||
} catch (error) {
|
||||
console.error('[CloudBackup] delete error:', error);
|
||||
res.status(502).json({ error: getErrorMessage(error, 'Failed to delete cloud snapshot') });
|
||||
}
|
||||
});
|
||||
@@ -20,6 +20,8 @@ import { getLatestVersion } from '../utils/version-check';
|
||||
import { isValidStackName } from '../utils/validation';
|
||||
import { isDebugEnabled } from '../utils/debug';
|
||||
import { getErrorMessage } from '../utils/errors';
|
||||
import { CloudBackupService } from '../services/CloudBackupService';
|
||||
import { NotificationService } from '../services/NotificationService';
|
||||
|
||||
const updateTracker = FleetUpdateTrackerService.getInstance();
|
||||
const UPDATE_TIMEOUT_MS = 5 * 60 * 1000; // 5 minutes
|
||||
@@ -819,6 +821,17 @@ fleetRouter.post('/snapshots', authMiddleware, async (req: Request, res: Respons
|
||||
db.insertSnapshotFiles(snapshotId, allFiles);
|
||||
}
|
||||
|
||||
const cloudSvc = CloudBackupService.getInstance();
|
||||
if (cloudSvc.isEnabled() && cloudSvc.isAutoUploadOn()) {
|
||||
void cloudSvc.uploadSnapshot(snapshotId).catch(uploadErr => {
|
||||
const message = uploadErr instanceof Error ? uploadErr.message : String(uploadErr);
|
||||
console.error('[Fleet Snapshot] Cloud upload failed:', message);
|
||||
void NotificationService.getInstance()
|
||||
.dispatchAlert('error', 'system', `Cloud backup upload failed for snapshot ${snapshotId}: ${message}`)
|
||||
.catch(() => { /* notification dispatch is best-effort */ });
|
||||
});
|
||||
}
|
||||
|
||||
console.log('[Fleet] Snapshot created:', capturedNodes.length, 'nodes,', totalStacks, 'stacks');
|
||||
if (isDebugEnabled()) {
|
||||
console.debug(`[Fleet:debug] Snapshot ${snapshotId} capture completed in ${Date.now() - captureStart}ms, ${allFiles.length} file(s) stored`);
|
||||
|
||||
@@ -0,0 +1,400 @@
|
||||
/**
|
||||
* CloudBackupService — off-site replication for fleet snapshots.
|
||||
*
|
||||
* Two providers share the same S3-compatible code path:
|
||||
* - 'sencho' : managed Sencho Cloud Backup. Credentials provisioned by
|
||||
* sencho.io/api/cloud-backup/provision and stored in system_state.
|
||||
* - 'custom' : bring-your-own-bucket. Credentials user-configured in global_settings.
|
||||
*
|
||||
* Routes call this service after a snapshot is persisted to SQLite. The service
|
||||
* reads snapshot rows from DatabaseService, packs them into a tar.gz archive,
|
||||
* and uploads via @aws-sdk/client-s3.
|
||||
*/
|
||||
|
||||
import {
|
||||
S3Client,
|
||||
PutObjectCommand,
|
||||
GetObjectCommand,
|
||||
ListObjectsV2Command,
|
||||
DeleteObjectCommand,
|
||||
HeadBucketCommand,
|
||||
} from '@aws-sdk/client-s3';
|
||||
import { Readable } from 'stream';
|
||||
import * as zlib from 'zlib';
|
||||
import * as tar from 'tar-stream';
|
||||
import axios from 'axios';
|
||||
import { DatabaseService, type FleetSnapshotFile } from './DatabaseService';
|
||||
import { CryptoService } from './CryptoService';
|
||||
import { LicenseService } from './LicenseService';
|
||||
import { getErrorMessage } from '../utils/errors';
|
||||
import { isDebugEnabled } from '../utils/debug';
|
||||
|
||||
export type CloudProvider = 'disabled' | 'sencho' | 'custom';
|
||||
export type UploadStatus = 'idle' | 'uploading' | 'success' | 'failed';
|
||||
|
||||
export interface ResolvedCloudConfig {
|
||||
provider: 'sencho' | 'custom';
|
||||
endpoint: string;
|
||||
region: string;
|
||||
bucket: string;
|
||||
accessKey: string;
|
||||
secretKey: string;
|
||||
pathPrefix: string;
|
||||
autoUpload: boolean;
|
||||
quotaBytes?: number;
|
||||
}
|
||||
|
||||
export interface CloudSnapshotEntry {
|
||||
objectKey: string;
|
||||
sizeBytes: number;
|
||||
lastModified: string | null;
|
||||
snapshotId: number | null;
|
||||
}
|
||||
|
||||
export interface ProvisionResult {
|
||||
success: boolean;
|
||||
quotaBytes?: number;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface UploadStatusEntry {
|
||||
status: UploadStatus;
|
||||
objectKey?: string;
|
||||
error?: string;
|
||||
updatedAt: number;
|
||||
}
|
||||
|
||||
const SENCHO_CLOUD_BACKUP_API_DEFAULT = 'https://sencho.io';
|
||||
const PROVIDER_KEY = 'cloud_backup_provider';
|
||||
const SUCCESS_STATUS_TTL_MS = 5 * 60 * 1000;
|
||||
|
||||
export class CloudBackupService {
|
||||
private static instance: CloudBackupService;
|
||||
private uploadStatus = new Map<number, UploadStatusEntry>();
|
||||
private statusGcTimer: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
private constructor() {
|
||||
this.statusGcTimer = setInterval(() => this.gcUploadStatus(), 60 * 1000);
|
||||
if (typeof this.statusGcTimer.unref === 'function') this.statusGcTimer.unref();
|
||||
}
|
||||
|
||||
public static getInstance(): CloudBackupService {
|
||||
if (!CloudBackupService.instance) {
|
||||
CloudBackupService.instance = new CloudBackupService();
|
||||
}
|
||||
return CloudBackupService.instance;
|
||||
}
|
||||
|
||||
public stop(): void {
|
||||
if (this.statusGcTimer) {
|
||||
clearInterval(this.statusGcTimer);
|
||||
this.statusGcTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Configuration ─────────────────────────────────────────────────────────
|
||||
|
||||
public getProvider(): CloudProvider {
|
||||
const value = DatabaseService.getInstance().getGlobalSettings()[PROVIDER_KEY];
|
||||
if (value === 'sencho' || value === 'custom') return value;
|
||||
return 'disabled';
|
||||
}
|
||||
|
||||
public isEnabled(): boolean {
|
||||
return this.getResolvedConfig() !== null;
|
||||
}
|
||||
|
||||
public isAutoUploadOn(): boolean {
|
||||
const cfg = this.getResolvedConfig();
|
||||
return cfg?.autoUpload === true;
|
||||
}
|
||||
|
||||
public getResolvedConfig(): ResolvedCloudConfig | null {
|
||||
const provider = this.getProvider();
|
||||
if (provider === 'disabled') return null;
|
||||
const db = DatabaseService.getInstance();
|
||||
const crypto = CryptoService.getInstance();
|
||||
|
||||
if (provider === 'sencho') {
|
||||
const endpoint = db.getSystemState('sencho_cloud_backup_endpoint');
|
||||
const bucket = db.getSystemState('sencho_cloud_backup_bucket');
|
||||
const accessKey = db.getSystemState('sencho_cloud_backup_access_key');
|
||||
const secretRaw = db.getSystemState('sencho_cloud_backup_secret_key');
|
||||
const pathPrefix = db.getSystemState('sencho_cloud_backup_path_prefix') || '';
|
||||
const quotaRaw = db.getSystemState('sencho_cloud_backup_quota_bytes');
|
||||
if (!endpoint || !bucket || !accessKey || !secretRaw) return null;
|
||||
return {
|
||||
provider: 'sencho',
|
||||
endpoint,
|
||||
region: 'auto',
|
||||
bucket,
|
||||
accessKey,
|
||||
secretKey: crypto.decrypt(secretRaw),
|
||||
pathPrefix,
|
||||
autoUpload: true,
|
||||
quotaBytes: quotaRaw ? parseInt(quotaRaw, 10) : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
const settings = db.getGlobalSettings();
|
||||
const endpoint = settings.cloud_backup_endpoint;
|
||||
const region = settings.cloud_backup_region;
|
||||
const bucket = settings.cloud_backup_bucket;
|
||||
const accessKey = settings.cloud_backup_access_key;
|
||||
const secretRaw = settings.cloud_backup_secret_key;
|
||||
const pathPrefix = settings.cloud_backup_path_prefix || 'sencho/';
|
||||
if (!endpoint || !bucket || !accessKey || !secretRaw) return null;
|
||||
return {
|
||||
provider: 'custom',
|
||||
endpoint,
|
||||
region: region || 'us-east-1',
|
||||
bucket,
|
||||
accessKey,
|
||||
secretKey: crypto.decrypt(secretRaw),
|
||||
pathPrefix,
|
||||
autoUpload: settings.cloud_backup_auto_upload === '1',
|
||||
};
|
||||
}
|
||||
|
||||
// ─── Sencho Cloud Backup lifecycle ─────────────────────────────────────────
|
||||
|
||||
public async provisionSenchoCloudBackup(): Promise<ProvisionResult> {
|
||||
const db = DatabaseService.getInstance();
|
||||
const crypto = CryptoService.getInstance();
|
||||
const licenseKey = db.getSystemState('license_key');
|
||||
if (!licenseKey) return { success: false, error: 'No license key found. Activate an Admiral license first.' };
|
||||
|
||||
const variant = LicenseService.getInstance().getVariant();
|
||||
if (variant !== 'admiral') return { success: false, error: 'Sencho Cloud Backup requires the Admiral tier.' };
|
||||
|
||||
const apiBase = process.env.SENCHO_CLOUD_BACKUP_API || SENCHO_CLOUD_BACKUP_API_DEFAULT;
|
||||
try {
|
||||
const res = await axios.post(`${apiBase}/api/cloud-backup/provision`, { license_key: licenseKey }, { timeout: 15000 });
|
||||
const data = res.data as {
|
||||
endpoint: string;
|
||||
region?: string;
|
||||
bucket: string;
|
||||
access_key: string;
|
||||
secret_key: string;
|
||||
path_prefix: string;
|
||||
quota_bytes: number;
|
||||
};
|
||||
db.setSystemState('sencho_cloud_backup_endpoint', data.endpoint);
|
||||
db.setSystemState('sencho_cloud_backup_bucket', data.bucket);
|
||||
db.setSystemState('sencho_cloud_backup_access_key', data.access_key);
|
||||
db.setSystemState('sencho_cloud_backup_secret_key', crypto.encrypt(data.secret_key));
|
||||
db.setSystemState('sencho_cloud_backup_path_prefix', data.path_prefix);
|
||||
db.setSystemState('sencho_cloud_backup_quota_bytes', String(data.quota_bytes));
|
||||
db.setSystemState('sencho_cloud_backup_provisioned_at', new Date().toISOString());
|
||||
db.updateGlobalSetting(PROVIDER_KEY, 'sencho');
|
||||
return { success: true, quotaBytes: data.quota_bytes };
|
||||
} catch (err) {
|
||||
const responseError = (err as { response?: { data?: { error?: string } } }).response?.data?.error;
|
||||
return { success: false, error: responseError || getErrorMessage(err, 'Failed to provision Sencho Cloud Backup.') };
|
||||
}
|
||||
}
|
||||
|
||||
public async refreshSenchoCloudBackupCredentials(): Promise<void> {
|
||||
const result = await this.provisionSenchoCloudBackup();
|
||||
if (!result.success) throw new Error(result.error || 'Failed to refresh Sencho Cloud Backup credentials.');
|
||||
}
|
||||
|
||||
public async getSenchoCloudBackupUsage(): Promise<{ used_bytes: number; quota_bytes: number; object_count: number }> {
|
||||
const db = DatabaseService.getInstance();
|
||||
const licenseKey = db.getSystemState('license_key');
|
||||
if (!licenseKey) throw new Error('No license key found.');
|
||||
const apiBase = process.env.SENCHO_CLOUD_BACKUP_API || SENCHO_CLOUD_BACKUP_API_DEFAULT;
|
||||
const res = await axios.post(`${apiBase}/api/cloud-backup/usage`, { license_key: licenseKey }, { timeout: 15000 });
|
||||
const data = res.data as { used_bytes: number; quota_bytes: number; object_count: number };
|
||||
return data;
|
||||
}
|
||||
|
||||
// ─── S3 operations ─────────────────────────────────────────────────────────
|
||||
|
||||
public async testConnection(): Promise<{ success: boolean; error?: string }> {
|
||||
const cfg = this.getResolvedConfig();
|
||||
if (!cfg) return { success: false, error: 'No cloud backup configuration is active.' };
|
||||
try {
|
||||
const client = this.buildS3Client(cfg);
|
||||
await client.send(new HeadBucketCommand({ Bucket: cfg.bucket }));
|
||||
return { success: true };
|
||||
} catch (err) {
|
||||
return { success: false, error: getErrorMessage(err, 'Connection test failed.') };
|
||||
}
|
||||
}
|
||||
|
||||
public async uploadSnapshot(snapshotId: number): Promise<void> {
|
||||
const cfg = this.getResolvedConfig();
|
||||
if (!cfg) throw new Error('Cloud backup is not configured.');
|
||||
const db = DatabaseService.getInstance();
|
||||
const snapshot = db.getSnapshot(snapshotId);
|
||||
if (!snapshot) throw new Error(`Snapshot ${snapshotId} not found.`);
|
||||
const files = db.getSnapshotFiles(snapshotId);
|
||||
const objectKey = this.buildObjectKey(cfg, snapshot.id, snapshot.description, snapshot.created_at);
|
||||
|
||||
this.setStatus(snapshotId, { status: 'uploading', objectKey, updatedAt: Date.now() });
|
||||
try {
|
||||
const archive = await this.buildArchive(snapshot, files);
|
||||
const client = this.buildS3Client(cfg);
|
||||
await client.send(new PutObjectCommand({
|
||||
Bucket: cfg.bucket,
|
||||
Key: objectKey,
|
||||
Body: archive,
|
||||
ContentType: 'application/gzip',
|
||||
}));
|
||||
this.setStatus(snapshotId, { status: 'success', objectKey, updatedAt: Date.now() });
|
||||
if (isDebugEnabled()) {
|
||||
console.log(`[CloudBackup:debug] Uploaded snapshot ${snapshotId} (${archive.byteLength} bytes) to ${cfg.bucket}/${objectKey}`);
|
||||
}
|
||||
} catch (err) {
|
||||
const message = getErrorMessage(err, 'Cloud upload failed.');
|
||||
this.setStatus(snapshotId, { status: 'failed', objectKey, error: message, updatedAt: Date.now() });
|
||||
throw new Error(message);
|
||||
}
|
||||
}
|
||||
|
||||
public async downloadSnapshot(objectKey: string): Promise<Buffer> {
|
||||
const cfg = this.getResolvedConfig();
|
||||
if (!cfg) throw new Error('Cloud backup is not configured.');
|
||||
const client = this.buildS3Client(cfg);
|
||||
const result = await client.send(new GetObjectCommand({ Bucket: cfg.bucket, Key: objectKey }));
|
||||
const body = result.Body as Readable | undefined;
|
||||
if (!body) throw new Error('Empty response body.');
|
||||
return await streamToBuffer(body);
|
||||
}
|
||||
|
||||
public async listCloudSnapshots(): Promise<CloudSnapshotEntry[]> {
|
||||
const cfg = this.getResolvedConfig();
|
||||
if (!cfg) return [];
|
||||
const client = this.buildS3Client(cfg);
|
||||
const prefix = `${cfg.pathPrefix}instances/${this.getInstanceId()}/snapshots/`;
|
||||
const result = await client.send(new ListObjectsV2Command({
|
||||
Bucket: cfg.bucket,
|
||||
Prefix: prefix,
|
||||
MaxKeys: 1000,
|
||||
}));
|
||||
const objects = result.Contents || [];
|
||||
return objects
|
||||
.filter(o => !!o.Key)
|
||||
.map(o => {
|
||||
const key = o.Key as string;
|
||||
const basename = key.split('/').pop() || key;
|
||||
const idMatch = basename.match(/^(\d+)_/);
|
||||
return {
|
||||
objectKey: key,
|
||||
sizeBytes: o.Size ?? 0,
|
||||
lastModified: o.LastModified ? o.LastModified.toISOString() : null,
|
||||
snapshotId: idMatch ? parseInt(idMatch[1], 10) : null,
|
||||
};
|
||||
})
|
||||
.sort((a, b) => (b.lastModified || '').localeCompare(a.lastModified || ''));
|
||||
}
|
||||
|
||||
public async deleteCloudSnapshot(objectKey: string): Promise<void> {
|
||||
const cfg = this.getResolvedConfig();
|
||||
if (!cfg) throw new Error('Cloud backup is not configured.');
|
||||
const client = this.buildS3Client(cfg);
|
||||
await client.send(new DeleteObjectCommand({ Bucket: cfg.bucket, Key: objectKey }));
|
||||
}
|
||||
|
||||
// ─── Status tracking ───────────────────────────────────────────────────────
|
||||
|
||||
public getUploadStatus(snapshotId: number): UploadStatusEntry {
|
||||
return this.uploadStatus.get(snapshotId) || { status: 'idle', updatedAt: 0 };
|
||||
}
|
||||
|
||||
private setStatus(snapshotId: number, entry: UploadStatusEntry): void {
|
||||
this.uploadStatus.set(snapshotId, entry);
|
||||
}
|
||||
|
||||
private gcUploadStatus(): void {
|
||||
const now = Date.now();
|
||||
for (const [id, entry] of this.uploadStatus.entries()) {
|
||||
if (entry.status === 'success' && now - entry.updatedAt > SUCCESS_STATUS_TTL_MS) {
|
||||
this.uploadStatus.delete(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Internals ─────────────────────────────────────────────────────────────
|
||||
|
||||
private buildS3Client(cfg: ResolvedCloudConfig): S3Client {
|
||||
return new S3Client({
|
||||
endpoint: cfg.endpoint,
|
||||
region: cfg.region,
|
||||
credentials: { accessKeyId: cfg.accessKey, secretAccessKey: cfg.secretKey },
|
||||
forcePathStyle: true,
|
||||
});
|
||||
}
|
||||
|
||||
private getInstanceId(): string {
|
||||
return DatabaseService.getInstance().getSystemState('instance_id') || 'unknown';
|
||||
}
|
||||
|
||||
private buildObjectKey(cfg: ResolvedCloudConfig, snapshotId: number, description: string, createdAt: number): string {
|
||||
const ts = new Date(createdAt).toISOString().replace(/[:.]/g, '-');
|
||||
const slug = description.slice(0, 40).replace(/[^a-z0-9]+/gi, '-').replace(/^-+|-+$/g, '').toLowerCase() || 'snapshot';
|
||||
return `${cfg.pathPrefix}instances/${this.getInstanceId()}/snapshots/${snapshotId}_${ts}_${slug}.tar.gz`;
|
||||
}
|
||||
|
||||
private async buildArchive(
|
||||
snapshot: { id: number; description: string; created_by: string; node_count: number; stack_count: number; skipped_nodes: string; created_at: number },
|
||||
files: FleetSnapshotFile[],
|
||||
): Promise<Buffer> {
|
||||
const pack = tar.pack();
|
||||
const metadata = {
|
||||
id: snapshot.id,
|
||||
description: snapshot.description,
|
||||
created_by: snapshot.created_by,
|
||||
created_at: snapshot.created_at,
|
||||
node_count: snapshot.node_count,
|
||||
stack_count: snapshot.stack_count,
|
||||
skipped_nodes: safeParseJson(snapshot.skipped_nodes, []),
|
||||
instance_id: this.getInstanceId(),
|
||||
archive_version: 1,
|
||||
};
|
||||
pack.entry({ name: 'metadata.json' }, JSON.stringify(metadata, null, 2));
|
||||
|
||||
for (const file of files) {
|
||||
const safeNodeName = sanitizePathSegment(file.node_name);
|
||||
const safeStackName = sanitizePathSegment(file.stack_name);
|
||||
const safeFilename = sanitizePathSegment(file.filename);
|
||||
const entryPath = `nodes/${file.node_id}_${safeNodeName}/${safeStackName}/${safeFilename}`;
|
||||
pack.entry({ name: entryPath }, file.content);
|
||||
}
|
||||
pack.finalize();
|
||||
|
||||
const gzip = zlib.createGzip();
|
||||
const chunks: Buffer[] = [];
|
||||
return await new Promise<Buffer>((resolve, reject) => {
|
||||
gzip.on('data', (chunk: Buffer) => chunks.push(chunk));
|
||||
gzip.on('end', () => resolve(Buffer.concat(chunks)));
|
||||
gzip.on('error', reject);
|
||||
pack.on('error', reject);
|
||||
pack.pipe(gzip);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
function streamToBuffer(stream: Readable): Promise<Buffer> {
|
||||
return new Promise<Buffer>((resolve, reject) => {
|
||||
const chunks: Buffer[] = [];
|
||||
stream.on('data', (chunk: Buffer) => chunks.push(chunk));
|
||||
stream.on('end', () => resolve(Buffer.concat(chunks)));
|
||||
stream.on('error', reject);
|
||||
});
|
||||
}
|
||||
|
||||
function sanitizePathSegment(input: string): string {
|
||||
// Preserve dotfiles like `.env` while neutralising traversal (`..`) and path separators.
|
||||
const cleaned = input.replace(/\.\./g, '_').replace(/[\\/]/g, '_');
|
||||
if (/^\.+$/.test(cleaned)) return '_';
|
||||
return cleaned;
|
||||
}
|
||||
|
||||
function safeParseJson<T>(value: string | null | undefined, fallback: T): T {
|
||||
if (!value) return fallback;
|
||||
try { return JSON.parse(value) as T; } catch { return fallback; }
|
||||
}
|
||||
@@ -15,6 +15,7 @@ import { NotificationService } from './NotificationService';
|
||||
import TrivyService from './TrivyService';
|
||||
import type { ScanAllNodeImagesResult } from './TrivyService';
|
||||
import TrivyInstaller from './TrivyInstaller';
|
||||
import { CloudBackupService } from './CloudBackupService';
|
||||
|
||||
const TRIVY_UPDATE_CHECK_INTERVAL_MS = 24 * 60 * 60 * 1000;
|
||||
const TRIVY_UPDATE_CHECK_STARTUP_DELAY_MS = 5 * 60 * 1000;
|
||||
@@ -501,11 +502,25 @@ export class SchedulerService {
|
||||
db.insertSnapshotFiles(snapshotId, allFiles);
|
||||
}
|
||||
|
||||
if (isDebugEnabled()) {
|
||||
console.debug(`[SchedulerService:debug] Snapshot task ${task.id}: captured ${capturedNodes.length} node(s), ${totalStacks} stack(s), ${allFiles.length} file(s), skipped ${skippedNodes.length}`);
|
||||
let cloudUploadNote = '';
|
||||
const cloudSvc = CloudBackupService.getInstance();
|
||||
if (cloudSvc.isEnabled() && cloudSvc.isAutoUploadOn()) {
|
||||
try {
|
||||
await cloudSvc.uploadSnapshot(snapshotId);
|
||||
cloudUploadNote = ', cloud upload OK';
|
||||
} catch (err) {
|
||||
const message = getErrorMessage(err, 'Cloud upload failed');
|
||||
console.error('[SchedulerService] Cloud upload failed:', message);
|
||||
this.safeDispatch('warning', 'system', `Cloud backup failed for scheduled snapshot ${snapshotId}: ${message}`);
|
||||
cloudUploadNote = ', cloud upload FAILED';
|
||||
}
|
||||
}
|
||||
|
||||
return `Fleet snapshot created (id=${snapshotId}, ${capturedNodes.length} node(s), ${totalStacks} stack(s)${skippedNodes.length > 0 ? `, ${skippedNodes.length} skipped` : ''})`;
|
||||
if (isDebugEnabled()) {
|
||||
console.debug(`[SchedulerService:debug] Snapshot task ${task.id}: captured ${capturedNodes.length} node(s), ${totalStacks} stack(s), ${allFiles.length} file(s), skipped ${skippedNodes.length}${cloudUploadNote}`);
|
||||
}
|
||||
|
||||
return `Fleet snapshot created (id=${snapshotId}, ${capturedNodes.length} node(s), ${totalStacks} stack(s)${skippedNodes.length > 0 ? `, ${skippedNodes.length} skipped` : ''}${cloudUploadNote})`;
|
||||
}
|
||||
|
||||
private async executePrune(task: ScheduledTask): Promise<string> {
|
||||
|
||||
@@ -86,6 +86,13 @@ export const AUDIT_ROUTE_SUMMARIES: Record<string, string> = {
|
||||
'POST /fleet/nodes/*/update': 'Triggered fleet node update',
|
||||
'POST /fleet/update-all': 'Triggered fleet-wide update',
|
||||
|
||||
// Cloud backup
|
||||
'PUT /cloud-backup/config': 'Updated cloud backup config',
|
||||
'POST /cloud-backup/test': 'Tested cloud backup connection',
|
||||
'POST /cloud-backup/provision': 'Provisioned Sencho Cloud Backup',
|
||||
'POST /cloud-backup/upload': 'Uploaded snapshot to cloud',
|
||||
'DELETE /cloud-backup/object': 'Deleted cloud snapshot',
|
||||
|
||||
// SSO
|
||||
'PUT /sso/config': 'Updated SSO configuration',
|
||||
'DELETE /sso/config': 'Deleted SSO configuration',
|
||||
|
||||
@@ -82,6 +82,59 @@ Sencho writes the snapshot's files back to the target node:
|
||||
|
||||
Admins can delete snapshots from the list view by clicking the trash icon on the right side of each row. A confirmation dialog asks you to confirm before the snapshot is permanently removed. Deleting a snapshot removes all captured file data from the database. This action cannot be undone.
|
||||
|
||||
## Cloud Backup
|
||||
|
||||
<Note>
|
||||
Cloud Backup requires an Admiral license. Configure it in **Settings → Cloud Backup**.
|
||||
</Note>
|
||||
|
||||
Cloud Backup mirrors every fleet snapshot to off-site storage so your snapshots survive local disk failure. Two storage modes are supported.
|
||||
|
||||
### Sencho Cloud Backup (included)
|
||||
|
||||
A managed 500 MB allowance backed by Cloudflare R2, included with every Admiral license. Open **Settings → Cloud Backup**, choose **Sencho Cloud Backup**, and click **Activate**. Sencho exchanges your license key for scoped storage credentials and starts replicating new snapshots automatically. The settings panel shows your storage usage and lets you reprovision credentials if needed.
|
||||
|
||||
### Custom S3 (BYOB)
|
||||
|
||||
Bring any S3-compatible bucket: AWS S3, MinIO, Backblaze B2, Wasabi, or your own Cloudflare R2 token. Choose **Custom S3** in the storage-mode dropdown and fill in:
|
||||
|
||||
- **Endpoint URL** (e.g. `https://s3.us-east-1.amazonaws.com`, `https://my-minio.example.com:9000`)
|
||||
- **Region** (e.g. `us-east-1`, or `auto` for R2)
|
||||
- **Bucket** name
|
||||
- **Path Prefix** (default `sencho/`)
|
||||
- **Access Key ID** and **Secret Access Key**
|
||||
- **Auto-upload** toggle
|
||||
|
||||
Click **Test** to verify connectivity, then **Save**. Secret keys are encrypted at rest. Sencho only sends them to your configured endpoint.
|
||||
|
||||
### Manual upload vs auto-upload
|
||||
|
||||
When auto-upload is on, every fleet snapshot is replicated as soon as it is created. Manual snapshots from the **Fleet → Snapshots** view upload asynchronously so the UI returns immediately; scheduled snapshots block on the upload so the task's success status reflects cloud durability.
|
||||
|
||||
To upload a single snapshot on demand, open the **Snapshots** tab in Fleet View. Each row that hasn't been mirrored yet shows a cloud-upload action next to the **View** button. Once a snapshot is in the cloud, a small cloud icon appears next to its description.
|
||||
|
||||
### Browsing and downloading cloud snapshots
|
||||
|
||||
The **Cloud Snapshots** panel in **Settings → Cloud Backup** lists every archive currently in your bucket, with size and last-modified timestamp. Click the download icon to save a `.tar.gz` archive locally for off-host disaster recovery. Each archive contains a `metadata.json` describing the snapshot and a `nodes/` tree with the captured compose and environment files, organised by node and stack.
|
||||
|
||||
### Restoring from a cloud snapshot
|
||||
|
||||
For in-place rollback, use the **Restore** action on the snapshot detail view as described above; the local copy is the source of truth for live restore. Cloud snapshots cover the disaster-recovery case where the local disk is gone: download the archive, extract it, and bring up a fresh Sencho instance pointed at the recovered files.
|
||||
|
||||
### Cloud Backup troubleshooting
|
||||
|
||||
#### Bad credentials
|
||||
|
||||
If **Test** reports an authentication error, double-check the Access Key ID, Secret Access Key, and bucket. Some providers require you to enable S3-compatible API access on the bucket separately. For MinIO, verify the user has read/write permission on the target bucket.
|
||||
|
||||
#### Over quota (Sencho Cloud Backup)
|
||||
|
||||
Sencho Cloud Backup has a 500 MB allowance per license. When you hit the cap, new uploads fail with a quota error. Delete older cloud snapshots from the **Cloud Snapshots** panel to free space. The local copies are unaffected.
|
||||
|
||||
#### Network timeout or 5xx error
|
||||
|
||||
Transient errors surface as a notification. Retry by clicking the cloud-upload action on the snapshot row, or wait for the next scheduled snapshot which will retry on its own. Persistent failures usually indicate an endpoint outage; verify the storage provider is reachable from your Sencho host.
|
||||
|
||||
## Access control
|
||||
|
||||
| Action | Admin | Node Admin | Deployer | Auditor | Viewer |
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 200 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 166 KiB |
@@ -2,6 +2,7 @@ import { useState, useEffect, useCallback } from 'react';
|
||||
import {
|
||||
Camera, ArrowLeft, Server, Layers, FileText, AlertTriangle, Trash2,
|
||||
Eye, ChevronDown, ChevronLeft, ChevronRight, Plus, Loader2, RotateCcw,
|
||||
Cloud, CloudUpload,
|
||||
} from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
@@ -20,6 +21,7 @@ import {
|
||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
import { apiFetch } from '@/lib/api';
|
||||
import { useAuth } from '@/context/AuthContext';
|
||||
import { useLicense } from '@/context/LicenseContext';
|
||||
import { toast } from '@/components/ui/toast-store';
|
||||
|
||||
// --- Types ---
|
||||
@@ -66,6 +68,8 @@ const PAGE_SIZE = 10;
|
||||
|
||||
export default function FleetSnapshots() {
|
||||
const { isAdmin } = useAuth();
|
||||
const { license, isPaid } = useLicense();
|
||||
const isAdmiral = isPaid && license?.variant === 'admiral';
|
||||
|
||||
const [snapshots, setSnapshots] = useState<FleetSnapshot[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
@@ -81,6 +85,8 @@ export default function FleetSnapshots() {
|
||||
const [restoringStack, setRestoringStack] = useState<string | null>(null);
|
||||
const [deletingId, setDeletingId] = useState<number | null>(null);
|
||||
const [page, setPage] = useState(0);
|
||||
const [cloudSnapshotIds, setCloudSnapshotIds] = useState<Set<number>>(new Set());
|
||||
const [uploadingId, setUploadingId] = useState<number | null>(null);
|
||||
|
||||
const totalPages = Math.max(1, Math.ceil(snapshots.length / PAGE_SIZE));
|
||||
const safePage = Math.min(page, totalPages - 1);
|
||||
@@ -111,6 +117,37 @@ export default function FleetSnapshots() {
|
||||
fetchSnapshots();
|
||||
}, [fetchSnapshots]);
|
||||
|
||||
const fetchCloudSnapshots = useCallback(async () => {
|
||||
if (!isAdmiral) return;
|
||||
try {
|
||||
const res = await apiFetch('/cloud-backup/snapshots', { localOnly: true });
|
||||
if (!res.ok) return;
|
||||
const data = await res.json() as Array<{ snapshotId: number | null }>;
|
||||
setCloudSnapshotIds(new Set(data.map(d => d.snapshotId).filter((id): id is number => id != null)));
|
||||
} catch {
|
||||
// best-effort; cloud indicators stay hidden on failure
|
||||
}
|
||||
}, [isAdmiral]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchCloudSnapshots();
|
||||
}, [fetchCloudSnapshots]);
|
||||
|
||||
const handleCloudUpload = async (id: number) => {
|
||||
setUploadingId(id);
|
||||
try {
|
||||
const res = await apiFetch(`/cloud-backup/upload/${id}`, { method: 'POST', localOnly: true });
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok) throw new Error((data as { error?: string }).error || `Upload failed (${res.status})`);
|
||||
toast.success('Snapshot uploaded to cloud.');
|
||||
await fetchCloudSnapshots();
|
||||
} catch (err) {
|
||||
toast.error((err as Error)?.message || 'Cloud upload failed.');
|
||||
} finally {
|
||||
setUploadingId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCreate = async () => {
|
||||
setCreating(true);
|
||||
const loadingId = toast.loading('Creating fleet snapshot...');
|
||||
@@ -533,11 +570,20 @@ export default function FleetSnapshots() {
|
||||
{new Date(snapshot.created_at).toLocaleString()}
|
||||
</TableCell>
|
||||
<TableCell className="text-sm max-w-[300px] truncate">
|
||||
{snapshot.description ? (
|
||||
snapshot.description
|
||||
) : (
|
||||
<span className="italic text-muted-foreground">No description</span>
|
||||
)}
|
||||
<div className="flex items-center gap-1.5 min-w-0">
|
||||
{snapshot.description ? (
|
||||
<span className="truncate">{snapshot.description}</span>
|
||||
) : (
|
||||
<span className="italic text-muted-foreground">No description</span>
|
||||
)}
|
||||
{cloudSnapshotIds.has(snapshot.id) && (
|
||||
<Cloud
|
||||
className="w-3.5 h-3.5 text-success shrink-0"
|
||||
strokeWidth={1.5}
|
||||
aria-label="Uploaded to cloud"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="text-xs font-mono tabular-nums text-muted-foreground whitespace-nowrap">
|
||||
{snapshot.node_count} node{snapshot.node_count !== 1 ? 's' : ''}
|
||||
@@ -568,6 +614,22 @@ export default function FleetSnapshots() {
|
||||
<Eye className="w-3.5 h-3.5 mr-1" strokeWidth={1.5} />
|
||||
View
|
||||
</Button>
|
||||
{isAdmin && isAdmiral && !cloudSnapshotIds.has(snapshot.id) && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 px-2 text-xs"
|
||||
title="Upload to cloud"
|
||||
disabled={uploadingId === snapshot.id}
|
||||
onClick={() => handleCloudUpload(snapshot.id)}
|
||||
>
|
||||
{uploadingId === snapshot.id ? (
|
||||
<Loader2 className="w-3.5 h-3.5 animate-spin" strokeWidth={1.5} />
|
||||
) : (
|
||||
<CloudUpload className="w-3.5 h-3.5" strokeWidth={1.5} />
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
{isAdmin && (
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
|
||||
@@ -39,6 +39,7 @@ import {
|
||||
NotificationRoutingSection,
|
||||
WebhooksSection,
|
||||
SecuritySection,
|
||||
CloudBackupSection,
|
||||
DeveloperSection,
|
||||
AppStoreSection,
|
||||
SupportSection,
|
||||
@@ -318,6 +319,7 @@ export function SettingsModal({ isOpen, onClose, initialSection, onLabelsChanged
|
||||
case 'notification-routing': return <NotificationRoutingSection />;
|
||||
case 'webhooks': return <WebhooksSection isPaid={isPaid} />;
|
||||
case 'security': return <SecuritySection isPaid={isPaid} />;
|
||||
case 'cloud-backup': return <CloudBackupSection />;
|
||||
case 'developer':
|
||||
return (
|
||||
<DeveloperSection
|
||||
|
||||
@@ -0,0 +1,523 @@
|
||||
import { useEffect, useState, useCallback } from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { Combobox } from '@/components/ui/combobox';
|
||||
import { TogglePill } from '@/components/ui/toggle-pill';
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from '@/components/ui/alert-dialog';
|
||||
import { toast } from '@/components/ui/toast-store';
|
||||
import { apiFetch } from '@/lib/api';
|
||||
import { formatBytes } from '@/lib/utils';
|
||||
import { AdmiralGate } from '@/components/AdmiralGate';
|
||||
import { Cloud, CloudOff, RefreshCw, CheckCircle2, AlertCircle, Loader2, Trash2, Download } from 'lucide-react';
|
||||
|
||||
type Provider = 'disabled' | 'sencho' | 'custom';
|
||||
|
||||
interface CustomConfig {
|
||||
endpoint: string;
|
||||
region: string;
|
||||
bucket: string;
|
||||
access_key: string;
|
||||
secret_key: string;
|
||||
path_prefix: string;
|
||||
auto_upload: boolean;
|
||||
}
|
||||
|
||||
interface ConfigResponse {
|
||||
provider: Provider;
|
||||
sencho_provisioned: boolean;
|
||||
sencho_provisioned_at: string | null;
|
||||
custom: CustomConfig;
|
||||
}
|
||||
|
||||
interface UsageResponse {
|
||||
used_bytes: number;
|
||||
quota_bytes: number;
|
||||
object_count: number;
|
||||
}
|
||||
|
||||
interface CloudSnapshotEntry {
|
||||
objectKey: string;
|
||||
sizeBytes: number;
|
||||
lastModified: string | null;
|
||||
snapshotId: number | null;
|
||||
}
|
||||
|
||||
const EMPTY_CUSTOM: CustomConfig = {
|
||||
endpoint: '',
|
||||
region: '',
|
||||
bucket: '',
|
||||
access_key: '',
|
||||
secret_key: '',
|
||||
path_prefix: 'sencho/',
|
||||
auto_upload: false,
|
||||
};
|
||||
|
||||
const PROVIDER_OPTIONS = [
|
||||
{ value: 'disabled', label: 'Disabled' },
|
||||
{ value: 'sencho', label: 'Sencho Cloud Backup (included)' },
|
||||
{ value: 'custom', label: 'Custom S3 (BYOB)' },
|
||||
];
|
||||
|
||||
const PANEL_CLASS = 'rounded-lg border border-card-border border-t-card-border-top bg-card shadow-card-bevel p-4 space-y-3';
|
||||
|
||||
export function CloudBackupSection() {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [provider, setProvider] = useState<Provider>('disabled');
|
||||
const [senchoProvisioned, setSenchoProvisioned] = useState(false);
|
||||
const [custom, setCustom] = useState<CustomConfig>(EMPTY_CUSTOM);
|
||||
const [originalSecretSaved, setOriginalSecretSaved] = useState(false);
|
||||
const [usage, setUsage] = useState<UsageResponse | null>(null);
|
||||
const [snapshots, setSnapshots] = useState<CloudSnapshotEntry[]>([]);
|
||||
const [testing, setTesting] = useState(false);
|
||||
const [provisioning, setProvisioning] = useState(false);
|
||||
const [deleteKey, setDeleteKey] = useState<string | null>(null);
|
||||
|
||||
const loadConfig = useCallback(async () => {
|
||||
try {
|
||||
const res = await apiFetch('/cloud-backup/config');
|
||||
if (!res.ok) throw new Error(`Failed to load config (${res.status})`);
|
||||
const data: ConfigResponse = await res.json();
|
||||
setProvider(data.provider);
|
||||
setSenchoProvisioned(data.sencho_provisioned);
|
||||
setCustom({ ...data.custom, secret_key: '' });
|
||||
setOriginalSecretSaved(!!data.custom.secret_key);
|
||||
} catch (err) {
|
||||
toast.error((err as Error)?.message || 'Failed to load cloud backup config.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const loadUsage = useCallback(async () => {
|
||||
try {
|
||||
const res = await apiFetch('/cloud-backup/usage');
|
||||
if (res.ok) setUsage(await res.json());
|
||||
} catch {
|
||||
// Usage is informational; failures shouldn't surface as toasts.
|
||||
}
|
||||
}, []);
|
||||
|
||||
const loadSnapshots = useCallback(async () => {
|
||||
try {
|
||||
const res = await apiFetch('/cloud-backup/snapshots');
|
||||
if (res.ok) setSnapshots(await res.json());
|
||||
} catch {
|
||||
// Best-effort; the panel renders empty when listing fails.
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
loadConfig();
|
||||
}, [loadConfig]);
|
||||
|
||||
useEffect(() => {
|
||||
if (provider === 'sencho' && senchoProvisioned) loadUsage();
|
||||
}, [provider, senchoProvisioned, loadUsage]);
|
||||
|
||||
useEffect(() => {
|
||||
if (provider !== 'disabled') loadSnapshots();
|
||||
else setSnapshots([]);
|
||||
}, [provider, loadSnapshots]);
|
||||
|
||||
const handleProviderChange = async (next: string) => {
|
||||
const nextProvider = next as Provider;
|
||||
setProvider(nextProvider);
|
||||
if (nextProvider === 'sencho' && !senchoProvisioned) return;
|
||||
setSaving(true);
|
||||
try {
|
||||
const body = nextProvider === 'custom' ? { provider: nextProvider, custom: { ...custom, secret_key: '' } } : { provider: nextProvider };
|
||||
const res = await apiFetch('/cloud-backup/config', {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({}));
|
||||
throw new Error(err?.error || 'Failed to save provider');
|
||||
}
|
||||
toast.success('Cloud backup provider updated.');
|
||||
} catch (err) {
|
||||
toast.error((err as Error)?.message || 'Failed to update provider.');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSaveCustom = async () => {
|
||||
setSaving(true);
|
||||
try {
|
||||
const payload = {
|
||||
provider: 'custom',
|
||||
custom: {
|
||||
endpoint: custom.endpoint,
|
||||
region: custom.region,
|
||||
bucket: custom.bucket,
|
||||
access_key: custom.access_key,
|
||||
secret_key: custom.secret_key || (originalSecretSaved ? '***' : ''),
|
||||
path_prefix: custom.path_prefix,
|
||||
auto_upload: custom.auto_upload,
|
||||
},
|
||||
};
|
||||
const res = await apiFetch('/cloud-backup/config', {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({}));
|
||||
throw new Error(err?.error || 'Failed to save configuration');
|
||||
}
|
||||
toast.success('Custom S3 configuration saved.');
|
||||
setCustom(c => ({ ...c, secret_key: '' }));
|
||||
setOriginalSecretSaved(true);
|
||||
loadSnapshots();
|
||||
} catch (err) {
|
||||
toast.error((err as Error)?.message || 'Failed to save configuration.');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleTest = async () => {
|
||||
setTesting(true);
|
||||
try {
|
||||
const res = await apiFetch('/cloud-backup/test', { method: 'POST' });
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (data.success) toast.success('Connection successful.');
|
||||
else toast.error(data.error || 'Connection test failed.');
|
||||
} catch (err) {
|
||||
toast.error((err as Error)?.message || 'Connection test failed.');
|
||||
} finally {
|
||||
setTesting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleProvision = async () => {
|
||||
setProvisioning(true);
|
||||
try {
|
||||
const res = await apiFetch('/cloud-backup/provision', { method: 'POST' });
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok || data?.error) throw new Error(data?.error || 'Provisioning failed.');
|
||||
toast.success('Sencho Cloud Backup activated.');
|
||||
setSenchoProvisioned(true);
|
||||
await Promise.all([loadConfig(), loadUsage(), loadSnapshots()]);
|
||||
} catch (err) {
|
||||
toast.error((err as Error)?.message || 'Provisioning failed.');
|
||||
} finally {
|
||||
setProvisioning(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAutoUploadToggle = async (next: boolean) => {
|
||||
setCustom(c => ({ ...c, auto_upload: next }));
|
||||
try {
|
||||
const res = await apiFetch('/cloud-backup/config', {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({
|
||||
provider: 'custom',
|
||||
custom: { ...custom, auto_upload: next, secret_key: originalSecretSaved ? '***' : '' },
|
||||
}),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({}));
|
||||
throw new Error(err?.error || 'Failed to update auto-upload');
|
||||
}
|
||||
} catch (err) {
|
||||
toast.error((err as Error)?.message || 'Failed to update auto-upload.');
|
||||
setCustom(c => ({ ...c, auto_upload: !next }));
|
||||
}
|
||||
};
|
||||
|
||||
const confirmDelete = async () => {
|
||||
if (!deleteKey) return;
|
||||
try {
|
||||
const encoded = btoa(deleteKey).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
|
||||
const res = await apiFetch(`/cloud-backup/object/${encoded}`, { method: 'DELETE' });
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({}));
|
||||
throw new Error(err?.error || 'Failed to delete cloud snapshot');
|
||||
}
|
||||
toast.success('Cloud snapshot deleted.');
|
||||
loadSnapshots();
|
||||
if (provider === 'sencho') loadUsage();
|
||||
} catch (err) {
|
||||
toast.error((err as Error)?.message || 'Failed to delete cloud snapshot.');
|
||||
} finally {
|
||||
setDeleteKey(null);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<Skeleton className="h-20 w-full rounded-lg" />
|
||||
<Skeleton className="h-32 w-full rounded-lg" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const usagePercent = usage && usage.quota_bytes > 0 ? Math.min(100, Math.round((usage.used_bytes / usage.quota_bytes) * 100)) : 0;
|
||||
const usageColor = usagePercent >= 90 ? 'var(--destructive)' : usagePercent >= 80 ? 'var(--warning)' : 'var(--brand)';
|
||||
|
||||
return (
|
||||
<AdmiralGate featureName="Cloud Backup">
|
||||
<div className="space-y-6">
|
||||
<div className={PANEL_CLASS}>
|
||||
<Label className="text-sm">Storage Mode</Label>
|
||||
<Combobox
|
||||
options={PROVIDER_OPTIONS}
|
||||
value={provider}
|
||||
onValueChange={handleProviderChange}
|
||||
disabled={saving}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Choose where fleet snapshots are replicated. Sencho Cloud Backup is included with the Admiral tier.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{provider === 'sencho' && !senchoProvisioned && (
|
||||
<div className={PANEL_CLASS}>
|
||||
<div className="flex items-center gap-2">
|
||||
<Cloud className="w-4 h-4 text-muted-foreground" strokeWidth={1.5} />
|
||||
<span className="font-medium text-sm">Activate Sencho Cloud Backup</span>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Activates a 500 MB allowance backed by Cloudflare R2, scoped to this Admiral license.
|
||||
</p>
|
||||
<Button size="sm" onClick={handleProvision} disabled={provisioning}>
|
||||
{provisioning ? <Loader2 className="w-3.5 h-3.5 mr-1.5 animate-spin" strokeWidth={1.5} /> : <Cloud className="w-3.5 h-3.5 mr-1.5" strokeWidth={1.5} />}
|
||||
Activate
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{provider === 'sencho' && senchoProvisioned && (
|
||||
<div className={PANEL_CLASS}>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<CheckCircle2 className="w-4 h-4 text-success" strokeWidth={1.5} />
|
||||
<span className="font-medium text-sm">Sencho Cloud Backup</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button size="sm" variant="outline" onClick={handleTest} disabled={testing}>
|
||||
{testing ? <Loader2 className="w-3.5 h-3.5 mr-1.5 animate-spin" strokeWidth={1.5} /> : null}
|
||||
Test
|
||||
</Button>
|
||||
<Button size="sm" variant="ghost" onClick={handleProvision} disabled={provisioning}>
|
||||
<RefreshCw className="w-3.5 h-3.5 mr-1.5" strokeWidth={1.5} />
|
||||
Reprovision
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{usage && (
|
||||
<div className="rounded-lg border border-glass-border px-3 py-2.5 space-y-2">
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="font-medium">Storage used</span>
|
||||
<span className="text-stat-subtitle font-mono text-xs">
|
||||
{formatBytes(usage.used_bytes)} / {formatBytes(usage.quota_bytes)} ({usage.object_count} objects)
|
||||
</span>
|
||||
</div>
|
||||
<div className="h-1.5 rounded-full bg-muted/60 overflow-hidden">
|
||||
<div
|
||||
className="h-full rounded-full transition-all duration-500"
|
||||
style={{
|
||||
width: `${usagePercent}%`,
|
||||
backgroundColor: usageColor,
|
||||
boxShadow: `0 0 8px ${usageColor}`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-start gap-2 rounded-lg border border-glass-border bg-muted/30 px-3 py-2.5">
|
||||
<Cloud className="w-4 h-4 text-muted-foreground shrink-0 mt-0.5" strokeWidth={1.5} />
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Auto-upload is on for Sencho Cloud Backup. Every fleet snapshot is replicated within seconds.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{provider === 'custom' && (
|
||||
<div className={PANEL_CLASS}>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Cloud className="w-4 h-4 text-muted-foreground" strokeWidth={1.5} />
|
||||
<span className="font-medium text-sm">Custom S3 Configuration</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button size="sm" variant="outline" onClick={handleTest} disabled={testing || saving}>
|
||||
{testing ? <Loader2 className="w-3.5 h-3.5 mr-1.5 animate-spin" strokeWidth={1.5} /> : null}
|
||||
Test
|
||||
</Button>
|
||||
<Button size="sm" onClick={handleSaveCustom} disabled={saving}>
|
||||
{saving ? <Loader2 className="w-3.5 h-3.5 mr-1.5 animate-spin" strokeWidth={1.5} /> : null}
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs">Endpoint URL</Label>
|
||||
<Input
|
||||
placeholder="https://s3.us-east-1.amazonaws.com"
|
||||
value={custom.endpoint}
|
||||
onChange={e => setCustom({ ...custom, endpoint: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs">Region</Label>
|
||||
<Input
|
||||
placeholder="us-east-1"
|
||||
value={custom.region}
|
||||
onChange={e => setCustom({ ...custom, region: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs">Bucket</Label>
|
||||
<Input
|
||||
placeholder="my-sencho-backups"
|
||||
value={custom.bucket}
|
||||
onChange={e => setCustom({ ...custom, bucket: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs">Path Prefix</Label>
|
||||
<Input
|
||||
placeholder="sencho/"
|
||||
value={custom.path_prefix}
|
||||
onChange={e => setCustom({ ...custom, path_prefix: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs">Access Key ID</Label>
|
||||
<Input
|
||||
placeholder="AKIA..."
|
||||
value={custom.access_key}
|
||||
onChange={e => setCustom({ ...custom, access_key: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs">Secret Access Key</Label>
|
||||
<Input
|
||||
type="password"
|
||||
placeholder={originalSecretSaved && !custom.secret_key ? '•••• saved ••••' : ''}
|
||||
value={custom.secret_key}
|
||||
onChange={e => setCustom({ ...custom, secret_key: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between rounded-lg border border-glass-border px-3 py-2.5">
|
||||
<div>
|
||||
<Label className="text-sm">Auto-upload</Label>
|
||||
<p className="text-xs text-muted-foreground">Automatically upload every fleet snapshot to this bucket.</p>
|
||||
</div>
|
||||
<TogglePill checked={custom.auto_upload} onChange={handleAutoUploadToggle} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{provider !== 'disabled' && (
|
||||
<div className={PANEL_CLASS}>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="font-medium text-sm">Cloud Snapshots</span>
|
||||
<Button size="sm" variant="ghost" onClick={loadSnapshots}>
|
||||
<RefreshCw className="w-3.5 h-3.5" strokeWidth={1.5} />
|
||||
</Button>
|
||||
</div>
|
||||
{snapshots.length === 0 ? (
|
||||
<div className="flex items-start gap-2 text-xs text-muted-foreground py-2">
|
||||
<CloudOff className="w-4 h-4 shrink-0 mt-0.5" strokeWidth={1.5} />
|
||||
No cloud snapshots yet. The next fleet snapshot will appear here.
|
||||
</div>
|
||||
) : (
|
||||
<ul className="space-y-1.5">
|
||||
{snapshots.map(s => (
|
||||
<li key={s.objectKey} className="flex items-center justify-between gap-2 rounded-md border border-glass-border px-3 py-2">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-xs font-mono truncate">{s.objectKey.split('/').pop()}</div>
|
||||
<div className="text-[10px] text-stat-subtitle font-mono">
|
||||
{formatBytes(s.sizeBytes)} {s.lastModified ? `· ${new Date(s.lastModified).toLocaleString()}` : ''}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 shrink-0">
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="h-7 w-7"
|
||||
onClick={async () => {
|
||||
const encoded = btoa(s.objectKey).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
|
||||
try {
|
||||
const res = await apiFetch(`/cloud-backup/object/${encoded}/download`);
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({}));
|
||||
throw new Error(err?.error || `Download failed (${res.status})`);
|
||||
}
|
||||
const blob = await res.blob();
|
||||
const link = document.createElement('a');
|
||||
link.href = URL.createObjectURL(blob);
|
||||
link.download = s.objectKey.split('/').pop() || 'snapshot.tar.gz';
|
||||
link.click();
|
||||
URL.revokeObjectURL(link.href);
|
||||
} catch (err) {
|
||||
toast.error((err as Error)?.message || 'Download failed.');
|
||||
}
|
||||
}}
|
||||
title="Download"
|
||||
>
|
||||
<Download className="w-3.5 h-3.5" strokeWidth={1.5} />
|
||||
</Button>
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="h-7 w-7"
|
||||
onClick={() => setDeleteKey(s.objectKey)}
|
||||
title="Delete"
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5" strokeWidth={1.5} />
|
||||
</Button>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<AlertDialog open={!!deleteKey} onOpenChange={open => !open && setDeleteKey(null)}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle className="flex items-center gap-2">
|
||||
<AlertCircle className="w-4 h-4 text-destructive" strokeWidth={1.5} />
|
||||
Delete cloud snapshot?
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
This permanently removes the archive from your bucket. The local SQLite copy is unaffected.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={confirmDelete} className="bg-destructive text-destructive-foreground hover:bg-destructive/90">
|
||||
Delete
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
</AdmiralGate>
|
||||
);
|
||||
}
|
||||
@@ -6,6 +6,7 @@ export { SystemSection } from './SystemSection';
|
||||
export { NotificationsSection } from './NotificationsSection';
|
||||
export { WebhooksSection } from './WebhooksSection';
|
||||
export { SecuritySection } from './SecuritySection';
|
||||
export { CloudBackupSection } from './CloudBackupSection';
|
||||
export { DeveloperSection } from './DeveloperSection';
|
||||
export { AppStoreSection } from './AppStoreSection';
|
||||
export { SupportSection } from './SupportSection';
|
||||
|
||||
@@ -114,6 +114,17 @@ export const SETTINGS_ITEMS: readonly SettingsItemMeta[] = [
|
||||
adminOnly: true,
|
||||
hiddenOnRemote: true,
|
||||
},
|
||||
{
|
||||
id: 'cloud-backup',
|
||||
group: 'system',
|
||||
label: 'Cloud Backup',
|
||||
description: 'Mirror fleet snapshots to Sencho Cloud Backup or any S3-compatible storage.',
|
||||
keywords: ['cloud', 'backup', 'snapshot', 's3', 'r2', 'minio', 'storage', 'offsite'],
|
||||
tier: 'admiral',
|
||||
scope: 'global',
|
||||
adminOnly: true,
|
||||
hiddenOnRemote: true,
|
||||
},
|
||||
{
|
||||
id: 'nodes',
|
||||
group: 'system',
|
||||
|
||||
@@ -37,6 +37,7 @@ export type SectionId =
|
||||
| 'notifications'
|
||||
| 'webhooks'
|
||||
| 'security'
|
||||
| 'cloud-backup'
|
||||
| 'developer'
|
||||
| 'nodes'
|
||||
| 'appstore'
|
||||
|
||||
Reference in New Issue
Block a user