1284 lines
44 KiB
TypeScript
1284 lines
44 KiB
TypeScript
"use client";
|
|
|
|
import { useState } from "react";
|
|
import {
|
|
Cloud,
|
|
Database,
|
|
HardDrive,
|
|
Key,
|
|
Loader2,
|
|
Pencil,
|
|
Plus,
|
|
Power,
|
|
TestTube2,
|
|
CloudCheck,
|
|
Trash2,
|
|
X,
|
|
type LucideIcon,
|
|
} from "lucide-react";
|
|
import TablePagination from "@/components/TablePagination";
|
|
import { FieldLabel } from "@/app/settings/components/SettingsPrimitives";
|
|
import type {
|
|
S3StorageDestinationInput,
|
|
S3StorageDestinationRecord,
|
|
S3StorageProvider,
|
|
Server as ServerRecord,
|
|
} from "@/lib/api";
|
|
|
|
interface S3StorageSettingsPanelProps {
|
|
destinations: S3StorageDestinationRecord[];
|
|
servers: ServerRecord[];
|
|
actionLoading: string | null;
|
|
savingDestinationId: string | null;
|
|
onUpsertDestination: (destination: DestinationDraft) => Promise<boolean>;
|
|
onVerifyDestination: (destination: DestinationDraft) => Promise<void>;
|
|
onToggleDestination: (
|
|
destination: S3StorageDestinationRecord,
|
|
) => Promise<void>;
|
|
onDeleteDestination: (destination: S3StorageDestinationRecord) => void;
|
|
}
|
|
|
|
type DestinationDraft = S3StorageDestinationInput & {
|
|
id?: string;
|
|
hasSecretAccessKey: boolean;
|
|
};
|
|
|
|
const PROVIDER_OPTIONS: Array<{
|
|
value: S3StorageProvider;
|
|
label: string;
|
|
description: string;
|
|
icon: LucideIcon;
|
|
color: string;
|
|
}> = [
|
|
{
|
|
value: "awsS3",
|
|
label: "AWS S3",
|
|
description: "Default Amazon S3 region endpoint for primary backups.",
|
|
icon: Database,
|
|
color: "#f59e0b",
|
|
},
|
|
{
|
|
value: "cloudflareR2",
|
|
label: "Cloudflare R2",
|
|
description: "S3-compatible bucket for edge-friendly object storage.",
|
|
icon: Cloud,
|
|
color: "#f97316",
|
|
},
|
|
{
|
|
value: "digitalOceanSpaces",
|
|
label: "DO Spaces",
|
|
description: "DigitalOcean object storage with custom endpoint support.",
|
|
icon: HardDrive,
|
|
color: "#2563eb",
|
|
},
|
|
{
|
|
value: "dreamObjects",
|
|
label: "DreamObjects",
|
|
description: "DreamHost S3-compatible destination for archives.",
|
|
icon: Cloud,
|
|
color: "#10b981",
|
|
},
|
|
{
|
|
value: "googleCloudStorage",
|
|
label: "Google Cloud Storage",
|
|
description: "Use GCS interoperability key for S3-compatible access.",
|
|
icon: Database,
|
|
color: "#0ea5e9",
|
|
},
|
|
{
|
|
value: "ibmCos",
|
|
label: "IBM COS",
|
|
description: "IBM Cloud Object Storage with region-aware endpoint.",
|
|
icon: HardDrive,
|
|
color: "#8b5cf6",
|
|
},
|
|
{
|
|
value: "alibabaOss",
|
|
label: "Alibaba OSS",
|
|
description: "OSS bucket for multi-region storage replication.",
|
|
icon: Cloud,
|
|
color: "#ef4444",
|
|
},
|
|
{
|
|
value: "huaweiObs",
|
|
label: "Huawei OBS",
|
|
description: "Object storage for Huawei Cloud workloads.",
|
|
icon: Database,
|
|
color: "#06b6d4",
|
|
},
|
|
{
|
|
value: "ceph",
|
|
label: "Ceph",
|
|
description: "Private S3-compatible cluster with custom endpoint.",
|
|
icon: HardDrive,
|
|
color: "#64748b",
|
|
},
|
|
{
|
|
value: "custom",
|
|
label: "Custom S3",
|
|
description: "Bring your own S3 endpoint for internal or niche providers.",
|
|
icon: Cloud,
|
|
color: "#14b8a6",
|
|
},
|
|
];
|
|
|
|
const PAGE_SIZE = 6;
|
|
|
|
function createDraft(): DestinationDraft {
|
|
return {
|
|
name: "",
|
|
provider: "awsS3",
|
|
enabled: true,
|
|
accessKeyId: "",
|
|
secretAccessKey: "",
|
|
hasSecretAccessKey: false,
|
|
region: "ap-southeast-1",
|
|
bucket: "",
|
|
endpoint: "",
|
|
additionalFlags: [],
|
|
retentionDays: null,
|
|
maxBackupCount: null,
|
|
objectPrefix: "",
|
|
serverId: null,
|
|
};
|
|
}
|
|
|
|
function getProviderMeta(provider: S3StorageProvider) {
|
|
return (
|
|
PROVIDER_OPTIONS.find((option) => option.value === provider) ||
|
|
PROVIDER_OPTIONS[0]
|
|
);
|
|
}
|
|
|
|
function getDestinationSummary(destination: DestinationDraft) {
|
|
const parts = [destination.bucket, destination.region]
|
|
.map((value) => value.trim())
|
|
.filter(Boolean);
|
|
|
|
if (destination.endpoint?.trim()) {
|
|
parts.push(destination.endpoint.trim());
|
|
}
|
|
|
|
return parts.length > 0
|
|
? parts.join(" • ")
|
|
: "Bucket and region are incomplete";
|
|
}
|
|
|
|
function StatusButton({
|
|
active,
|
|
loading,
|
|
onClick,
|
|
}: {
|
|
active: boolean;
|
|
loading?: boolean;
|
|
onClick: () => void;
|
|
}) {
|
|
return (
|
|
<button
|
|
type="button"
|
|
className="btn btn-ghost"
|
|
onClick={onClick}
|
|
disabled={loading}
|
|
style={{
|
|
minWidth: 108,
|
|
justifyContent: "center",
|
|
gap: 8,
|
|
padding: "7px 14px",
|
|
fontSize: 12,
|
|
color: active ? "#10b981" : "var(--text-secondary)",
|
|
borderColor: active ? "rgba(16,185,129,0.24)" : "var(--border)",
|
|
background: active ? "rgba(16,185,129,0.08)" : "transparent",
|
|
}}
|
|
>
|
|
{loading ? (
|
|
<Loader2 size={14} className="animate-spin" />
|
|
) : (
|
|
<Power size={14} />
|
|
)}
|
|
{active ? "Active" : "Inactive"}
|
|
</button>
|
|
);
|
|
}
|
|
|
|
export default function S3StorageSettingsPanel({
|
|
destinations,
|
|
servers,
|
|
actionLoading,
|
|
savingDestinationId,
|
|
onUpsertDestination,
|
|
onVerifyDestination,
|
|
onToggleDestination,
|
|
onDeleteDestination,
|
|
}: S3StorageSettingsPanelProps) {
|
|
const [draft, setDraft] = useState<DestinationDraft | null>(null);
|
|
const [editingId, setEditingId] = useState<string | null>(null);
|
|
const [currentPage, setCurrentPage] = useState(1);
|
|
|
|
const totalItems = destinations.length;
|
|
const totalPages = Math.max(1, Math.ceil(totalItems / PAGE_SIZE));
|
|
const safePage = Math.min(currentPage, totalPages);
|
|
const startIndex = (safePage - 1) * PAGE_SIZE;
|
|
const pagedDestinations = destinations.slice(
|
|
startIndex,
|
|
startIndex + PAGE_SIZE,
|
|
);
|
|
const startItem = totalItems === 0 ? 0 : startIndex + 1;
|
|
const endItem = totalItems === 0 ? 0 : startIndex + pagedDestinations.length;
|
|
|
|
const openCreateModal = () => {
|
|
setEditingId(null);
|
|
setDraft(createDraft());
|
|
};
|
|
|
|
const openEditModal = (destination: S3StorageDestinationRecord) => {
|
|
setEditingId(destination.id);
|
|
setDraft({
|
|
id: destination.id,
|
|
name: destination.name,
|
|
provider: destination.provider,
|
|
enabled: destination.enabled,
|
|
accessKeyId: destination.accessKeyId,
|
|
secretAccessKey: "",
|
|
hasSecretAccessKey: destination.hasSecretAccessKey,
|
|
region: destination.region,
|
|
bucket: destination.bucket,
|
|
endpoint: destination.endpoint,
|
|
additionalFlags: [...destination.additionalFlags],
|
|
retentionDays: destination.retentionDays,
|
|
maxBackupCount: destination.maxBackupCount,
|
|
objectPrefix: destination.objectPrefix,
|
|
serverId: destination.serverId,
|
|
});
|
|
};
|
|
|
|
const closeModal = () => {
|
|
setEditingId(null);
|
|
setDraft(null);
|
|
};
|
|
|
|
const saveDraft = async () => {
|
|
if (!draft) return;
|
|
|
|
const success = await onUpsertDestination(draft);
|
|
if (success) {
|
|
closeModal();
|
|
}
|
|
};
|
|
|
|
return (
|
|
<>
|
|
<div className="card" style={{ overflow: "hidden" }}>
|
|
<div
|
|
style={{
|
|
padding: 20,
|
|
display: "flex",
|
|
alignItems: "center",
|
|
justifyContent: "space-between",
|
|
gap: 16,
|
|
flexWrap: "wrap",
|
|
borderBottom:
|
|
destinations.length > 0 ? "1px solid var(--border)" : "none",
|
|
}}
|
|
>
|
|
<div style={{ display: "flex", alignItems: "center", gap: 12 }}>
|
|
<div
|
|
style={{
|
|
width: 42,
|
|
height: 42,
|
|
borderRadius: 14,
|
|
background: "rgba(14,165,233,0.12)",
|
|
border: "1px solid rgba(14,165,233,0.24)",
|
|
display: "flex",
|
|
alignItems: "center",
|
|
justifyContent: "center",
|
|
flexShrink: 0,
|
|
}}
|
|
>
|
|
<Database size={18} style={{ color: "#0ea5e9" }} />
|
|
</div>
|
|
<div>
|
|
<h2
|
|
style={{
|
|
fontSize: 15,
|
|
fontWeight: 700,
|
|
color: "var(--text-primary)",
|
|
}}
|
|
>
|
|
Storage Destinations
|
|
</h2>
|
|
<p
|
|
style={{
|
|
fontSize: 12,
|
|
color: "var(--text-muted)",
|
|
marginTop: 4,
|
|
}}
|
|
>
|
|
Add and manage multiple S3-compatible storage destinations and
|
|
target specific servers as needed.
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
<button
|
|
type="button"
|
|
className="btn btn-primary"
|
|
onClick={openCreateModal}
|
|
style={{ padding: "8px 14px", fontSize: 12 }}
|
|
>
|
|
<Plus size={14} /> Add Destination
|
|
</button>
|
|
</div>
|
|
|
|
{destinations.length === 0 ? (
|
|
<div style={{ padding: 24 }}>
|
|
<div
|
|
style={{
|
|
border: "1px dashed var(--border)",
|
|
borderRadius: 16,
|
|
padding: 24,
|
|
textAlign: "center",
|
|
background: "var(--bg-input)",
|
|
}}
|
|
>
|
|
<HardDrive
|
|
size={18}
|
|
style={{ color: "var(--text-muted)", marginBottom: 10 }}
|
|
/>
|
|
<p
|
|
style={{
|
|
fontSize: 14,
|
|
fontWeight: 600,
|
|
color: "var(--text-primary)",
|
|
}}
|
|
>
|
|
No storage destinations yet
|
|
</p>
|
|
<p
|
|
style={{
|
|
fontSize: 12,
|
|
color: "var(--text-muted)",
|
|
marginTop: 6,
|
|
}}
|
|
>
|
|
Create your first destination for global backup or specific
|
|
target servers.
|
|
</p>
|
|
</div>
|
|
</div>
|
|
) : (
|
|
<>
|
|
<div style={{ overflowX: "auto" }}>
|
|
<table className="data-table">
|
|
<thead>
|
|
<tr>
|
|
<th>Destination</th>
|
|
<th>Provider</th>
|
|
<th>Target Server</th>
|
|
<th>Connection</th>
|
|
<th style={{ textAlign: "right" }}>Actions</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{pagedDestinations.map((destination) => {
|
|
const meta = getProviderMeta(destination.provider);
|
|
const Icon = meta.icon;
|
|
const isDeleting =
|
|
savingDestinationId === `delete-${destination.id}`;
|
|
const isVerifying =
|
|
actionLoading === `verify-${destination.id}`;
|
|
|
|
return (
|
|
<tr key={destination.id}>
|
|
<td>
|
|
<div
|
|
style={{
|
|
display: "flex",
|
|
alignItems: "center",
|
|
gap: 12,
|
|
}}
|
|
>
|
|
<div
|
|
style={{
|
|
width: 36,
|
|
height: 36,
|
|
borderRadius: 12,
|
|
background: `${meta.color}18`,
|
|
border: `1px solid ${meta.color}33`,
|
|
display: "flex",
|
|
alignItems: "center",
|
|
justifyContent: "center",
|
|
flexShrink: 0,
|
|
}}
|
|
>
|
|
<Icon size={15} style={{ color: meta.color }} />
|
|
</div>
|
|
<div>
|
|
<p
|
|
style={{
|
|
fontSize: 13,
|
|
fontWeight: 600,
|
|
color: "var(--text-primary)",
|
|
}}
|
|
>
|
|
{destination.name}
|
|
</p>
|
|
<p
|
|
style={{
|
|
fontSize: 11,
|
|
color: "var(--text-muted)",
|
|
marginTop: 4,
|
|
}}
|
|
>
|
|
{destination.accessKeyId ||
|
|
"Access key not provided"}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</td>
|
|
<td>
|
|
<p
|
|
style={{
|
|
fontSize: 12,
|
|
color: "var(--text-secondary)",
|
|
fontWeight: 500,
|
|
}}
|
|
>
|
|
{meta.label}
|
|
</p>
|
|
<p
|
|
style={{
|
|
fontSize: 11,
|
|
color: "var(--text-muted)",
|
|
marginTop: 4,
|
|
}}
|
|
>
|
|
{meta.description}
|
|
</p>
|
|
</td>
|
|
<td>
|
|
<p
|
|
style={{
|
|
fontSize: 12,
|
|
color: "var(--text-secondary)",
|
|
fontWeight: 500,
|
|
}}
|
|
>
|
|
{destination.targetServer?.name ||
|
|
"All accessible servers"}
|
|
</p>
|
|
<p
|
|
style={{
|
|
fontSize: 11,
|
|
color: "var(--text-muted)",
|
|
marginTop: 4,
|
|
}}
|
|
>
|
|
{destination.targetServer?.ip ||
|
|
"Not restricted to specific servers"}
|
|
</p>
|
|
</td>
|
|
<td>
|
|
<p
|
|
style={{
|
|
fontSize: 12,
|
|
color: "var(--text-secondary)",
|
|
fontWeight: 500,
|
|
}}
|
|
>
|
|
{getDestinationSummary(destination)}
|
|
</p>
|
|
<p
|
|
style={{
|
|
fontSize: 11,
|
|
color: "var(--text-muted)",
|
|
marginTop: 4,
|
|
}}
|
|
>
|
|
{destination.additionalFlags.length > 0
|
|
? `${destination.additionalFlags.length} additional flags`
|
|
: "No additional flags"}
|
|
</p>
|
|
</td>
|
|
<td>
|
|
<div
|
|
style={{
|
|
display: "flex",
|
|
justifyContent: "flex-end",
|
|
gap: 8,
|
|
flexWrap: "wrap",
|
|
}}
|
|
>
|
|
<button
|
|
type="button"
|
|
className="btn btn-ghost"
|
|
onClick={() =>
|
|
void onToggleDestination(destination)
|
|
}
|
|
style={{ padding: "7px 12px", fontSize: 12 }}
|
|
>
|
|
{destination.enabled ? (
|
|
<Power color="#10b981" size={13} />
|
|
) : (
|
|
<Power color="#ef4444" size={13} />
|
|
)}
|
|
{/* {destination.enabled ? "Disable" : "Enable"} */}
|
|
</button>
|
|
<button
|
|
type="button"
|
|
className="btn btn-ghost"
|
|
onClick={() => openEditModal(destination)}
|
|
style={{ padding: "7px 12px", fontSize: 12 }}
|
|
>
|
|
<Pencil size={13} />
|
|
</button>
|
|
<button
|
|
type="button"
|
|
className="btn btn-ghost"
|
|
onClick={() =>
|
|
void onVerifyDestination({
|
|
id: destination.id,
|
|
name: destination.name,
|
|
provider: destination.provider,
|
|
enabled: destination.enabled,
|
|
accessKeyId: destination.accessKeyId,
|
|
secretAccessKey: "",
|
|
hasSecretAccessKey:
|
|
destination.hasSecretAccessKey,
|
|
region: destination.region,
|
|
bucket: destination.bucket,
|
|
endpoint: destination.endpoint,
|
|
additionalFlags: [
|
|
...destination.additionalFlags,
|
|
],
|
|
serverId: destination.serverId,
|
|
})
|
|
}
|
|
disabled={isVerifying}
|
|
style={{ padding: "7px 12px", fontSize: 12 }}
|
|
>
|
|
{isVerifying ? (
|
|
<Loader2 size={13} className="animate-spin" />
|
|
) : (
|
|
<CloudCheck size={13} />
|
|
)}
|
|
</button>
|
|
<button
|
|
type="button"
|
|
className="btn btn-ghost"
|
|
onClick={() => onDeleteDestination(destination)}
|
|
disabled={isDeleting}
|
|
style={{
|
|
padding: "7px 12px",
|
|
fontSize: 12,
|
|
color: "#ef4444",
|
|
}}
|
|
>
|
|
{isDeleting ? (
|
|
<Loader2 size={13} className="animate-spin" />
|
|
) : (
|
|
<Trash2 size={13} />
|
|
)}
|
|
</button>
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
);
|
|
})}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
|
|
<TablePagination
|
|
currentPage={safePage}
|
|
totalPages={totalPages}
|
|
totalItems={totalItems}
|
|
startItem={startItem}
|
|
endItem={endItem}
|
|
itemLabel="destinations"
|
|
onPageChange={(page) =>
|
|
setCurrentPage(Math.min(Math.max(page, 1), totalPages))
|
|
}
|
|
/>
|
|
</>
|
|
)}
|
|
</div>
|
|
|
|
{draft ? (
|
|
<div className="modal-overlay modal-overlay-wide">
|
|
<div className="modal-shell modal-shell-wide">
|
|
<button
|
|
type="button"
|
|
onClick={closeModal}
|
|
className="modal-close"
|
|
aria-label="Close modal"
|
|
title="Close"
|
|
>
|
|
<X size={22} />
|
|
</button>
|
|
<div className="modal modal-wide animate-slide-in">
|
|
<div
|
|
style={{
|
|
display: "flex",
|
|
gap: 12,
|
|
alignItems: "flex-start",
|
|
marginBottom: 22,
|
|
paddingRight: 36,
|
|
}}
|
|
>
|
|
<div>
|
|
<h3
|
|
style={{
|
|
fontSize: 16,
|
|
fontWeight: 700,
|
|
color: "var(--text-primary)",
|
|
}}
|
|
>
|
|
{editingId ? "Update Destination" : "Add Destination"}
|
|
</h3>
|
|
<p
|
|
style={{
|
|
fontSize: 12,
|
|
color: "var(--text-muted)",
|
|
marginTop: 6,
|
|
}}
|
|
>
|
|
Set the destination name, bucket, and target server that
|
|
will use it.
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
<div
|
|
style={{ display: "flex", flexDirection: "column", gap: 20 }}
|
|
>
|
|
<section>
|
|
<p
|
|
style={{
|
|
fontSize: 13,
|
|
fontWeight: 700,
|
|
color: "var(--text-primary)",
|
|
marginBottom: 12,
|
|
}}
|
|
>
|
|
Provider Type
|
|
</p>
|
|
<div
|
|
style={{
|
|
display: "grid",
|
|
gridTemplateColumns:
|
|
"repeat(auto-fit, minmax(180px, 1fr))",
|
|
gap: 10,
|
|
}}
|
|
>
|
|
{PROVIDER_OPTIONS.map((option) => {
|
|
const Icon = option.icon;
|
|
const selected = draft.provider === option.value;
|
|
|
|
return (
|
|
<button
|
|
key={option.value}
|
|
type="button"
|
|
onClick={() =>
|
|
setDraft((current) =>
|
|
current
|
|
? {
|
|
...current,
|
|
provider: option.value,
|
|
endpoint:
|
|
option.value === "awsS3"
|
|
? ""
|
|
: current.endpoint,
|
|
}
|
|
: current,
|
|
)
|
|
}
|
|
style={{
|
|
textAlign: "left",
|
|
padding: 14,
|
|
borderRadius: 14,
|
|
border: selected
|
|
? `1px solid ${option.color}`
|
|
: "1px solid var(--border)",
|
|
background: selected
|
|
? `${option.color}12`
|
|
: "var(--bg-card)",
|
|
display: "flex",
|
|
gap: 12,
|
|
alignItems: "flex-start",
|
|
cursor: "pointer",
|
|
}}
|
|
>
|
|
<div
|
|
style={{
|
|
width: 36,
|
|
height: 36,
|
|
borderRadius: 10,
|
|
background: `${option.color}18`,
|
|
border: `1px solid ${option.color}33`,
|
|
display: "flex",
|
|
alignItems: "center",
|
|
justifyContent: "center",
|
|
flexShrink: 0,
|
|
}}
|
|
>
|
|
<Icon size={15} style={{ color: option.color }} />
|
|
</div>
|
|
<div>
|
|
<p
|
|
style={{
|
|
fontSize: 13,
|
|
fontWeight: 600,
|
|
color: "var(--text-primary)",
|
|
}}
|
|
>
|
|
{option.label}
|
|
</p>
|
|
<p
|
|
style={{
|
|
fontSize: 11,
|
|
color: "var(--text-muted)",
|
|
marginTop: 4,
|
|
}}
|
|
>
|
|
{option.description}
|
|
</p>
|
|
</div>
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
</section>
|
|
|
|
<section>
|
|
<div
|
|
style={{
|
|
display: "flex",
|
|
flexDirection: "column",
|
|
gap: 6,
|
|
marginBottom: 12,
|
|
}}
|
|
>
|
|
<p
|
|
style={{
|
|
fontSize: 13,
|
|
fontWeight: 700,
|
|
color: "var(--text-primary)",
|
|
}}
|
|
>
|
|
Lifecycle & Retention
|
|
</p>
|
|
<p
|
|
style={{
|
|
fontSize: 11,
|
|
color: "var(--text-muted)",
|
|
}}
|
|
>
|
|
Automatically remove old objects from this destination.
|
|
Retention is disabled when both limits are empty.
|
|
</p>
|
|
</div>
|
|
<div
|
|
style={{
|
|
display: "grid",
|
|
gridTemplateColumns:
|
|
"repeat(auto-fit, minmax(min(100%, 280px), 1fr))",
|
|
gap: 14,
|
|
}}
|
|
>
|
|
<div>
|
|
<FieldLabel>Retention days</FieldLabel>
|
|
<input
|
|
className="input"
|
|
type="number"
|
|
min={1}
|
|
max={3650}
|
|
value={draft.retentionDays ?? ""}
|
|
onChange={(event) =>
|
|
setDraft((current) =>
|
|
current
|
|
? {
|
|
...current,
|
|
retentionDays: event.target.value
|
|
? Number(event.target.value)
|
|
: null,
|
|
}
|
|
: current,
|
|
)
|
|
}
|
|
placeholder="30"
|
|
/>
|
|
</div>
|
|
<div>
|
|
<FieldLabel>Maximum backup count</FieldLabel>
|
|
<input
|
|
className="input"
|
|
type="number"
|
|
min={1}
|
|
max={10000}
|
|
value={draft.maxBackupCount ?? ""}
|
|
onChange={(event) =>
|
|
setDraft((current) =>
|
|
current
|
|
? {
|
|
...current,
|
|
maxBackupCount: event.target.value
|
|
? Number(event.target.value)
|
|
: null,
|
|
}
|
|
: current,
|
|
)
|
|
}
|
|
placeholder="10"
|
|
/>
|
|
</div>
|
|
<div style={{ gridColumn: "1 / -1" }}>
|
|
<FieldLabel>Object prefix / folder</FieldLabel>
|
|
<input
|
|
className="input"
|
|
value={draft.objectPrefix ?? ""}
|
|
onChange={(event) =>
|
|
setDraft((current) =>
|
|
current
|
|
? { ...current, objectPrefix: event.target.value }
|
|
: current,
|
|
)
|
|
}
|
|
placeholder="doktainer/backups/production"
|
|
/>
|
|
<p
|
|
style={{
|
|
fontSize: 11,
|
|
color: "var(--text-muted)",
|
|
marginTop: 5,
|
|
}}
|
|
>
|
|
Required when retention or maximum count is enabled.
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
|
|
<section>
|
|
<div
|
|
style={{
|
|
display: "flex",
|
|
justifyContent: "space-between",
|
|
alignItems: "center",
|
|
gap: 12,
|
|
marginBottom: 12,
|
|
}}
|
|
>
|
|
<div>
|
|
<p
|
|
style={{
|
|
fontSize: 13,
|
|
fontWeight: 700,
|
|
color: "var(--text-primary)",
|
|
}}
|
|
>
|
|
Basic Information
|
|
</p>
|
|
<p
|
|
style={{
|
|
fontSize: 11,
|
|
color: "var(--text-muted)",
|
|
marginTop: 4,
|
|
}}
|
|
>
|
|
Set the destination name, bucket, and target server that
|
|
will use it.
|
|
</p>
|
|
</div>
|
|
<StatusButton
|
|
active={draft.enabled}
|
|
onClick={() =>
|
|
setDraft((current) =>
|
|
current
|
|
? { ...current, enabled: !current.enabled }
|
|
: current,
|
|
)
|
|
}
|
|
/>
|
|
</div>
|
|
|
|
<div
|
|
style={{
|
|
display: "flex",
|
|
flexDirection: "column",
|
|
gap: 14,
|
|
}}
|
|
>
|
|
<div>
|
|
<FieldLabel>Destination name</FieldLabel>
|
|
<input
|
|
className="input"
|
|
value={draft.name}
|
|
onChange={(event) =>
|
|
setDraft((current) =>
|
|
current
|
|
? { ...current, name: event.target.value }
|
|
: current,
|
|
)
|
|
}
|
|
placeholder="Example: Backup Singapore, R2 Europe, Archive Internal"
|
|
/>
|
|
</div>
|
|
|
|
<div>
|
|
<FieldLabel>Target server</FieldLabel>
|
|
<select
|
|
className="input"
|
|
value={draft.serverId ?? ""}
|
|
onChange={(event) =>
|
|
setDraft((current) =>
|
|
current
|
|
? {
|
|
...current,
|
|
serverId: event.target.value || null,
|
|
}
|
|
: current,
|
|
)
|
|
}
|
|
>
|
|
<option value="">All accessible servers</option>
|
|
{servers.map((server) => (
|
|
<option key={server.id} value={server.id}>
|
|
{server.name} ({server.ip})
|
|
</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
|
|
<section>
|
|
<p
|
|
style={{
|
|
fontSize: 13,
|
|
fontWeight: 700,
|
|
color: "var(--text-primary)",
|
|
marginBottom: 12,
|
|
}}
|
|
>
|
|
Connection Settings
|
|
</p>
|
|
<div
|
|
style={{
|
|
display: "flex",
|
|
flexDirection: "column",
|
|
gap: 14,
|
|
}}
|
|
>
|
|
<div>
|
|
<FieldLabel>Access Key ID</FieldLabel>
|
|
<div style={{ position: "relative" }}>
|
|
<input
|
|
className="input"
|
|
autoComplete="off"
|
|
value={draft.accessKeyId}
|
|
onChange={(event) =>
|
|
setDraft((current) =>
|
|
current
|
|
? {
|
|
...current,
|
|
accessKeyId: event.target.value,
|
|
}
|
|
: current,
|
|
)
|
|
}
|
|
placeholder="AKIA... or provider interoperability key"
|
|
/>
|
|
<Key
|
|
size={14}
|
|
style={{
|
|
position: "absolute",
|
|
right: 12,
|
|
top: 12,
|
|
color: "var(--text-muted)",
|
|
}}
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<div>
|
|
<FieldLabel>
|
|
Secret Access Key
|
|
{draft.hasSecretAccessKey
|
|
? " (leave blank to keep existing key)"
|
|
: ""}
|
|
</FieldLabel>
|
|
<input
|
|
className="input"
|
|
type="password"
|
|
autoComplete="new-password"
|
|
value={draft.secretAccessKey ?? ""}
|
|
onChange={(event) =>
|
|
setDraft((current) =>
|
|
current
|
|
? {
|
|
...current,
|
|
secretAccessKey: event.target.value,
|
|
hasSecretAccessKey:
|
|
current.hasSecretAccessKey ||
|
|
Boolean(event.target.value.trim()),
|
|
}
|
|
: current,
|
|
)
|
|
}
|
|
/>
|
|
</div>
|
|
|
|
<div>
|
|
<FieldLabel>Bucket</FieldLabel>
|
|
<input
|
|
className="input"
|
|
value={draft.bucket}
|
|
onChange={(event) =>
|
|
setDraft((current) =>
|
|
current
|
|
? { ...current, bucket: event.target.value }
|
|
: current,
|
|
)
|
|
}
|
|
placeholder="doktainer-backups"
|
|
/>
|
|
</div>
|
|
|
|
<div>
|
|
<FieldLabel>Region</FieldLabel>
|
|
<input
|
|
className="input"
|
|
value={draft.region}
|
|
onChange={(event) =>
|
|
setDraft((current) =>
|
|
current
|
|
? { ...current, region: event.target.value }
|
|
: current,
|
|
)
|
|
}
|
|
placeholder="ap-southeast-1"
|
|
/>
|
|
</div>
|
|
|
|
<div>
|
|
<FieldLabel>
|
|
Endpoint
|
|
{draft.provider === "awsS3"
|
|
? " (optional for AWS S3)"
|
|
: " (required for S3-compatible providers)"}
|
|
</FieldLabel>
|
|
<input
|
|
className="input"
|
|
value={draft.endpoint ?? ""}
|
|
onChange={(event) =>
|
|
setDraft((current) =>
|
|
current
|
|
? { ...current, endpoint: event.target.value }
|
|
: current,
|
|
)
|
|
}
|
|
placeholder="https://<account>.r2.cloudflarestorage.com"
|
|
/>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
|
|
<section>
|
|
<div
|
|
style={{
|
|
display: "flex",
|
|
justifyContent: "space-between",
|
|
alignItems: "center",
|
|
gap: 12,
|
|
marginBottom: 12,
|
|
}}
|
|
>
|
|
<div>
|
|
<p
|
|
style={{
|
|
fontSize: 13,
|
|
fontWeight: 700,
|
|
color: "var(--text-primary)",
|
|
}}
|
|
>
|
|
Additional Flags
|
|
</p>
|
|
<p
|
|
style={{
|
|
fontSize: 11,
|
|
color: "var(--text-muted)",
|
|
marginTop: 4,
|
|
}}
|
|
>
|
|
Save additional flags for runtime backup needs or
|
|
provider compatibility.
|
|
</p>
|
|
</div>
|
|
<button
|
|
type="button"
|
|
className="btn btn-ghost"
|
|
onClick={() =>
|
|
setDraft((current) =>
|
|
current
|
|
? {
|
|
...current,
|
|
additionalFlags: [
|
|
...current.additionalFlags,
|
|
"",
|
|
],
|
|
}
|
|
: current,
|
|
)
|
|
}
|
|
style={{ padding: "7px 12px", fontSize: 12 }}
|
|
>
|
|
<Plus size={13} /> Add Flag
|
|
</button>
|
|
</div>
|
|
|
|
<div
|
|
style={{
|
|
display: "flex",
|
|
flexDirection: "column",
|
|
gap: 10,
|
|
}}
|
|
>
|
|
{draft.additionalFlags.length === 0 ? (
|
|
<div
|
|
style={{
|
|
padding: 14,
|
|
borderRadius: 12,
|
|
border: "1px dashed var(--border)",
|
|
background: "var(--bg-input)",
|
|
fontSize: 12,
|
|
color: "var(--text-muted)",
|
|
}}
|
|
>
|
|
No additional flags.
|
|
</div>
|
|
) : null}
|
|
|
|
{draft.additionalFlags.map((flag, index) => (
|
|
<div
|
|
key={`flag-${index}`}
|
|
style={{
|
|
display: "flex",
|
|
gap: 10,
|
|
alignItems: "center",
|
|
}}
|
|
>
|
|
<input
|
|
className="input"
|
|
value={flag}
|
|
onChange={(event) =>
|
|
setDraft((current) => {
|
|
if (!current) return current;
|
|
|
|
const nextFlags = [...current.additionalFlags];
|
|
nextFlags[index] = event.target.value;
|
|
return { ...current, additionalFlags: nextFlags };
|
|
})
|
|
}
|
|
placeholder="Contoh: forcePathStyle=true"
|
|
/>
|
|
<button
|
|
type="button"
|
|
className="btn btn-ghost"
|
|
onClick={() =>
|
|
setDraft((current) => {
|
|
if (!current) return current;
|
|
return {
|
|
...current,
|
|
additionalFlags: current.additionalFlags.filter(
|
|
(_, flagIndex) => flagIndex !== index,
|
|
),
|
|
};
|
|
})
|
|
}
|
|
style={{
|
|
padding: "7px 12px",
|
|
fontSize: 12,
|
|
color: "#ef4444",
|
|
}}
|
|
>
|
|
<Trash2 size={13} />
|
|
</button>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</section>
|
|
|
|
<div
|
|
style={{
|
|
display: "flex",
|
|
justifyContent: "space-between",
|
|
gap: 12,
|
|
alignItems: "center",
|
|
flexWrap: "wrap",
|
|
paddingTop: 6,
|
|
}}
|
|
>
|
|
<div>
|
|
<p
|
|
style={{
|
|
fontSize: 12,
|
|
fontWeight: 600,
|
|
color: "var(--text-primary)",
|
|
}}
|
|
>
|
|
{getProviderMeta(draft.provider).label}
|
|
</p>
|
|
<p
|
|
style={{
|
|
fontSize: 11,
|
|
color: "var(--text-muted)",
|
|
marginTop: 4,
|
|
}}
|
|
>
|
|
{getDestinationSummary(draft)}
|
|
</p>
|
|
</div>
|
|
|
|
<div style={{ display: "flex", gap: 8, flexWrap: "wrap" }}>
|
|
<button
|
|
type="button"
|
|
className="btn btn-ghost"
|
|
onClick={() => void onVerifyDestination(draft)}
|
|
disabled={
|
|
actionLoading === `verify-${draft.id ?? "draft"}`
|
|
}
|
|
style={{ padding: "8px 14px", fontSize: 12 }}
|
|
>
|
|
{actionLoading === `verify-${draft.id ?? "draft"}` ? (
|
|
<Loader2 size={13} className="animate-spin" />
|
|
) : (
|
|
<TestTube2 size={13} />
|
|
)}
|
|
Verify Connection
|
|
</button>
|
|
<button
|
|
type="button"
|
|
className="btn btn-primary"
|
|
onClick={() => void saveDraft()}
|
|
disabled={
|
|
savingDestinationId === (draft.id ?? "new-destination")
|
|
}
|
|
style={{ padding: "8px 14px", fontSize: 12 }}
|
|
>
|
|
{savingDestinationId ===
|
|
(draft.id ?? "new-destination") ? (
|
|
<Loader2 size={13} className="animate-spin" />
|
|
) : null}
|
|
{editingId ? "Update Destination" : "Create Destination"}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
) : null}
|
|
</>
|
|
);
|
|
}
|