mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-08 18:05:10 +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:
@@ -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