feat(cloud-backup): make Custom S3-compatible target available on every tier (#1143)

* feat(cloud-backup): make Custom S3-compatible target available on every tier

Sencho Cloud Backup remains an Admiral feature; the bring-your-own-bucket
Custom S3 target is now reachable on Community and Skipper as well.

Backend splits the per-route Admiral gate into two helpers: operations
that touch the saved provider use gateForCurrentProvider, PUT /config
uses gateForRequestedProvider against the body. /provision and /usage
stay requireAdmiral because they are Sencho-only by definition; GET
/config is ungated so any tier can read its own stored configuration.

Frontend drops the AdmiralGate wrapper on the Cloud Backup section,
filters the Sencho provider option out of the dropdown for non-Admiral
users, and gates the per-snapshot cloud-upload affordance on
"cloud-backup configured" instead of Admiral tier. Dashboard
Configuration row is no longer locked on lower tiers.

Sidebar registry tier on cloud-backup goes from 'admiral' to null.
Docs and licensing breakdown restate the rule once per page without
fence-spec.

* fix(cloud-backup): keep downgraded sencho config off the upload surface

If an Admiral configured Sencho Cloud Backup and the license later drops
to Skipper or Community, the saved provider is still 'sencho'. The
FleetSnapshots cloud-upload affordance now requires either provider=
custom (every tier) or provider=sencho with an active Admiral license,
so a downgraded admin never sees an upload button that the backend
would 403 on click.

Also tidies the Fleet Backups doc, which still claimed the cloud-upload
icon was Admiral only; the icon now renders whenever a Cloud Backup
target is configured.
This commit is contained in:
Anso
2026-05-21 18:11:16 -04:00
committed by GitHub
parent c491d309c1
commit 380ed6fd50
10 changed files with 437 additions and 276 deletions
+131 -11
View File
@@ -3,7 +3,7 @@
* 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 { describe, it, expect, beforeAll, afterAll, beforeEach, afterEach, vi } from 'vitest';
import request from 'supertest';
import { setupTestDb, cleanupTestDb, loginAsTestAdmin } from './helpers/setupTestDb';
@@ -58,26 +58,146 @@ beforeEach(() => {
}
});
// Sticky mocks (mockReturnValue, not mockReturnValueOnce) so a test that does
// not actually hit a tier-gated codepath doesn't leak its persona into later
// tests. The afterEach hook resets back to the Admiral baseline.
function mockCommunity() {
vi.spyOn(LicenseService.getInstance(), 'getTier').mockReturnValue('community');
}
function mockSkipper() {
vi.spyOn(LicenseService.getInstance(), 'getTier').mockReturnValue('paid');
vi.spyOn(LicenseService.getInstance(), 'getVariant').mockReturnValue('skipper');
}
afterEach(() => {
vi.spyOn(LicenseService.getInstance(), 'getTier').mockReturnValue('paid');
vi.spyOn(LicenseService.getInstance(), 'getVariant').mockReturnValue('admiral');
});
const customConfigBody = {
provider: 'custom',
custom: {
endpoint: 'https://s3.example.com',
region: 'us-east-1',
bucket: 'b',
access_key: 'a',
secret_key: 's',
path_prefix: 'p/',
auto_upload: false,
},
};
describe('Cloud backup tier gating', () => {
it('rejects community tier with PAID_REQUIRED', async () => {
vi.spyOn(LicenseService.getInstance(), 'getTier').mockReturnValueOnce('community');
// GET /config is ungated — every tier can read the stored configuration.
it('GET /config is readable on Community', async () => {
mockCommunity();
const res = await request(app).get('/api/cloud-backup/config').set('Cookie', authCookie);
expect(res.status).toBe(200);
});
it('GET /config is readable on Skipper', async () => {
mockSkipper();
const res = await request(app).get('/api/cloud-backup/config').set('Cookie', authCookie);
expect(res.status).toBe(200);
});
it('GET /config is readable on Admiral', 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');
});
// PUT /config: 'custom' is available on every tier, 'sencho' is Admiral-only.
it('PUT /config with provider=custom succeeds on Community', async () => {
mockCommunity();
const res = await request(app).put('/api/cloud-backup/config').set('Cookie', authCookie).send(customConfigBody);
expect(res.status).toBe(204);
});
it('PUT /config with provider=custom succeeds on Skipper', async () => {
mockSkipper();
const res = await request(app).put('/api/cloud-backup/config').set('Cookie', authCookie).send(customConfigBody);
expect(res.status).toBe(204);
});
it('PUT /config with provider=sencho is rejected on Community with PAID_REQUIRED', async () => {
mockCommunity();
const res = await request(app).put('/api/cloud-backup/config').set('Cookie', authCookie).send({ provider: 'sencho' });
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);
it('PUT /config with provider=sencho is rejected on Skipper with ADMIRAL_REQUIRED', async () => {
mockSkipper();
const res = await request(app).put('/api/cloud-backup/config').set('Cookie', authCookie).send({ provider: 'sencho' });
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');
// POST /provision is Admiral-only by definition (Sencho Cloud Backup activation).
it('POST /provision is rejected on Community', async () => {
mockCommunity();
const res = await request(app).post('/api/cloud-backup/provision').set('Cookie', authCookie);
expect(res.status).toBe(403);
expect(res.body.code).toBe('PAID_REQUIRED');
});
it('POST /provision is rejected on Skipper', async () => {
mockSkipper();
const res = await request(app).post('/api/cloud-backup/provision').set('Cookie', authCookie);
expect(res.status).toBe(403);
expect(res.body.code).toBe('ADMIRAL_REQUIRED');
});
// GET /usage is Admiral-only (sencho-specific endpoint).
it('GET /usage is rejected on Community', async () => {
mockCommunity();
const res = await request(app).get('/api/cloud-backup/usage').set('Cookie', authCookie);
expect(res.status).toBe(403);
});
it('GET /usage is rejected on Skipper', async () => {
mockSkipper();
const res = await request(app).get('/api/cloud-backup/usage').set('Cookie', authCookie);
expect(res.status).toBe(403);
});
// POST /test, GET /snapshots, POST /upload, GET /status, GET /object/.../download,
// DELETE /object are gated by the *currently saved* provider.
it('POST /test reaches handler on Community when saved provider is custom', async () => {
DatabaseService.getInstance().updateGlobalSetting('cloud_backup_provider', 'custom');
mockCommunity();
const res = await request(app).post('/api/cloud-backup/test').set('Cookie', authCookie);
expect(res.status).not.toBe(403);
});
it('POST /test is rejected on Community when saved provider is sencho', async () => {
DatabaseService.getInstance().updateGlobalSetting('cloud_backup_provider', 'sencho');
mockCommunity();
const res = await request(app).post('/api/cloud-backup/test').set('Cookie', authCookie);
expect(res.status).toBe(403);
expect(res.body.code).toBe('PAID_REQUIRED');
});
it('GET /snapshots reaches handler on Community when saved provider is custom', async () => {
DatabaseService.getInstance().updateGlobalSetting('cloud_backup_provider', 'custom');
mockCommunity();
const res = await request(app).get('/api/cloud-backup/snapshots').set('Cookie', authCookie);
expect(res.status).not.toBe(403);
});
it('GET /snapshots is rejected on Community when saved provider is sencho', async () => {
DatabaseService.getInstance().updateGlobalSetting('cloud_backup_provider', 'sencho');
mockCommunity();
const res = await request(app).get('/api/cloud-backup/snapshots').set('Cookie', authCookie);
expect(res.status).toBe(403);
});
// Admiral retains access to every endpoint.
it('Admiral can configure provider=sencho', async () => {
const res = await request(app).put('/api/cloud-backup/config').set('Cookie', authCookie).send({ provider: 'sencho' });
expect(res.status).toBe(204);
});
});
+23 -8
View File
@@ -10,6 +10,22 @@ const SCOPE_MESSAGE = 'API tokens cannot manage cloud backup configuration.';
const SECRET_REDACTED = '***';
const VALID_PROVIDERS = new Set(['disabled', 'sencho', 'custom']);
// Provider-aware tier gates. The managed Sencho Cloud Backup target requires
// Admiral; the bring-your-own-bucket Custom S3 target is available on every
// tier. These wrappers short-circuit to requireAdmiral only when the operation
// actually touches the 'sencho' provider.
function gateForCurrentProvider(req: Request, res: Response): boolean {
const provider = CloudBackupService.getInstance().getProvider();
if (provider === 'sencho') return requireAdmiral(req, res);
return true;
}
function gateForRequestedProvider(req: Request, res: Response, requested: string): boolean {
if (requested === 'sencho') return requireAdmiral(req, res);
return true;
}
function parseSnapshotIdParam(req: Request, res: Response): number | null {
const raw = req.params.id as string | undefined;
const parsed = parseInt(raw ?? '', 10);
@@ -43,7 +59,6 @@ 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();
@@ -72,7 +87,6 @@ cloudBackupRouter.get('/config', (req: Request, res: Response): void => {
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;
@@ -80,6 +94,7 @@ cloudBackupRouter.put('/config', (req: Request, res: Response): void => {
res.status(400).json({ error: 'provider must be one of: disabled, sencho, custom' });
return;
}
if (!gateForRequestedProvider(req, res, provider)) return;
const db = DatabaseService.getInstance();
const crypto = CryptoService.getInstance();
db.updateGlobalSetting('cloud_backup_provider', provider);
@@ -125,7 +140,7 @@ cloudBackupRouter.put('/config', (req: Request, res: Response): void => {
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;
if (!gateForCurrentProvider(req, res)) return;
try {
const result = await CloudBackupService.getInstance().testConnection();
res.json(result);
@@ -171,7 +186,7 @@ cloudBackupRouter.get('/usage', async (req: Request, res: Response): Promise<voi
cloudBackupRouter.get('/snapshots', async (req: Request, res: Response): Promise<void> => {
if (rejectApiTokenScope(req, res, SCOPE_MESSAGE)) return;
if (!requireAdmiral(req, res)) return;
if (!gateForCurrentProvider(req, res)) return;
try {
const entries = await CloudBackupService.getInstance().listCloudSnapshots();
res.json(entries);
@@ -184,7 +199,7 @@ cloudBackupRouter.get('/snapshots', async (req: Request, res: Response): Promise
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;
if (!gateForCurrentProvider(req, res)) return;
const id = parseSnapshotIdParam(req, res);
if (id == null) return;
try {
@@ -203,7 +218,7 @@ cloudBackupRouter.post('/upload/:id', async (req: Request, res: Response): Promi
cloudBackupRouter.get('/status/:id', (req: Request, res: Response): void => {
if (rejectApiTokenScope(req, res, SCOPE_MESSAGE)) return;
if (!requireAdmiral(req, res)) return;
if (!gateForCurrentProvider(req, res)) return;
const id = parseSnapshotIdParam(req, res);
if (id == null) return;
res.json(CloudBackupService.getInstance().getUploadStatus(id));
@@ -211,7 +226,7 @@ cloudBackupRouter.get('/status/:id', (req: Request, res: Response): void => {
cloudBackupRouter.get('/object/:keyB64/download', async (req: Request, res: Response): Promise<void> => {
if (rejectApiTokenScope(req, res, SCOPE_MESSAGE)) return;
if (!requireAdmiral(req, res)) return;
if (!gateForCurrentProvider(req, res)) return;
const objectKey = decodeObjectKey(req, res);
if (!objectKey) return;
try {
@@ -230,7 +245,7 @@ cloudBackupRouter.get('/object/:keyB64/download', async (req: Request, res: Resp
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;
if (!gateForCurrentProvider(req, res)) return;
const objectKey = decodeObjectKey(req, res);
if (!objectKey) return;
try {
+1 -1
View File
@@ -148,7 +148,7 @@ export function buildLocalConfigurationStatus(
backup: {
provider: cloudProvider,
autoUpload: cloudAutoUpload,
locked: !isAdmiral,
locked: false,
requiredTier: 'admiral',
},
};
+2 -2
View File
@@ -114,11 +114,11 @@ The Automation block only renders on Skipper or Admiral.
| Row | What it shows |
|-----|---------------|
| **Cloud Backup** (Admiral) | The active cloud backup target: `Sencho Cloud`, `Custom S3` (with ` (auto)` appended when auto-upload is enabled), or `Disabled` |
| **Cloud Backup** | The active cloud backup target: `Sencho Cloud` (Admiral only), `Custom S3` (with ` (auto)` appended when auto-upload is enabled), or `Disabled` |
| **Alert thresholds** | The current host thresholds, formatted `CPU x% · RAM y% · Disk z%` |
| **Crash detection** | `On` when global container-crash notifications are enabled, `Off` otherwise |
Click any row to jump directly to the settings section that manages it. Rows that require a higher tier than the active license are not rendered at all; you do not see a locked placeholder. The section headers (Notifications, Automation, Security, Backups & Thresholds) only render when at least one of their rows is visible, so a Community node sees a tighter card with no Automation block and no Cloud Backup row.
Click any row to jump directly to the settings section that manages it. Rows that require a higher tier than the active license are not rendered at all; you do not see a locked placeholder. The section headers (Notifications, Automation, Security, Backups & Thresholds) only render when at least one of their rows is visible.
The data refreshes automatically every 60 seconds and immediately on any container start/stop/restart event broadcast over the live notification stream, so the card stays in lockstep with what the rest of the UI shows.
+3 -3
View File
@@ -38,7 +38,7 @@ The snapshot list shows each snapshot in a table with the following columns:
- **Description** - your optional label, or a prefix like "Scheduled snapshot" for automated ones. If Cloud Backup is configured, an upload icon in this column marks snapshots that have been mirrored off-site.
- **Scope** - how many nodes and stacks were captured (e.g. "3 nodes, 21 stacks")
- **Warnings** - a warning icon with a count if any nodes were skipped, or "None"
- **Actions** - **View** to open the detail view, a cloud-upload icon for snapshots not yet mirrored (Admiral only), and a delete button for admins
- **Actions** - **View** to open the detail view, a cloud-upload icon for snapshots not yet mirrored to a configured Cloud Backup target, and a delete button for admins
<Frame>
<img src="/images/fleet-backups/browse-snapshots.png" alt="Snapshot list showing paginated rows with date, description with cloud upload indicator, scope, warnings, and action buttons" />
@@ -87,7 +87,7 @@ Admins can delete snapshots from the list view by clicking the trash icon on the
## Cloud Backup
<Note>
Cloud Backup requires an Admiral license. Configure it in **Settings → System → Cloud Backup**.
Custom S3-compatible storage is available on every tier. Sencho Cloud Backup is an Admiral feature. Configure either in **Settings → System → Cloud Backup**.
</Note>
Cloud Backup mirrors every fleet snapshot to off-site storage so your snapshots survive local disk failure. The Cloud Backup settings page (reached via **Settings → System → Cloud Backup**) shows a header with your current scope, provider, storage used, and total snapshot count in the cloud. Two storage modes are supported.
@@ -146,7 +146,7 @@ For in-place rollback, use the **Restore** action on the snapshot detail view as
| Delete cloud snapshot | Yes | No | No | No | No |
<Note>
Cloud backup actions (upload, delete cloud snapshots) also require an Admiral license.
Cloud backup actions to Sencho Cloud Backup require an Admiral license. Cloud backup actions to a Custom S3-compatible target work on every tier.
</Note>
## Storage
+1
View File
@@ -30,6 +30,7 @@ For larger deployments, an **Enterprise** tier is available with custom pricing,
- Git sources for compose stacks
- Multi-node management in both Proxy and Pilot Agent modes
- Manual fleet snapshots (create, browse, restore, delete) and per-node Sencho updates
- Custom S3-compatible backup target (bring your own AWS S3, Cloudflare R2, MinIO, Backblaze B2, or Wasabi bucket)
- Vulnerability scanning: install, update, and uninstall Trivy, on-demand scans for vulnerabilities, secrets, and misconfigurations, plus scan comparison
- CVE suppressions
- Alert rules with Discord, Slack, and webhook targets
+1 -1
View File
@@ -257,7 +257,7 @@ See [Private Registries](/features/private-registries) for the full walkthrough.
## Cloud Backup
<Note>
Cloud Backup requires a Sencho Admiral license.
Custom S3-compatible storage is available on every tier. Sencho Cloud Backup is an Admiral feature.
</Note>
**Scope:** Global, admin-only
+24 -3
View File
@@ -67,6 +67,12 @@ export default function FleetSnapshots() {
const { license, isPaid } = useLicense();
const isAdmiral = isPaid && license?.variant === 'admiral';
// Cloud-upload affordance is reachable when the saved provider is custom
// (every tier) or sencho on an Admiral license. A downgraded admin whose
// saved provider is still 'sencho' sees no upload button — they cannot
// call POST /cloud-backup/upload/:id because gateForCurrentProvider would
// 403 anyway, so the UI must not advertise an action that is gated away.
const [cloudEnabled, setCloudEnabled] = useState(false);
const [snapshots, setSnapshots] = useState<FleetSnapshot[]>([]);
const [loading, setLoading] = useState(true);
const [creating, setCreating] = useState(false);
@@ -114,8 +120,19 @@ export default function FleetSnapshots() {
fetchSnapshots();
}, [fetchSnapshots]);
const fetchCloudConfig = useCallback(async () => {
try {
const res = await apiFetch('/cloud-backup/config', { localOnly: true });
if (!res.ok) return;
const data = await res.json() as { provider: 'disabled' | 'sencho' | 'custom' };
setCloudEnabled(data.provider === 'custom' || (data.provider === 'sencho' && isAdmiral));
} catch {
// best-effort; cloud affordances stay hidden on failure
}
}, [isAdmiral]);
const fetchCloudSnapshots = useCallback(async () => {
if (!isAdmiral) return;
if (!cloudEnabled) return;
try {
const res = await apiFetch('/cloud-backup/snapshots', { localOnly: true });
if (!res.ok) return;
@@ -124,7 +141,11 @@ export default function FleetSnapshots() {
} catch {
// best-effort; cloud indicators stay hidden on failure
}
}, [isAdmiral]);
}, [cloudEnabled]);
useEffect(() => {
fetchCloudConfig();
}, [fetchCloudConfig]);
useEffect(() => {
fetchCloudSnapshots();
@@ -611,7 +632,7 @@ 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) && (
{isAdmin && cloudEnabled && !cloudSnapshotIds.has(snapshot.id) && (
<Button
variant="ghost"
size="sm"
@@ -9,7 +9,7 @@ import { ConfirmModal } from '@/components/ui/modal';
import { toast } from '@/components/ui/toast-store';
import { apiFetch } from '@/lib/api';
import { formatBytes } from '@/lib/utils';
import { AdmiralGate } from '@/components/AdmiralGate';
import { useLicense } from '@/context/LicenseContext';
import { Cloud, CloudOff, RefreshCw, CheckCircle2, Loader2, Trash2, Download, ChevronLeft, ChevronRight } from 'lucide-react';
import { SettingsPrimaryButton } from './SettingsActions';
import { useMastheadStats } from './MastheadStatsContext';
@@ -56,17 +56,23 @@ const EMPTY_CUSTOM: CustomConfig = {
auto_upload: false,
};
const PROVIDER_OPTIONS = [
const BASE_PROVIDER_OPTIONS = [
{ value: 'disabled', label: 'Disabled' },
{ value: 'sencho', label: 'Sencho Cloud Backup (included)' },
{ value: 'custom', label: 'Custom S3 (BYOB)' },
];
const SENCHO_PROVIDER_OPTION = { value: 'sencho', label: 'Sencho Cloud Backup (included)' };
const PANEL_CLASS = 'rounded-lg border border-card-border border-t-card-border-top bg-card shadow-card-bevel p-4 space-y-3';
const PAGE_SIZE = 10;
export function CloudBackupSection() {
const { license, isPaid } = useLicense();
const isAdmiral = isPaid && license?.variant === 'admiral';
const providerOptions = isAdmiral
? [BASE_PROVIDER_OPTIONS[0], SENCHO_PROVIDER_OPTION, BASE_PROVIDER_OPTIONS[1]]
: BASE_PROVIDER_OPTIONS;
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [provider, setProvider] = useState<Provider>('disabled');
@@ -292,263 +298,261 @@ export function CloudBackupSection() {
const usageColor = usagePercent >= 90 ? 'var(--destructive)' : usagePercent >= 80 ? 'var(--warning)' : 'var(--brand)';
return (
<AdmiralGate>
<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>
<div className="space-y-6">
<div className={PANEL_CLASS}>
<Label className="text-sm">Storage Mode</Label>
<Combobox
options={providerOptions}
value={provider}
onValueChange={handleProviderChange}
disabled={saving}
/>
<p className="text-xs text-muted-foreground">
Choose where fleet snapshots are replicated.
</p>
</div>
{provider === 'sencho' && !senchoProvisioned && (
<div className={PANEL_CLASS}>
{isAdmiral && 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>
<SettingsPrimaryButton 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
</SettingsPrimaryButton>
</div>
)}
{isAdmiral && 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">Activate Sencho Cloud Backup</span>
<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>
<SettingsPrimaryButton size="sm" onClick={handleSaveCustom} disabled={saving}>
{saving ? <Loader2 className="w-3.5 h-3.5 animate-spin" strokeWidth={1.5} /> : null}
Save
</SettingsPrimaryButton>
</div>
<p className="text-xs text-muted-foreground">
Activates a 500 MB allowance backed by Cloudflare R2, scoped to this Admiral license.
</p>
<SettingsPrimaryButton 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
</SettingsPrimaryButton>
</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 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>
{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)
<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>
<div className="flex items-center gap-1.5">
{needsPagination && (
<>
<Button variant="ghost" size="icon" className="h-6 w-6" disabled={safePage === 0} onClick={() => setPage(safePage - 1)} aria-label="Previous page">
<ChevronLeft className="h-3.5 w-3.5" strokeWidth={1.5} />
</Button>
<span className="text-xs font-mono tabular-nums text-stat-subtitle min-w-[3rem] text-center">
{safePage + 1} / {totalPages}
</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>
<Button variant="ghost" size="icon" className="h-6 w-6" disabled={safePage >= totalPages - 1} onClick={() => setPage(safePage + 1)} aria-label="Next page">
<ChevronRight className="h-3.5 w-3.5" strokeWidth={1.5} />
</Button>
</>
)}
<Button size="sm" variant="ghost" onClick={loadSnapshots}>
<RefreshCw className="w-3.5 h-3.5" strokeWidth={1.5} />
</Button>
</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>
<SettingsPrimaryButton size="sm" onClick={handleSaveCustom} disabled={saving}>
{saving ? <Loader2 className="w-3.5 h-3.5 animate-spin" strokeWidth={1.5} /> : null}
Save
</SettingsPrimaryButton>
</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>
<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>
<div className="flex items-center gap-1.5">
{needsPagination && (
<>
<Button variant="ghost" size="icon" className="h-6 w-6" disabled={safePage === 0} onClick={() => setPage(safePage - 1)} aria-label="Previous page">
<ChevronLeft className="h-3.5 w-3.5" strokeWidth={1.5} />
</Button>
<span className="text-xs font-mono tabular-nums text-stat-subtitle min-w-[3rem] text-center">
{safePage + 1} / {totalPages}
</span>
<Button variant="ghost" size="icon" className="h-6 w-6" disabled={safePage >= totalPages - 1} onClick={() => setPage(safePage + 1)} aria-label="Next page">
<ChevronRight className="h-3.5 w-3.5" strokeWidth={1.5} />
</Button>
</>
)}
<Button size="sm" variant="ghost" onClick={loadSnapshots}>
<RefreshCw className="w-3.5 h-3.5" strokeWidth={1.5} />
</Button>
</div>
</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">
{pagedSnapshots.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>
) : (
<ul className="space-y-1.5">
{pagedSnapshots.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 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.');
</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})`);
}
}}
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>
)}
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>
)}
<ConfirmModal
open={!!deleteKey}
onOpenChange={open => !open && setDeleteKey(null)}
variant="destructive"
kicker="CLOUD · DELETE · IRREVERSIBLE"
title="Delete cloud snapshot"
confirmLabel="Delete"
onConfirm={confirmDelete}
>
<p className="text-sm text-stat-subtitle">
Permanently removes the archive from your bucket. The local SQLite copy is unaffected.
</p>
</ConfirmModal>
</div>
</AdmiralGate>
<ConfirmModal
open={!!deleteKey}
onOpenChange={open => !open && setDeleteKey(null)}
variant="destructive"
kicker="CLOUD · DELETE · IRREVERSIBLE"
title="Delete cloud snapshot"
confirmLabel="Delete"
onConfirm={confirmDelete}
>
<p className="text-sm text-stat-subtitle">
Permanently removes the archive from your bucket. The local SQLite copy is unaffected.
</p>
</ConfirmModal>
</div>
);
}
+1 -1
View File
@@ -120,7 +120,7 @@ export const SETTINGS_ITEMS: readonly SettingsItemMeta[] = [
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',
tier: null,
scope: 'global',
adminOnly: true,
hiddenOnRemote: true,