352 lines
14 KiB
TypeScript
352 lines
14 KiB
TypeScript
import { create } from "@bufbuild/protobuf";
|
|
import { isEqual } from "lodash-es";
|
|
import { CloudIcon, DatabaseIcon, FolderIcon, LucideIcon } from "lucide-react";
|
|
import React, { useEffect, useMemo, useState } from "react";
|
|
import { toast } from "react-hot-toast";
|
|
import { Badge } from "@/components/ui/badge";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Input } from "@/components/ui/input";
|
|
import { Label } from "@/components/ui/label";
|
|
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
|
|
import { Switch } from "@/components/ui/switch";
|
|
import { useInstance } from "@/contexts/InstanceContext";
|
|
import { handleError } from "@/lib/error";
|
|
import { cn } from "@/lib/utils";
|
|
import {
|
|
InstanceSetting_Key,
|
|
InstanceSetting_StorageSetting,
|
|
InstanceSetting_StorageSetting_S3Config,
|
|
InstanceSetting_StorageSetting_S3ConfigSchema,
|
|
InstanceSetting_StorageSetting_StorageType,
|
|
InstanceSetting_StorageSettingSchema,
|
|
InstanceSettingSchema,
|
|
} from "@/types/proto/api/v1/instance_service_pb";
|
|
import { useTranslate } from "@/utils/i18n";
|
|
import SettingGroup from "./SettingGroup";
|
|
import SettingRow from "./SettingRow";
|
|
import SettingSection from "./SettingSection";
|
|
|
|
const DEFAULT_FILEPATH_TEMPLATE = "assets/{timestamp}_{uuid}_{filename}";
|
|
|
|
type StorageTypeOption = {
|
|
storageType: InstanceSetting_StorageSetting_StorageType;
|
|
id: string;
|
|
titleKey: "setting.storage.type-database" | "setting.storage.type-local" | "setting.storage.type-s3";
|
|
descriptionKey: "setting.storage.database-description" | "setting.storage.local-description" | "setting.storage.s3-description";
|
|
noteKeys: readonly [
|
|
"setting.storage.database-note-backup" | "setting.storage.local-note-path" | "setting.storage.s3-note-scale",
|
|
"setting.storage.database-note-size" | "setting.storage.local-note-backup" | "setting.storage.s3-note-config",
|
|
];
|
|
icon: LucideIcon;
|
|
badges?: readonly ("setting.storage.badge-default" | "setting.storage.badge-recommended")[];
|
|
};
|
|
|
|
const storageTypeOptions: StorageTypeOption[] = [
|
|
{
|
|
storageType: InstanceSetting_StorageSetting_StorageType.LOCAL,
|
|
id: "storage-type-local",
|
|
titleKey: "setting.storage.type-local",
|
|
descriptionKey: "setting.storage.local-description",
|
|
noteKeys: ["setting.storage.local-note-path", "setting.storage.local-note-backup"],
|
|
icon: FolderIcon,
|
|
badges: ["setting.storage.badge-default", "setting.storage.badge-recommended"],
|
|
},
|
|
{
|
|
storageType: InstanceSetting_StorageSetting_StorageType.DATABASE,
|
|
id: "storage-type-database",
|
|
titleKey: "setting.storage.type-database",
|
|
descriptionKey: "setting.storage.database-description",
|
|
noteKeys: ["setting.storage.database-note-backup", "setting.storage.database-note-size"],
|
|
icon: DatabaseIcon,
|
|
},
|
|
{
|
|
storageType: InstanceSetting_StorageSetting_StorageType.S3,
|
|
id: "storage-type-s3",
|
|
titleKey: "setting.storage.type-s3",
|
|
descriptionKey: "setting.storage.s3-description",
|
|
noteKeys: ["setting.storage.s3-note-scale", "setting.storage.s3-note-config"],
|
|
icon: CloudIcon,
|
|
},
|
|
];
|
|
|
|
const StorageSection = () => {
|
|
const t = useTranslate();
|
|
const { storageSetting: originalSetting, updateSetting, fetchSetting } = useInstance();
|
|
const [instanceStorageSetting, setInstanceStorageSetting] = useState<InstanceSetting_StorageSetting>(originalSetting);
|
|
|
|
const selectedStorageOption = useMemo(
|
|
() => storageTypeOptions.find((option) => option.storageType === instanceStorageSetting.storageType) ?? storageTypeOptions[0],
|
|
[instanceStorageSetting.storageType],
|
|
);
|
|
const SelectedStorageIcon = selectedStorageOption.icon;
|
|
|
|
useEffect(() => {
|
|
setInstanceStorageSetting(originalSetting);
|
|
}, [originalSetting]);
|
|
|
|
const allowSaveStorageSetting = useMemo(() => {
|
|
if (instanceStorageSetting.uploadSizeLimitMb <= 0) {
|
|
return false;
|
|
}
|
|
|
|
if (instanceStorageSetting.storageType === InstanceSetting_StorageSetting_StorageType.LOCAL) {
|
|
if (instanceStorageSetting.filepathTemplate.length === 0) {
|
|
return false;
|
|
}
|
|
} else if (instanceStorageSetting.storageType === InstanceSetting_StorageSetting_StorageType.S3) {
|
|
const hasExistingS3Config = originalSetting.s3Config !== undefined;
|
|
if (
|
|
!instanceStorageSetting.filepathTemplate ||
|
|
!instanceStorageSetting.s3Config?.accessKeyId ||
|
|
(!hasExistingS3Config && !instanceStorageSetting.s3Config?.accessKeySecret) ||
|
|
!instanceStorageSetting.s3Config?.endpoint ||
|
|
!instanceStorageSetting.s3Config?.region ||
|
|
!instanceStorageSetting.s3Config?.bucket
|
|
) {
|
|
return false;
|
|
}
|
|
}
|
|
return !isEqual(originalSetting, instanceStorageSetting);
|
|
}, [instanceStorageSetting, originalSetting]);
|
|
|
|
const handleMaxUploadSizeChanged = (event: React.ChangeEvent<HTMLInputElement>) => {
|
|
let num = parseInt(event.target.value);
|
|
if (Number.isNaN(num)) {
|
|
num = 0;
|
|
}
|
|
setInstanceStorageSetting(
|
|
create(InstanceSetting_StorageSettingSchema, {
|
|
...instanceStorageSetting,
|
|
uploadSizeLimitMb: BigInt(num),
|
|
}),
|
|
);
|
|
};
|
|
|
|
const handleFilepathTemplateChanged = (event: React.ChangeEvent<HTMLInputElement>) => {
|
|
setInstanceStorageSetting(
|
|
create(InstanceSetting_StorageSettingSchema, {
|
|
...instanceStorageSetting,
|
|
filepathTemplate: event.target.value,
|
|
}),
|
|
);
|
|
};
|
|
|
|
const handleS3FieldChange = (field: keyof InstanceSetting_StorageSetting_S3Config, value: string | boolean) => {
|
|
const existing = instanceStorageSetting.s3Config;
|
|
setInstanceStorageSetting(
|
|
create(InstanceSetting_StorageSettingSchema, {
|
|
storageType: instanceStorageSetting.storageType,
|
|
filepathTemplate: instanceStorageSetting.filepathTemplate,
|
|
uploadSizeLimitMb: instanceStorageSetting.uploadSizeLimitMb,
|
|
s3Config: create(InstanceSetting_StorageSetting_S3ConfigSchema, {
|
|
accessKeyId: existing?.accessKeyId ?? "",
|
|
accessKeySecret: existing?.accessKeySecret ?? "",
|
|
endpoint: existing?.endpoint ?? "",
|
|
region: existing?.region ?? "",
|
|
bucket: existing?.bucket ?? "",
|
|
usePathStyle: existing?.usePathStyle ?? false,
|
|
[field]: value,
|
|
}),
|
|
}),
|
|
);
|
|
};
|
|
|
|
const handleStorageTypeChanged = (storageType: InstanceSetting_StorageSetting_StorageType) => {
|
|
setInstanceStorageSetting(
|
|
create(InstanceSetting_StorageSettingSchema, {
|
|
...instanceStorageSetting,
|
|
storageType,
|
|
filepathTemplate: instanceStorageSetting.filepathTemplate || DEFAULT_FILEPATH_TEMPLATE,
|
|
}),
|
|
);
|
|
};
|
|
|
|
const saveInstanceStorageSetting = async () => {
|
|
try {
|
|
await updateSetting(
|
|
create(InstanceSettingSchema, {
|
|
name: `instance/settings/${InstanceSetting_Key[InstanceSetting_Key.STORAGE]}`,
|
|
value: {
|
|
case: "storageSetting",
|
|
value: instanceStorageSetting,
|
|
},
|
|
}),
|
|
);
|
|
await fetchSetting(InstanceSetting_Key.STORAGE);
|
|
toast.success(t("message.update-succeed"));
|
|
} catch (error: unknown) {
|
|
handleError(error, toast.error, {
|
|
context: "Update storage settings",
|
|
});
|
|
}
|
|
};
|
|
|
|
return (
|
|
<SettingSection title={t("setting.storage.label")}>
|
|
<SettingGroup title={t("setting.storage.current-storage")} description={t("setting.storage.current-storage-description")}>
|
|
<RadioGroup
|
|
value={String(instanceStorageSetting.storageType)}
|
|
onValueChange={(value) => {
|
|
handleStorageTypeChanged(Number(value) as InstanceSetting_StorageSetting_StorageType);
|
|
}}
|
|
className="overflow-hidden rounded-lg border border-border bg-background divide-y divide-border"
|
|
>
|
|
{storageTypeOptions.map((option) => {
|
|
const Icon = option.icon;
|
|
const selected = instanceStorageSetting.storageType === option.storageType;
|
|
return (
|
|
<div
|
|
key={option.id}
|
|
className={cn(
|
|
"relative flex border-border bg-background px-3 py-3 transition-colors first:rounded-t-lg last:rounded-b-lg",
|
|
selected ? "bg-muted/50" : "hover:bg-muted/30",
|
|
)}
|
|
>
|
|
{selected && <div className="absolute inset-y-2 left-0 w-0.5 rounded-full bg-primary" aria-hidden />}
|
|
<div className="flex w-full items-start gap-3">
|
|
<RadioGroupItem value={String(option.storageType)} id={option.id} className="mt-0.5" />
|
|
<Label
|
|
htmlFor={option.id}
|
|
className="grid min-w-0 flex-1 cursor-pointer gap-2 sm:grid-cols-[minmax(12rem,16rem)_1fr] sm:gap-5"
|
|
>
|
|
<div className="flex min-w-0 flex-col gap-1">
|
|
<div className="flex min-w-0 items-center gap-2">
|
|
<Icon className={cn("size-4 shrink-0", selected ? "text-foreground" : "text-muted-foreground")} />
|
|
<span className="truncate text-sm font-medium text-foreground">{t(option.titleKey)}</span>
|
|
</div>
|
|
{option.badges && (
|
|
<div className="flex flex-wrap gap-1.5 pl-6">
|
|
{option.badges.map((badge) => (
|
|
<Badge
|
|
key={badge}
|
|
variant="outline"
|
|
className={cn(
|
|
"rounded-md px-1.5 py-0 text-[10px] font-normal",
|
|
badge === "setting.storage.badge-recommended" && "border-primary/30 bg-primary/10 text-primary",
|
|
)}
|
|
>
|
|
{t(badge)}
|
|
</Badge>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
<p className="text-xs font-normal leading-5 text-muted-foreground">{t(option.descriptionKey)}</p>
|
|
</Label>
|
|
</div>
|
|
</div>
|
|
);
|
|
})}
|
|
</RadioGroup>
|
|
|
|
<div className="rounded-md border border-border/70 bg-muted/20 px-3 py-2.5">
|
|
<div className="mb-2 flex items-center gap-2 text-xs font-medium text-muted-foreground">
|
|
<SelectedStorageIcon className="size-3.5" />
|
|
<span>{t("setting.storage.selected-backend")}</span>
|
|
<span className="text-foreground">{t(selectedStorageOption.titleKey)}</span>
|
|
</div>
|
|
<ul className="grid gap-1.5 text-xs leading-5 text-muted-foreground sm:grid-cols-2">
|
|
{selectedStorageOption.noteKeys.map((note) => (
|
|
<li key={note} className="flex gap-2">
|
|
<span className="mt-2 size-1 rounded-full bg-muted-foreground/60" aria-hidden />
|
|
<span>{t(note)}</span>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
</div>
|
|
|
|
<SettingRow label={t("setting.system.max-upload-size")} tooltip={t("setting.system.max-upload-size-hint")}>
|
|
<Input
|
|
className="w-24 font-mono"
|
|
value={String(instanceStorageSetting.uploadSizeLimitMb)}
|
|
onChange={handleMaxUploadSizeChanged}
|
|
/>
|
|
</SettingRow>
|
|
|
|
{instanceStorageSetting.storageType !== InstanceSetting_StorageSetting_StorageType.DATABASE && (
|
|
<SettingRow
|
|
label={t("setting.storage.filepath-template")}
|
|
description={t("setting.storage.filepath-template-description")}
|
|
vertical
|
|
>
|
|
<Input
|
|
className="w-full max-w-lg font-mono"
|
|
value={instanceStorageSetting.filepathTemplate}
|
|
placeholder={DEFAULT_FILEPATH_TEMPLATE}
|
|
onChange={handleFilepathTemplateChanged}
|
|
/>
|
|
</SettingRow>
|
|
)}
|
|
</SettingGroup>
|
|
|
|
{instanceStorageSetting.storageType === InstanceSetting_StorageSetting_StorageType.S3 && (
|
|
<SettingGroup
|
|
title={t("setting.storage.s3-configuration")}
|
|
description={t("setting.storage.s3-configuration-description")}
|
|
showSeparator
|
|
>
|
|
<SettingRow label={t("setting.storage.accesskey")} description={t("setting.storage.accesskey-description")}>
|
|
<Input
|
|
className="w-64"
|
|
value={instanceStorageSetting.s3Config?.accessKeyId ?? ""}
|
|
onChange={(e) => handleS3FieldChange("accessKeyId", e.target.value)}
|
|
/>
|
|
</SettingRow>
|
|
|
|
<SettingRow
|
|
label={t("setting.storage.secretkey")}
|
|
description={
|
|
originalSetting.s3Config ? t("setting.storage.secretkey-preserve-description") : t("setting.storage.secretkey-description")
|
|
}
|
|
>
|
|
<Input
|
|
className="w-64"
|
|
type="password"
|
|
value={instanceStorageSetting.s3Config?.accessKeySecret ?? ""}
|
|
onChange={(e) => handleS3FieldChange("accessKeySecret", e.target.value)}
|
|
/>
|
|
</SettingRow>
|
|
|
|
<SettingRow label={t("setting.storage.endpoint")} description={t("setting.storage.endpoint-description")}>
|
|
<Input
|
|
className="w-64"
|
|
value={instanceStorageSetting.s3Config?.endpoint ?? ""}
|
|
onChange={(e) => handleS3FieldChange("endpoint", e.target.value)}
|
|
/>
|
|
</SettingRow>
|
|
|
|
<SettingRow label={t("setting.storage.region")} description={t("setting.storage.region-description")}>
|
|
<Input
|
|
className="w-64"
|
|
value={instanceStorageSetting.s3Config?.region ?? ""}
|
|
onChange={(e) => handleS3FieldChange("region", e.target.value)}
|
|
/>
|
|
</SettingRow>
|
|
|
|
<SettingRow label={t("setting.storage.bucket")} description={t("setting.storage.bucket-description")}>
|
|
<Input
|
|
className="w-64"
|
|
value={instanceStorageSetting.s3Config?.bucket ?? ""}
|
|
onChange={(e) => handleS3FieldChange("bucket", e.target.value)}
|
|
/>
|
|
</SettingRow>
|
|
|
|
<SettingRow label={t("setting.storage.use-path-style")} description={t("setting.storage.use-path-style-description")}>
|
|
<Switch
|
|
checked={instanceStorageSetting.s3Config?.usePathStyle ?? false}
|
|
onCheckedChange={(checked) => handleS3FieldChange("usePathStyle", checked)}
|
|
/>
|
|
</SettingRow>
|
|
</SettingGroup>
|
|
)}
|
|
|
|
<div className="w-full flex justify-end">
|
|
<Button disabled={!allowSaveStorageSetting} onClick={saveInstanceStorageSetting}>
|
|
{t("common.save")}
|
|
</Button>
|
|
</div>
|
|
</SettingSection>
|
|
);
|
|
};
|
|
|
|
export default StorageSection;
|