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:
Anso
2026-04-26 15:42:21 -04:00
committed by GitHub
parent 801a098a5b
commit 03f91cd5bb
20 changed files with 2805 additions and 401 deletions
+67 -5
View File
@@ -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>