diff --git a/backend/internal/features/storages/model_test.go b/backend/internal/features/storages/model_test.go index e25cc79..4d00d1a 100644 --- a/backend/internal/features/storages/model_test.go +++ b/backend/internal/features/storages/model_test.go @@ -110,6 +110,18 @@ func Test_Storage_BasicOperations(t *testing.T) { S3Endpoint: "http://" + s3Container.endpoint, }, }, + { + name: "S3Storage_WithStorageClass", + storage: &s3_storage.S3Storage{ + StorageID: uuid.New(), + S3Bucket: s3Container.bucketName, + S3Region: s3Container.region, + S3AccessKey: s3Container.accessKey, + S3SecretKey: s3Container.secretKey, + S3Endpoint: "http://" + s3Container.endpoint, + S3StorageClass: s3_storage.S3StorageClassStandard, + }, + }, { name: "NASStorage", storage: &nas_storage.NASStorage{ diff --git a/backend/internal/features/storages/models/s3/enums.go b/backend/internal/features/storages/models/s3/enums.go new file mode 100644 index 0000000..92fca71 --- /dev/null +++ b/backend/internal/features/storages/models/s3/enums.go @@ -0,0 +1,13 @@ +package s3_storage + +type S3StorageClass string + +const ( + S3StorageClassDefault S3StorageClass = "" + S3StorageClassStandard S3StorageClass = "STANDARD" + S3StorageClassStandardIA S3StorageClass = "STANDARD_IA" + S3StorageClassOnezoneIA S3StorageClass = "ONEZONE_IA" + S3StorageClassIntelligentTiering S3StorageClass = "INTELLIGENT_TIERING" + S3StorageClassReducedRedundancy S3StorageClass = "REDUCED_REDUNDANCY" + S3StorageClassGlacierIR S3StorageClass = "GLACIER_IR" +) diff --git a/backend/internal/features/storages/models/s3/model.go b/backend/internal/features/storages/models/s3/model.go index ddbf911..988841a 100644 --- a/backend/internal/features/storages/models/s3/model.go +++ b/backend/internal/features/storages/models/s3/model.go @@ -43,9 +43,10 @@ type S3Storage struct { S3SecretKey string `json:"s3SecretKey" gorm:"not null;type:text;column:s3_secret_key"` S3Endpoint string `json:"s3Endpoint" gorm:"type:text;column:s3_endpoint"` - S3Prefix string `json:"s3Prefix" gorm:"type:text;column:s3_prefix"` - S3UseVirtualHostedStyle bool `json:"s3UseVirtualHostedStyle" gorm:"default:false;column:s3_use_virtual_hosted_style"` - SkipTLSVerify bool `json:"skipTLSVerify" gorm:"default:false;column:skip_tls_verify"` + S3Prefix string `json:"s3Prefix" gorm:"type:text;column:s3_prefix"` + S3UseVirtualHostedStyle bool `json:"s3UseVirtualHostedStyle" gorm:"default:false;column:s3_use_virtual_hosted_style"` + SkipTLSVerify bool `json:"skipTLSVerify" gorm:"default:false;column:skip_tls_verify"` + S3StorageClass S3StorageClass `json:"s3StorageClass" gorm:"type:text;column:s3_storage_class;default:''"` } func (s *S3Storage) TableName() string { @@ -76,7 +77,7 @@ func (s *S3Storage) SaveFile( ctx, s.S3Bucket, objectKey, - minio.PutObjectOptions{}, + s.putObjectOptions(), ) if err != nil { return fmt.Errorf("failed to initiate multipart upload: %w", err) @@ -151,15 +152,16 @@ func (s *S3Storage) SaveFile( if err != nil { return err } + opts := s.putObjectOptions() + opts.SendContentMd5 = true + _, err = client.PutObject( ctx, s.S3Bucket, objectKey, bytes.NewReader([]byte{}), 0, - minio.PutObjectOptions{ - SendContentMd5: true, - }, + opts, ) if err != nil { return fmt.Errorf("failed to upload empty file: %w", err) @@ -173,7 +175,7 @@ func (s *S3Storage) SaveFile( objectKey, uploadID, parts, - minio.PutObjectOptions{}, + s.putObjectOptions(), ) if err != nil { _ = coreClient.AbortMultipartUpload(ctx, s.S3Bucket, objectKey, uploadID) @@ -350,6 +352,7 @@ func (s *S3Storage) Update(incoming *S3Storage) { s.S3Endpoint = incoming.S3Endpoint s.S3UseVirtualHostedStyle = incoming.S3UseVirtualHostedStyle s.SkipTLSVerify = incoming.SkipTLSVerify + s.S3StorageClass = incoming.S3StorageClass if incoming.S3AccessKey != "" { s.S3AccessKey = incoming.S3AccessKey @@ -363,6 +366,12 @@ func (s *S3Storage) Update(incoming *S3Storage) { // otherwise we will have to transfer all the data to the new prefix } +func (s *S3Storage) putObjectOptions() minio.PutObjectOptions { + return minio.PutObjectOptions{ + StorageClass: string(s.S3StorageClass), + } +} + func (s *S3Storage) buildObjectKey(fileName string) string { if s.S3Prefix == "" { return fileName diff --git a/backend/migrations/20260402055223_add_storage_class_to_s3.sql b/backend/migrations/20260402055223_add_storage_class_to_s3.sql new file mode 100644 index 0000000..845a472 --- /dev/null +++ b/backend/migrations/20260402055223_add_storage_class_to_s3.sql @@ -0,0 +1,11 @@ +-- +goose Up +-- +goose StatementBegin +ALTER TABLE s3_storages + ADD COLUMN s3_storage_class TEXT NOT NULL DEFAULT ''; +-- +goose StatementEnd + +-- +goose Down +-- +goose StatementBegin +ALTER TABLE s3_storages + DROP COLUMN s3_storage_class; +-- +goose StatementEnd diff --git a/frontend/src/entity/storages/index.ts b/frontend/src/entity/storages/index.ts index 2132463..70415b5 100644 --- a/frontend/src/entity/storages/index.ts +++ b/frontend/src/entity/storages/index.ts @@ -3,6 +3,7 @@ export { type Storage } from './models/Storage'; export { StorageType } from './models/StorageType'; export { type LocalStorage } from './models/LocalStorage'; export { type S3Storage } from './models/S3Storage'; +export { S3StorageClass, S3StorageClassLabels } from './models/S3StorageClass'; export { type NASStorage } from './models/NASStorage'; export { getStorageLogoFromType } from './models/getStorageLogoFromType'; export { getStorageNameFromType } from './models/getStorageNameFromType'; diff --git a/frontend/src/entity/storages/models/S3Storage.ts b/frontend/src/entity/storages/models/S3Storage.ts index 88c0905..2ee663f 100644 --- a/frontend/src/entity/storages/models/S3Storage.ts +++ b/frontend/src/entity/storages/models/S3Storage.ts @@ -7,4 +7,5 @@ export interface S3Storage { s3Prefix?: string; s3UseVirtualHostedStyle?: boolean; skipTLSVerify?: boolean; + s3StorageClass?: string; } diff --git a/frontend/src/entity/storages/models/S3StorageClass.ts b/frontend/src/entity/storages/models/S3StorageClass.ts new file mode 100644 index 0000000..7816ede --- /dev/null +++ b/frontend/src/entity/storages/models/S3StorageClass.ts @@ -0,0 +1,19 @@ +export enum S3StorageClass { + DEFAULT = '', + STANDARD = 'STANDARD', + STANDARD_IA = 'STANDARD_IA', + ONEZONE_IA = 'ONEZONE_IA', + INTELLIGENT_TIERING = 'INTELLIGENT_TIERING', + REDUCED_REDUNDANCY = 'REDUCED_REDUNDANCY', + GLACIER_IR = 'GLACIER_IR', +} + +export const S3StorageClassLabels: Record = { + [S3StorageClass.DEFAULT]: 'Default (Standard)', + [S3StorageClass.STANDARD]: 'Standard', + [S3StorageClass.STANDARD_IA]: 'Standard - Infrequent Access', + [S3StorageClass.ONEZONE_IA]: 'One Zone - Infrequent Access', + [S3StorageClass.INTELLIGENT_TIERING]: 'Intelligent Tiering', + [S3StorageClass.REDUCED_REDUNDANCY]: 'Reduced Redundancy', + [S3StorageClass.GLACIER_IR]: 'Glacier Instant Retrieval', +}; diff --git a/frontend/src/features/storages/ui/edit/storages/EditS3StorageComponent.tsx b/frontend/src/features/storages/ui/edit/storages/EditS3StorageComponent.tsx index 147aaf0..dc93214 100644 --- a/frontend/src/features/storages/ui/edit/storages/EditS3StorageComponent.tsx +++ b/frontend/src/features/storages/ui/edit/storages/EditS3StorageComponent.tsx @@ -1,8 +1,8 @@ import { DownOutlined, InfoCircleOutlined, UpOutlined } from '@ant-design/icons'; -import { Checkbox, Input, Tooltip } from 'antd'; +import { Checkbox, Input, Select, Tooltip } from 'antd'; import { useEffect, useState } from 'react'; -import type { Storage } from '../../../../../entity/storages'; +import { S3StorageClass, S3StorageClassLabels, type Storage } from '../../../../../entity/storages'; interface Props { storage: Storage; @@ -20,7 +20,8 @@ export function EditS3StorageComponent({ const hasAdvancedValues = !!storage?.s3Storage?.s3Prefix || !!storage?.s3Storage?.s3UseVirtualHostedStyle || - !!storage?.s3Storage?.skipTLSVerify; + !!storage?.s3Storage?.skipTLSVerify || + !!storage?.s3Storage?.s3StorageClass; const [showAdvanced, setShowAdvanced] = useState(hasAdvancedValues); useEffect(() => { @@ -278,6 +279,40 @@ export function EditS3StorageComponent({ + +
+
Storage class
+
+