diff --git a/apps/aurora-portal/.env.example b/apps/aurora-portal/.env.example index 5aec9501a..55d4cab2d 100644 --- a/apps/aurora-portal/.env.example +++ b/apps/aurora-portal/.env.example @@ -2,7 +2,11 @@ IDENTITY_ENDPOINT="http://localhost:8080/identity/v3/" DEFAULT_ENDPOINT_INTERFACE="public" PORT="4001" CEPH_S3_ENDPOINT="https://rgw.example.com" -CEPH_REGION="default" +# Ceph region identifier for AWS Signature V4 signing and LocationConstraint +# SAP Converged Cloud examples: +# - Standard regions: ceph-objectstore-st1 + {region} +# For other Ceph deployments, use your custom region identifier +CEPH_REGION="ceph-objectstore-ec-st1-qa-de-1" IMAGE_METADATA_EXCLUDED_PROPERTIES="name,tags,visibility,protected,min_disk,min_ram,id,status,size,checksum,created_at,updated_at,created-at,updated-at,disk_format,container_format,file,schema,locations,self,direct_url,owner,virtual_size,kernel_id,ramdisk_id,os_hash_algo,os_hash_value,os-hash-algo,os-hash-value,stores,owner_specified.openstack.md5,owner_specified.openstack.sha256,owner_specified.openstack.object" INSECURE_COOKIES=true diff --git a/apps/aurora-portal/docs/009_ceph_s3_bff.md b/apps/aurora-portal/docs/009_ceph_s3_bff.md index bb94ffe09..4bc574342 100644 --- a/apps/aurora-portal/docs/009_ceph_s3_bff.md +++ b/apps/aurora-portal/docs/009_ceph_s3_bff.md @@ -85,13 +85,32 @@ The `createS3Client` factory: 1. Validates that `access` and `secret` are non-empty 2. Resolves the S3 endpoint: - - **Primary:** Extract from Ceph service catalog (removes `/swift/v1/...` suffix) - - **Fallback:** `CEPH_S3_ENDPOINT` environment variable -3. Resolves the region: `CEPH_REGION` env var or `"default"` + - Extract from Ceph service catalog (removes `/swift/v1/...` suffix) +3. Resolves the region from OpenStack service catalog: + - Extracts the OpenStack region from Ceph service endpoint (e.g., `qa-de-1`, `eu-de-2`) + - Constructs Ceph-compatible region identifier: + - Standard format: `ceph-objectstore-st1-{region}` (e.g., `ceph-objectstore-st1-eu-de-2`) + - Special case: `qa-de-1` uses `ceph-objectstore-ec-st1-qa-de-1` (with "ec" prefix for historical reasons) + - This identifier is used for: + - AWS Signature V4 request signing (region field in Authorization header) + - LocationConstraint in CreateBucket API calls 4. Returns a configured AWS SDK v3 `S3Client` with: - `forcePathStyle: true` (required for Ceph RGW — it does not support virtual-hosted-style URLs) - Static credentials (access key ID + secret access key) +**Region Configuration:** + +Region identifiers are automatically constructed from the OpenStack service catalog: + +- Extracts region from Ceph service endpoint (e.g., `qa-de-1`, `eu-de-2`, `staging`) +- Constructs Ceph-compatible identifier using the pattern from Go SDK / Terraform: + - Standard: `ceph-objectstore-st1-{region}` (e.g., `ceph-objectstore-st1-eu-de-2`) + - Exception: `qa-de-1` → `ceph-objectstore-ec-st1-qa-de-1` (uses "ec" prefix for historical reasons) +- Used for AWS Signature V4 request signing and LocationConstraint in CreateBucket +- No environment variable override needed — region is auto-detected + +See: https://documentation.global.cloud.sap/docs/customer/storage/obj-v2-ceph/ceph-storage-options/ + ### 3. Middleware Layers #### `cephCredentialMiddleware` @@ -571,11 +590,10 @@ try { ### Environment Variables -#### Optional +None required. All configuration is resolved from the OpenStack service catalog: -- **`CEPH_REGION`** - - S3 region name (default: `"default"`) - - Used for AWS SDK signature calculation +- S3 endpoint: extracted from Ceph service endpoints +- Region: auto-constructed from OpenStack region identifier ### Service Catalog Endpoint Resolution @@ -586,9 +604,13 @@ The BFF resolves the S3 endpoint from the OpenStack service catalog: 3. If the URL contains `/swift/`, remove that suffix: - Swift: `https://rgw.example.com/swift/v1/AUTH_xxx` - S3: `https://rgw.example.com` (base URL) -4. Return the base URL +4. Extract the region from the Ceph service endpoint (e.g., `qa-de-1`) +5. Construct the Ceph-compatible region identifier: + - Standard: `ceph-objectstore-st1-{region}` + - Exception: `qa-de-1` → `ceph-objectstore-ec-st1-qa-de-1` +6. Return the base URL and region identifier -**Important:** The Ceph service **must** be registered in the OpenStack service catalog. Environment variable fallbacks (e.g., `CEPH_S3_ENDPOINT`) are **not supported** — all configuration comes from the service catalog to ensure consistency across deployments. +**Important:** The Ceph service **must** be registered in the OpenStack service catalog. All configuration comes from the service catalog to ensure consistency across deployments. --- @@ -799,8 +821,8 @@ Both can coexist — Ceph RGW supports **both Swift and S3 APIs** on the same cl ### Not Yet Implemented 1. **Bucket Management** - - Create bucket (`CreateBucketCommand`) - - Delete bucket (`DeleteBucketCommand`) + - ~~Create bucket (`CreateBucketCommand`)~~ ✅ Implemented + - ~~Delete bucket (`DeleteBucketCommand`)~~ ✅ Implemented - Configure bucket policies, CORS, lifecycle rules 2. **Object Upload/Download** diff --git a/apps/aurora-portal/src/client/routeTree.gen.ts b/apps/aurora-portal/src/client/routeTree.gen.ts index 3fa899306..1e581973c 100644 --- a/apps/aurora-portal/src/client/routeTree.gen.ts +++ b/apps/aurora-portal/src/client/routeTree.gen.ts @@ -26,7 +26,6 @@ import { Route as AuthProjectsProjectIdNetworkOverviewRouteImport } from "./rout import { Route as AuthProjectsProjectIdComputeOverviewRouteImport } from "./routes/_auth/projects/$projectId/compute/overview" import { Route as AuthProjectsProjectIdComputeImagesRouteImport } from "./routes/_auth/projects/$projectId/compute/images" import { Route as AuthProjectsProjectIdComputeFlavorsRouteImport } from "./routes/_auth/projects/$projectId/compute/flavors" -import { Route as AuthProjectsProjectIdStorageCephIndexRouteImport } from "./routes/_auth/projects/$projectId/storage/ceph/index" import { Route as AuthProjectsProjectIdServicesPcaIndexRouteImport } from "./routes/_auth/projects/$projectId/services/pca/index" import { Route as AuthProjectsProjectIdNetworkSecuritygroupsIndexRouteImport } from "./routes/_auth/projects/$projectId/network/securitygroups/index" import { Route as AuthProjectsProjectIdNetworkFloatingipsIndexRouteImport } from "./routes/_auth/projects/$projectId/network/floatingips/index" @@ -34,13 +33,11 @@ import { Route as AuthProjectsProjectIdComputeImagesIndexRouteImport } from "./r import { Route as AuthProjectsProjectIdComputeFlavorsIndexRouteImport } from "./routes/_auth/projects/$projectId/compute/flavors/index" import { Route as AuthProjectsProjectIdComputeImagesImageIdRouteImport } from "./routes/_auth/projects/$projectId/compute/images/$imageId" import { Route as AuthProjectsProjectIdComputeFlavorsFlavorIdRouteImport } from "./routes/_auth/projects/$projectId/compute/flavors/$flavorId" -import { Route as AuthProjectsProjectIdStorageCephContainersIndexRouteImport } from "./routes/_auth/projects/$projectId/storage/ceph/containers/index" import { Route as AuthProjectsProjectIdStorageProviderContainersIndexRouteImport } from "./routes/_auth/projects/$projectId/storage/$provider/containers/index" import { Route as AuthProjectsProjectIdServicesPcaPcaIdIndexRouteImport } from "./routes/_auth/projects/$projectId/services/pca/$pcaId/index" import { Route as AuthProjectsProjectIdNetworkSecuritygroupsSecurityGroupIdIndexRouteImport } from "./routes/_auth/projects/$projectId/network/securitygroups/$securityGroupId/index" import { Route as AuthProjectsProjectIdNetworkFloatingipsFloatingIpIdIndexRouteImport } from "./routes/_auth/projects/$projectId/network/floatingips/$floatingIpId/index" import { Route as AuthProjectsProjectIdServicesPcaPcaIdCertificateIdRouteImport } from "./routes/_auth/projects/$projectId/services/pca/$pcaId/$certificateId" -import { Route as AuthProjectsProjectIdStorageCephContainersContainerNameObjectsIndexRouteImport } from "./routes/_auth/projects/$projectId/storage/ceph/containers/$containerName/objects/index" import { Route as AuthProjectsProjectIdStorageProviderContainersContainerNameObjectsIndexRouteImport } from "./routes/_auth/projects/$projectId/storage/$provider/containers/$containerName/objects/index" const AboutRoute = AboutRouteImport.update({ @@ -137,12 +134,6 @@ const AuthProjectsProjectIdComputeFlavorsRoute = path: "/compute/flavors", getParentRoute: () => AuthProjectsProjectIdRoute, } as any) -const AuthProjectsProjectIdStorageCephIndexRoute = - AuthProjectsProjectIdStorageCephIndexRouteImport.update({ - id: "/storage/ceph/", - path: "/storage/ceph/", - getParentRoute: () => AuthProjectsProjectIdRoute, - } as any) const AuthProjectsProjectIdServicesPcaIndexRoute = AuthProjectsProjectIdServicesPcaIndexRouteImport.update({ id: "/services/pca/", @@ -185,12 +176,6 @@ const AuthProjectsProjectIdComputeFlavorsFlavorIdRoute = path: "/$flavorId", getParentRoute: () => AuthProjectsProjectIdComputeFlavorsRoute, } as any) -const AuthProjectsProjectIdStorageCephContainersIndexRoute = - AuthProjectsProjectIdStorageCephContainersIndexRouteImport.update({ - id: "/storage/ceph/containers/", - path: "/storage/ceph/containers/", - getParentRoute: () => AuthProjectsProjectIdRoute, - } as any) const AuthProjectsProjectIdStorageProviderContainersIndexRoute = AuthProjectsProjectIdStorageProviderContainersIndexRouteImport.update({ id: "/storage/$provider/containers/", @@ -223,14 +208,6 @@ const AuthProjectsProjectIdServicesPcaPcaIdCertificateIdRoute = path: "/services/pca/$pcaId/$certificateId", getParentRoute: () => AuthProjectsProjectIdRoute, } as any) -const AuthProjectsProjectIdStorageCephContainersContainerNameObjectsIndexRoute = - AuthProjectsProjectIdStorageCephContainersContainerNameObjectsIndexRouteImport.update( - { - id: "/storage/ceph/containers/$containerName/objects/", - path: "/storage/ceph/containers/$containerName/objects/", - getParentRoute: () => AuthProjectsProjectIdRoute, - } as any, - ) const AuthProjectsProjectIdStorageProviderContainersContainerNameObjectsIndexRoute = AuthProjectsProjectIdStorageProviderContainersContainerNameObjectsIndexRouteImport.update( { @@ -264,15 +241,12 @@ export interface FileRoutesByFullPath { "/projects/$projectId/network/floatingips/": typeof AuthProjectsProjectIdNetworkFloatingipsIndexRoute "/projects/$projectId/network/securitygroups/": typeof AuthProjectsProjectIdNetworkSecuritygroupsIndexRoute "/projects/$projectId/services/pca/": typeof AuthProjectsProjectIdServicesPcaIndexRoute - "/projects/$projectId/storage/ceph/": typeof AuthProjectsProjectIdStorageCephIndexRoute "/projects/$projectId/services/pca/$pcaId/$certificateId": typeof AuthProjectsProjectIdServicesPcaPcaIdCertificateIdRoute "/projects/$projectId/network/floatingips/$floatingIpId/": typeof AuthProjectsProjectIdNetworkFloatingipsFloatingIpIdIndexRoute "/projects/$projectId/network/securitygroups/$securityGroupId/": typeof AuthProjectsProjectIdNetworkSecuritygroupsSecurityGroupIdIndexRoute "/projects/$projectId/services/pca/$pcaId/": typeof AuthProjectsProjectIdServicesPcaPcaIdIndexRoute "/projects/$projectId/storage/$provider/containers/": typeof AuthProjectsProjectIdStorageProviderContainersIndexRoute - "/projects/$projectId/storage/ceph/containers/": typeof AuthProjectsProjectIdStorageCephContainersIndexRoute "/projects/$projectId/storage/$provider/containers/$containerName/objects/": typeof AuthProjectsProjectIdStorageProviderContainersContainerNameObjectsIndexRoute - "/projects/$projectId/storage/ceph/containers/$containerName/objects/": typeof AuthProjectsProjectIdStorageCephContainersContainerNameObjectsIndexRoute } export interface FileRoutesByTo { "/": typeof IndexRoute @@ -294,15 +268,12 @@ export interface FileRoutesByTo { "/projects/$projectId/network/floatingips": typeof AuthProjectsProjectIdNetworkFloatingipsIndexRoute "/projects/$projectId/network/securitygroups": typeof AuthProjectsProjectIdNetworkSecuritygroupsIndexRoute "/projects/$projectId/services/pca": typeof AuthProjectsProjectIdServicesPcaIndexRoute - "/projects/$projectId/storage/ceph": typeof AuthProjectsProjectIdStorageCephIndexRoute "/projects/$projectId/services/pca/$pcaId/$certificateId": typeof AuthProjectsProjectIdServicesPcaPcaIdCertificateIdRoute "/projects/$projectId/network/floatingips/$floatingIpId": typeof AuthProjectsProjectIdNetworkFloatingipsFloatingIpIdIndexRoute "/projects/$projectId/network/securitygroups/$securityGroupId": typeof AuthProjectsProjectIdNetworkSecuritygroupsSecurityGroupIdIndexRoute "/projects/$projectId/services/pca/$pcaId": typeof AuthProjectsProjectIdServicesPcaPcaIdIndexRoute "/projects/$projectId/storage/$provider/containers": typeof AuthProjectsProjectIdStorageProviderContainersIndexRoute - "/projects/$projectId/storage/ceph/containers": typeof AuthProjectsProjectIdStorageCephContainersIndexRoute "/projects/$projectId/storage/$provider/containers/$containerName/objects": typeof AuthProjectsProjectIdStorageProviderContainersContainerNameObjectsIndexRoute - "/projects/$projectId/storage/ceph/containers/$containerName/objects": typeof AuthProjectsProjectIdStorageCephContainersContainerNameObjectsIndexRoute } export interface FileRoutesById { __root__: typeof rootRouteImport @@ -330,15 +301,12 @@ export interface FileRoutesById { "/_auth/projects/$projectId/network/floatingips/": typeof AuthProjectsProjectIdNetworkFloatingipsIndexRoute "/_auth/projects/$projectId/network/securitygroups/": typeof AuthProjectsProjectIdNetworkSecuritygroupsIndexRoute "/_auth/projects/$projectId/services/pca/": typeof AuthProjectsProjectIdServicesPcaIndexRoute - "/_auth/projects/$projectId/storage/ceph/": typeof AuthProjectsProjectIdStorageCephIndexRoute "/_auth/projects/$projectId/services/pca/$pcaId/$certificateId": typeof AuthProjectsProjectIdServicesPcaPcaIdCertificateIdRoute "/_auth/projects/$projectId/network/floatingips/$floatingIpId/": typeof AuthProjectsProjectIdNetworkFloatingipsFloatingIpIdIndexRoute "/_auth/projects/$projectId/network/securitygroups/$securityGroupId/": typeof AuthProjectsProjectIdNetworkSecuritygroupsSecurityGroupIdIndexRoute "/_auth/projects/$projectId/services/pca/$pcaId/": typeof AuthProjectsProjectIdServicesPcaPcaIdIndexRoute "/_auth/projects/$projectId/storage/$provider/containers/": typeof AuthProjectsProjectIdStorageProviderContainersIndexRoute - "/_auth/projects/$projectId/storage/ceph/containers/": typeof AuthProjectsProjectIdStorageCephContainersIndexRoute "/_auth/projects/$projectId/storage/$provider/containers/$containerName/objects/": typeof AuthProjectsProjectIdStorageProviderContainersContainerNameObjectsIndexRoute - "/_auth/projects/$projectId/storage/ceph/containers/$containerName/objects/": typeof AuthProjectsProjectIdStorageCephContainersContainerNameObjectsIndexRoute } export interface FileRouteTypes { fileRoutesByFullPath: FileRoutesByFullPath @@ -366,15 +334,12 @@ export interface FileRouteTypes { | "/projects/$projectId/network/floatingips/" | "/projects/$projectId/network/securitygroups/" | "/projects/$projectId/services/pca/" - | "/projects/$projectId/storage/ceph/" | "/projects/$projectId/services/pca/$pcaId/$certificateId" | "/projects/$projectId/network/floatingips/$floatingIpId/" | "/projects/$projectId/network/securitygroups/$securityGroupId/" | "/projects/$projectId/services/pca/$pcaId/" | "/projects/$projectId/storage/$provider/containers/" - | "/projects/$projectId/storage/ceph/containers/" | "/projects/$projectId/storage/$provider/containers/$containerName/objects/" - | "/projects/$projectId/storage/ceph/containers/$containerName/objects/" fileRoutesByTo: FileRoutesByTo to: | "/" @@ -396,15 +361,12 @@ export interface FileRouteTypes { | "/projects/$projectId/network/floatingips" | "/projects/$projectId/network/securitygroups" | "/projects/$projectId/services/pca" - | "/projects/$projectId/storage/ceph" | "/projects/$projectId/services/pca/$pcaId/$certificateId" | "/projects/$projectId/network/floatingips/$floatingIpId" | "/projects/$projectId/network/securitygroups/$securityGroupId" | "/projects/$projectId/services/pca/$pcaId" | "/projects/$projectId/storage/$provider/containers" - | "/projects/$projectId/storage/ceph/containers" | "/projects/$projectId/storage/$provider/containers/$containerName/objects" - | "/projects/$projectId/storage/ceph/containers/$containerName/objects" id: | "__root__" | "/" @@ -431,15 +393,12 @@ export interface FileRouteTypes { | "/_auth/projects/$projectId/network/floatingips/" | "/_auth/projects/$projectId/network/securitygroups/" | "/_auth/projects/$projectId/services/pca/" - | "/_auth/projects/$projectId/storage/ceph/" | "/_auth/projects/$projectId/services/pca/$pcaId/$certificateId" | "/_auth/projects/$projectId/network/floatingips/$floatingIpId/" | "/_auth/projects/$projectId/network/securitygroups/$securityGroupId/" | "/_auth/projects/$projectId/services/pca/$pcaId/" | "/_auth/projects/$projectId/storage/$provider/containers/" - | "/_auth/projects/$projectId/storage/ceph/containers/" | "/_auth/projects/$projectId/storage/$provider/containers/$containerName/objects/" - | "/_auth/projects/$projectId/storage/ceph/containers/$containerName/objects/" fileRoutesById: FileRoutesById } export interface RootRouteChildren { @@ -569,13 +528,6 @@ declare module "@tanstack/react-router" { preLoaderRoute: typeof AuthProjectsProjectIdComputeFlavorsRouteImport parentRoute: typeof AuthProjectsProjectIdRoute } - "/_auth/projects/$projectId/storage/ceph/": { - id: "/_auth/projects/$projectId/storage/ceph/" - path: "/storage/ceph" - fullPath: "/projects/$projectId/storage/ceph/" - preLoaderRoute: typeof AuthProjectsProjectIdStorageCephIndexRouteImport - parentRoute: typeof AuthProjectsProjectIdRoute - } "/_auth/projects/$projectId/services/pca/": { id: "/_auth/projects/$projectId/services/pca/" path: "/services/pca" @@ -625,13 +577,6 @@ declare module "@tanstack/react-router" { preLoaderRoute: typeof AuthProjectsProjectIdComputeFlavorsFlavorIdRouteImport parentRoute: typeof AuthProjectsProjectIdComputeFlavorsRoute } - "/_auth/projects/$projectId/storage/ceph/containers/": { - id: "/_auth/projects/$projectId/storage/ceph/containers/" - path: "/storage/ceph/containers" - fullPath: "/projects/$projectId/storage/ceph/containers/" - preLoaderRoute: typeof AuthProjectsProjectIdStorageCephContainersIndexRouteImport - parentRoute: typeof AuthProjectsProjectIdRoute - } "/_auth/projects/$projectId/storage/$provider/containers/": { id: "/_auth/projects/$projectId/storage/$provider/containers/" path: "/storage/$provider/containers" @@ -667,13 +612,6 @@ declare module "@tanstack/react-router" { preLoaderRoute: typeof AuthProjectsProjectIdServicesPcaPcaIdCertificateIdRouteImport parentRoute: typeof AuthProjectsProjectIdRoute } - "/_auth/projects/$projectId/storage/ceph/containers/$containerName/objects/": { - id: "/_auth/projects/$projectId/storage/ceph/containers/$containerName/objects/" - path: "/storage/ceph/containers/$containerName/objects" - fullPath: "/projects/$projectId/storage/ceph/containers/$containerName/objects/" - preLoaderRoute: typeof AuthProjectsProjectIdStorageCephContainersContainerNameObjectsIndexRouteImport - parentRoute: typeof AuthProjectsProjectIdRoute - } "/_auth/projects/$projectId/storage/$provider/containers/$containerName/objects/": { id: "/_auth/projects/$projectId/storage/$provider/containers/$containerName/objects/" path: "/storage/$provider/containers/$containerName/objects" @@ -760,13 +698,10 @@ interface AuthProjectsProjectIdRouteChildren { AuthProjectsProjectIdComputeIndexRoute: typeof AuthProjectsProjectIdComputeIndexRoute AuthProjectsProjectIdServicesIndexRoute: typeof AuthProjectsProjectIdServicesIndexRoute AuthProjectsProjectIdServicesPcaIndexRoute: typeof AuthProjectsProjectIdServicesPcaIndexRoute - AuthProjectsProjectIdStorageCephIndexRoute: typeof AuthProjectsProjectIdStorageCephIndexRoute AuthProjectsProjectIdServicesPcaPcaIdCertificateIdRoute: typeof AuthProjectsProjectIdServicesPcaPcaIdCertificateIdRoute AuthProjectsProjectIdServicesPcaPcaIdIndexRoute: typeof AuthProjectsProjectIdServicesPcaPcaIdIndexRoute AuthProjectsProjectIdStorageProviderContainersIndexRoute: typeof AuthProjectsProjectIdStorageProviderContainersIndexRoute - AuthProjectsProjectIdStorageCephContainersIndexRoute: typeof AuthProjectsProjectIdStorageCephContainersIndexRoute AuthProjectsProjectIdStorageProviderContainersContainerNameObjectsIndexRoute: typeof AuthProjectsProjectIdStorageProviderContainersContainerNameObjectsIndexRoute - AuthProjectsProjectIdStorageCephContainersContainerNameObjectsIndexRoute: typeof AuthProjectsProjectIdStorageCephContainersContainerNameObjectsIndexRoute } const AuthProjectsProjectIdRouteChildren: AuthProjectsProjectIdRouteChildren = { @@ -787,20 +722,14 @@ const AuthProjectsProjectIdRouteChildren: AuthProjectsProjectIdRouteChildren = { AuthProjectsProjectIdServicesIndexRoute, AuthProjectsProjectIdServicesPcaIndexRoute: AuthProjectsProjectIdServicesPcaIndexRoute, - AuthProjectsProjectIdStorageCephIndexRoute: - AuthProjectsProjectIdStorageCephIndexRoute, AuthProjectsProjectIdServicesPcaPcaIdCertificateIdRoute: AuthProjectsProjectIdServicesPcaPcaIdCertificateIdRoute, AuthProjectsProjectIdServicesPcaPcaIdIndexRoute: AuthProjectsProjectIdServicesPcaPcaIdIndexRoute, AuthProjectsProjectIdStorageProviderContainersIndexRoute: AuthProjectsProjectIdStorageProviderContainersIndexRoute, - AuthProjectsProjectIdStorageCephContainersIndexRoute: - AuthProjectsProjectIdStorageCephContainersIndexRoute, AuthProjectsProjectIdStorageProviderContainersContainerNameObjectsIndexRoute: AuthProjectsProjectIdStorageProviderContainersContainerNameObjectsIndexRoute, - AuthProjectsProjectIdStorageCephContainersContainerNameObjectsIndexRoute: - AuthProjectsProjectIdStorageCephContainersContainerNameObjectsIndexRoute, } const AuthProjectsProjectIdRouteWithChildren = diff --git a/apps/aurora-portal/src/client/routes/_auth/projects/$projectId/storage/-components/Ceph/Containers/ContainerListView.test.tsx b/apps/aurora-portal/src/client/routes/_auth/projects/$projectId/storage/-components/Ceph/Containers/ContainerListView.test.tsx new file mode 100644 index 000000000..0cf5bf02b --- /dev/null +++ b/apps/aurora-portal/src/client/routes/_auth/projects/$projectId/storage/-components/Ceph/Containers/ContainerListView.test.tsx @@ -0,0 +1,350 @@ +import { describe, test, expect, vi, beforeEach } from "vitest" +import { render, screen, act, waitFor } from "@testing-library/react" +import userEvent from "@testing-library/user-event" +import { PortalProvider } from "@cloudoperators/juno-ui-components" +import { i18n } from "@lingui/core" +import { I18nProvider } from "@lingui/react" +import { ContainerListView } from "./ContainerListView" +import type { Container } from "@/server/Storage/types/ceph" + +// ─── Mock useProjectId ──────────────────────────────────────────────────────── + +const mockProjectId = "test-project-123" + +vi.mock("@/client/hooks/useProjectId", () => ({ + useProjectId: () => mockProjectId, +})) + +// ─── Mock router ────────────────────────────────────────────────────────────── + +vi.mock("@tanstack/react-router", () => ({ + useParams: () => ({ provider: "ceph" }), + Link: ({ children, ...props }: { children: React.ReactNode }) => {children}, +})) + +// ─── Mock modals ────────────────────────────────────────────────────────────── + +vi.mock("./CreateBucketModal", () => ({ + CreateBucketModal: vi.fn(({ isOpen, onClose }) => + isOpen ? ( +
+ +
+ ) : null + ), +})) + +vi.mock("./DeleteBucketModal", () => ({ + DeleteBucketModal: vi.fn(({ isOpen, onClose }) => + isOpen ? ( +
+ +
+ ) : null + ), +})) + +// ─── Mock CredentialPrompt ──────────────────────────────────────────────────── + +vi.mock("./CredentialPrompt", () => ({ + CredentialPrompt: vi.fn(({ onSuccess }) => ( +
+

No EC2 credentials configured

+ +
+ )), +})) + +// ─── Mock toast notifications ───────────────────────────────────────────────── + +vi.mock("./ContainerToastNotifications", () => ({ + getBucketCreatedToast: vi.fn((name) => ({ + variant: "success", + text: `Bucket ${name} created`, + })), + getBucketCreateErrorToast: vi.fn((name, error) => ({ + variant: "error", + text: `Failed to create bucket ${name}: ${error}`, + })), + getBucketDeletedToast: vi.fn((name) => ({ + variant: "success", + text: `Bucket ${name} deleted`, + })), + getBucketDeleteErrorToast: vi.fn((name, error) => ({ + variant: "error", + text: `Failed to delete bucket ${name}: ${error}`, + })), +})) + +// ─── tRPC mock ──────────────────────────────────────────────────────────────── + +const { mockRefetch, mockState } = vi.hoisted(() => { + const mockState = { + data: null as Container[] | null, + isLoading: false, + error: null as { message: string } | null, + } + const mockRefetch = vi.fn() + return { mockRefetch, mockState } +}) + +vi.mock("@/client/trpcClient", () => ({ + trpcReact: { + storage: { + ceph: { + containers: { + list: { + useQuery: () => ({ + data: mockState.data, + isLoading: mockState.isLoading, + error: mockState.error, + refetch: mockRefetch, + }), + }, + }, + }, + }, + }, +})) + +// ─── Fixtures ───────────────────────────────────────────────────────────────── + +const mockContainers: Container[] = [ + { + name: "bucket-1", + creationDate: "2024-01-15T10:00:00Z", + count: 5, + bytes: 1024, + }, + { + name: "bucket-2", + creationDate: "2024-01-16T10:00:00Z", + count: 3, + bytes: 512, + }, + { + name: "bucket-3", + creationDate: "2024-01-17T10:00:00Z", + count: 0, + bytes: 0, + }, +] + +// ─── Render helper ──────────────────────────────────────────────────────────── + +const renderListView = () => + render( + + + + + + ) + +// ─── Tests ──────────────────────────────────────────────────────────────────── + +describe("ContainerListView", () => { + beforeEach(async () => { + vi.clearAllMocks() + mockState.data = null + mockState.isLoading = false + mockState.error = null + await act(async () => { + i18n.activate("en") + }) + }) + + describe("Loading state", () => { + test("shows loading spinner while fetching containers", () => { + mockState.isLoading = true + renderListView() + expect(screen.getByText("Loading containers...")).toBeInTheDocument() + }) + + test("renders Spinner component during loading", () => { + mockState.isLoading = true + renderListView() + // Spinner is rendered (checking for the container with Stack) + expect(screen.getByText("Loading containers...").closest(".juno-stack")).toBeInTheDocument() + }) + }) + + describe("Error handling", () => { + test("shows CredentialPrompt when NO_CEPH_CREDENTIALS error occurs", () => { + mockState.error = { message: "NO_CEPH_CREDENTIALS" } + renderListView() + expect(screen.getByTestId("credential-prompt")).toBeInTheDocument() + expect(screen.getByText("No EC2 credentials configured")).toBeInTheDocument() + }) + + test("calls refetch when credential setup succeeds", async () => { + const user = userEvent.setup({ delay: null }) + mockState.error = { message: "NO_CEPH_CREDENTIALS" } + renderListView() + + const setupButton = screen.getByText("Setup Credentials") + await user.click(setupButton) + + expect(mockRefetch).toHaveBeenCalledTimes(1) + }) + + test("shows error message for other errors", () => { + mockState.error = { message: "Network error" } + renderListView() + expect(screen.getByText(/Failed to load containers: Network error/)).toBeInTheDocument() + }) + + test("does not show CredentialPrompt for non-credential errors", () => { + mockState.error = { message: "Server error" } + renderListView() + expect(screen.queryByTestId("credential-prompt")).not.toBeInTheDocument() + }) + }) + + describe("Empty state", () => { + test("shows empty message when no containers exist", () => { + mockState.data = [] + renderListView() + expect(screen.getByText("No containers found.")).toBeInTheDocument() + }) + + test("renders table headers even when empty", () => { + mockState.data = [] + renderListView() + expect(screen.getByText("Name")).toBeInTheDocument() + expect(screen.getByText("Creation Date")).toBeInTheDocument() + expect(screen.getByText("Actions")).toBeInTheDocument() + }) + + test("shows Create Bucket button when empty", () => { + mockState.data = [] + renderListView() + expect(screen.getByRole("button", { name: /Create Bucket/i })).toBeInTheDocument() + }) + }) + + describe("Container list rendering", () => { + test("renders all containers", () => { + mockState.data = mockContainers + renderListView() + expect(screen.getByText("bucket-1")).toBeInTheDocument() + expect(screen.getByText("bucket-2")).toBeInTheDocument() + expect(screen.getByText("bucket-3")).toBeInTheDocument() + }) + + test("renders table headers", () => { + mockState.data = mockContainers + renderListView() + expect(screen.getByText("Name")).toBeInTheDocument() + expect(screen.getByText("Creation Date")).toBeInTheDocument() + expect(screen.getByText("Actions")).toBeInTheDocument() + }) + + test("displays creation dates", () => { + mockState.data = mockContainers + renderListView() + // Dates are formatted to locale string, just check they exist + const dates = screen.getAllByText(/2024/) + expect(dates.length).toBeGreaterThan(0) + }) + + test("shows Unknown for missing creation date", () => { + const bucketWithoutDate: Container = { + name: "bucket-no-date", + creationDate: "", + count: 0, + bytes: 0, + } + mockState.data = [bucketWithoutDate] + renderListView() + expect(screen.getByText("Unknown")).toBeInTheDocument() + }) + + test("renders delete button for each container", () => { + mockState.data = mockContainers + renderListView() + const deleteButtons = screen.getAllByTitle("Delete bucket") + // Should have at least 3 delete buttons (one per container) + expect(deleteButtons.length).toBeGreaterThanOrEqual(3) + }) + + test("container names are links", () => { + mockState.data = mockContainers + renderListView() + const link = screen.getByText("bucket-1").closest("a") + expect(link).toBeInTheDocument() + }) + }) + + describe("Create Bucket action", () => { + test("renders Create Bucket button", () => { + mockState.data = mockContainers + renderListView() + expect(screen.getByRole("button", { name: /Create Bucket/i })).toBeInTheDocument() + }) + + test("opens CreateBucketModal when Create Bucket clicked", async () => { + const user = userEvent.setup({ delay: null }) + mockState.data = mockContainers + renderListView() + + const createButton = screen.getByRole("button", { name: /Create Bucket/i }) + await user.click(createButton) + + expect(screen.getByTestId("create-bucket-modal")).toBeInTheDocument() + }) + + test("closes CreateBucketModal when Cancel clicked", async () => { + const user = userEvent.setup({ delay: null }) + mockState.data = mockContainers + renderListView() + + const createButton = screen.getByRole("button", { name: /Create Bucket/i }) + await user.click(createButton) + + const cancelButton = screen.getByText("Cancel") + await user.click(cancelButton) + + await waitFor(() => { + expect(screen.queryByTestId("create-bucket-modal")).not.toBeInTheDocument() + }) + }) + }) + + describe("Delete Bucket action", () => { + test("opens DeleteBucketModal when delete button clicked", async () => { + const user = userEvent.setup({ delay: null }) + mockState.data = mockContainers + renderListView() + + const deleteButtons = screen.getAllByTitle("Delete bucket") + await user.click(deleteButtons[0]) + + expect(screen.getByTestId("delete-bucket-modal")).toBeInTheDocument() + }) + + test("closes DeleteBucketModal when Cancel clicked", async () => { + const user = userEvent.setup({ delay: null }) + mockState.data = mockContainers + renderListView() + + const deleteButtons = screen.getAllByTitle("Delete bucket") + await user.click(deleteButtons[0]) + + const cancelButton = screen.getByText("Cancel") + await user.click(cancelButton) + + await waitFor(() => { + expect(screen.queryByTestId("delete-bucket-modal")).not.toBeInTheDocument() + }) + }) + }) + + describe("Toast notifications", () => { + test("does not show toast initially", () => { + mockState.data = mockContainers + renderListView() + expect(screen.queryByRole("dialog")).not.toBeInTheDocument() + }) + }) +}) diff --git a/apps/aurora-portal/src/client/routes/_auth/projects/$projectId/storage/-components/Ceph/Containers/ContainerListView.tsx b/apps/aurora-portal/src/client/routes/_auth/projects/$projectId/storage/-components/Ceph/Containers/ContainerListView.tsx index 79b07e5d7..a61cb0b9e 100644 --- a/apps/aurora-portal/src/client/routes/_auth/projects/$projectId/storage/-components/Ceph/Containers/ContainerListView.tsx +++ b/apps/aurora-portal/src/client/routes/_auth/projects/$projectId/storage/-components/Ceph/Containers/ContainerListView.tsx @@ -1,4 +1,5 @@ -import { Trans } from "@lingui/react/macro" +import { useState } from "react" +import { Trans, useLingui } from "@lingui/react/macro" import { Link, useParams } from "@tanstack/react-router" import { trpcReact } from "@/client/trpcClient" import { useProjectId } from "@/client/hooks/useProjectId" @@ -9,13 +10,30 @@ import { DataGridHeadCell, Spinner, Stack, + Button, + Toast, + ToastProps, } from "@cloudoperators/juno-ui-components" import type { Container } from "@/server/Storage/types/ceph" import { CredentialPrompt } from "./CredentialPrompt" +import { CreateBucketModal } from "./CreateBucketModal" +import { DeleteBucketModal } from "./DeleteBucketModal" +import { + getBucketCreatedToast, + getBucketCreateErrorToast, + getBucketDeletedToast, + getBucketDeleteErrorToast, +} from "./ContainerToastNotifications" export function ContainerListView() { + const { t } = useLingui() const projectId = useProjectId() const { provider } = useParams({ strict: false }) // Get provider from URL + const [isCreateModalOpen, setIsCreateModalOpen] = useState(false) + const [deleteModalBucket, setDeleteModalBucket] = useState(null) + const [toastData, setToastData] = useState(null) + + const handleToastDismiss = () => setToastData(null) const { data: buckets, @@ -23,7 +41,10 @@ export function ContainerListView() { error, refetch, } = trpcReact.storage.ceph.containers.list.useQuery( - { project_id: projectId ?? "" }, + { + project_id: projectId ?? "", + includeMetadata: false, // Basic list view doesn't need metadata, load faster + }, { enabled: !!projectId, retry: false, // Don't retry on NO_CEPH_CREDENTIALS error @@ -56,9 +77,47 @@ export function ContainerListView() { return ( <> + {/* Toast Notification */} + {toastData && } + + {/* Modals */} + setIsCreateModalOpen(false)} + onSuccess={(name) => { + setToastData(getBucketCreatedToast(name, { onDismiss: handleToastDismiss })) + }} + onError={(name, error) => { + setToastData(getBucketCreateErrorToast(name, error, { onDismiss: handleToastDismiss })) + }} + /> + + setDeleteModalBucket(null)} + onSuccess={(name) => { + setToastData(getBucketDeletedToast(name, { onDismiss: handleToastDismiss })) + }} + onError={(name, error) => { + setToastData(getBucketDeleteErrorToast(name, error, { onDismiss: handleToastDismiss })) + }} + /> + + {/* Action Buttons */} + + + + ) : null + ), +})) + +vi.mock("./EmptyBucketModal", () => ({ + EmptyBucketModal: vi.fn(({ isOpen, onClose }) => + isOpen ? ( +
+ +
+ ) : null + ), +})) + +vi.mock("./DeleteBucketModal", () => ({ + DeleteBucketModal: vi.fn(({ isOpen, onClose }) => + isOpen ? ( +
+ +
+ ) : null + ), +})) + +// ─── Fixtures ───────────────────────────────────────────────────────────────── + +const mockContainers: Container[] = [ + { + name: "bucket-1", + creationDate: "2024-01-15T10:00:00Z", + count: 5, + bytes: 1024, + last_modified: "2024-01-20T15:30:00Z", + }, + { + name: "bucket-2", + creationDate: "2024-01-16T10:00:00Z", + count: 3, + bytes: 512, + last_modified: "2024-01-21T10:00:00Z", + }, + { + name: "bucket-3", + creationDate: "2024-01-17T10:00:00Z", + count: 0, + bytes: 0, + }, +] + +// ─── Render helper ──────────────────────────────────────────────────────────── + +const renderTableView = ({ + containers = mockContainers, + createModalOpen = false, + setCreateModalOpen = vi.fn(), + onCreateSuccess = vi.fn(), + onCreateError = vi.fn(), + onEmptySuccess = vi.fn(), + onEmptyError = vi.fn(), + onDeleteSuccess = vi.fn(), + onDeleteError = vi.fn(), + selectedContainers = [], + setSelectedContainers = vi.fn(), +}: Partial<{ + containers: Container[] + createModalOpen: boolean + setCreateModalOpen: (open: boolean) => void + onCreateSuccess: (bucketName: string) => void + onCreateError: (bucketName: string, errorMessage: string) => void + onEmptySuccess: (bucketName: string, deletedCount: number) => void + onEmptyError: (bucketName: string, errorMessage: string) => void + onDeleteSuccess: (bucketName: string) => void + onDeleteError: (bucketName: string, errorMessage: string) => void + selectedContainers: string[] + setSelectedContainers: (containers: string[]) => void +}> = {}) => + render( + + + + + + ) + +// ─── Tests ──────────────────────────────────────────────────────────────────── + +describe("ContainerTableView", () => { + beforeEach(async () => { + vi.clearAllMocks() + await act(async () => { + i18n.activate("en") + }) + }) + + describe("Empty state", () => { + test("shows empty state when no containers", () => { + renderTableView({ containers: [] }) + expect(screen.getByText("No buckets found")).toBeInTheDocument() + expect(screen.getByTestId("no-containers")).toBeInTheDocument() + }) + + test("shows helpful message in empty state", () => { + renderTableView({ containers: [] }) + expect(screen.getByText(/There are no buckets available with the current search criteria/)).toBeInTheDocument() + }) + }) + + describe("Table structure", () => { + test("renders table headers", () => { + renderTableView() + expect(screen.getByText("Bucket Name")).toBeInTheDocument() + expect(screen.getByText("Object Count")).toBeInTheDocument() + expect(screen.getByText("Last Modified")).toBeInTheDocument() + expect(screen.getByText("Total Size")).toBeInTheDocument() + }) + + test("renders all container rows", () => { + renderTableView() + expect(screen.getByTestId("container-row-bucket-1")).toBeInTheDocument() + expect(screen.getByTestId("container-row-bucket-2")).toBeInTheDocument() + expect(screen.getByTestId("container-row-bucket-3")).toBeInTheDocument() + }) + + test("displays container names", () => { + renderTableView() + expect(screen.getByText("bucket-1")).toBeInTheDocument() + expect(screen.getByText("bucket-2")).toBeInTheDocument() + expect(screen.getByText("bucket-3")).toBeInTheDocument() + }) + + test("displays object counts", () => { + renderTableView() + // Using getAllByText since numbers appear multiple times in the UI + expect(screen.getAllByText("5").length).toBeGreaterThan(0) + expect(screen.getAllByText("3").length).toBeGreaterThan(0) + expect(screen.getAllByText("0").length).toBeGreaterThan(0) + }) + + test("displays container size information", () => { + renderTableView() + // Verify the component renders - sizes are displayed, specific format less critical + expect(screen.getByTestId("container-row-bucket-1")).toBeInTheDocument() + expect(screen.getByTestId("container-row-bucket-2")).toBeInTheDocument() + expect(screen.getByTestId("container-row-bucket-3")).toBeInTheDocument() + }) + }) + + describe("Footer", () => { + test("shows singular form for one bucket", () => { + renderTableView({ containers: [mockContainers[0]] }) + expect(screen.getByText("1 bucket")).toBeInTheDocument() + }) + + test("shows plural form for multiple buckets", () => { + renderTableView() + expect(screen.getByText("3 buckets")).toBeInTheDocument() + }) + }) + + describe("Selection", () => { + test("renders select all checkbox", () => { + renderTableView() + expect(screen.getByTestId("select-all-containers")).toBeInTheDocument() + }) + + test("renders checkbox for each container", () => { + renderTableView() + expect(screen.getByTestId("select-container-bucket-1")).toBeInTheDocument() + expect(screen.getByTestId("select-container-bucket-2")).toBeInTheDocument() + expect(screen.getByTestId("select-container-bucket-3")).toBeInTheDocument() + }) + + test("calls setSelectedContainers when selecting all", async () => { + const user = userEvent.setup({ delay: null }) + const mockSetSelected = vi.fn() + renderTableView({ setSelectedContainers: mockSetSelected }) + + const selectAllCheckbox = screen.getByTestId("select-all-containers").querySelector("input")! + await user.click(selectAllCheckbox) + + expect(mockSetSelected).toHaveBeenCalledWith(["bucket-1", "bucket-2", "bucket-3"]) + }) + + test("calls setSelectedContainers when deselecting all", async () => { + const user = userEvent.setup({ delay: null }) + const mockSetSelected = vi.fn() + renderTableView({ + selectedContainers: ["bucket-1", "bucket-2", "bucket-3"], + setSelectedContainers: mockSetSelected, + }) + + const selectAllCheckbox = screen.getByTestId("select-all-containers").querySelector("input")! + await user.click(selectAllCheckbox) + + expect(mockSetSelected).toHaveBeenCalledWith([]) + }) + + test("calls setSelectedContainers when selecting a container", async () => { + const user = userEvent.setup({ delay: null }) + const mockSetSelected = vi.fn() + renderTableView({ setSelectedContainers: mockSetSelected }) + + const checkbox = screen.getByTestId("select-container-bucket-1").querySelector("input")! + await user.click(checkbox) + + expect(mockSetSelected).toHaveBeenCalledWith(["bucket-1"]) + }) + + test("calls setSelectedContainers when deselecting a container", async () => { + const user = userEvent.setup({ delay: null }) + const mockSetSelected = vi.fn() + renderTableView({ + selectedContainers: ["bucket-1", "bucket-2"], + setSelectedContainers: mockSetSelected, + }) + + const checkbox = screen.getByTestId("select-container-bucket-1").querySelector("input")! + await user.click(checkbox) + + expect(mockSetSelected).toHaveBeenCalledWith(["bucket-2"]) + }) + }) + + describe("Row navigation", () => { + test("navigates to objects page on row click", async () => { + const user = userEvent.setup() + renderTableView() + + const row = screen.getByTestId("container-row-bucket-1") + await user.click(row) + + expect(mockNavigate).toHaveBeenCalledWith({ + to: "/projects/$projectId/storage/$provider/containers/$containerName/objects", + params: { + projectId: "test-project", + provider: "ceph", + containerName: "bucket-1", + }, + }) + }) + + test("navigates on Enter key", async () => { + const user = userEvent.setup() + renderTableView() + + const row = screen.getByTestId("container-row-bucket-1") + row.focus() + await user.keyboard("{Enter}") + + expect(mockNavigate).toHaveBeenCalledWith( + expect.objectContaining({ + params: expect.objectContaining({ + containerName: "bucket-1", + }), + }) + ) + }) + + test("does not navigate when clicking checkbox", async () => { + const user = userEvent.setup() + renderTableView() + + const checkbox = screen.getByTestId("select-container-bucket-1") + await user.click(checkbox) + + expect(mockNavigate).not.toHaveBeenCalled() + }) + }) + + describe("Action menu", () => { + test("renders PopupMenu for each container", () => { + renderTableView() + // PopupMenu items are hidden until menu is opened, so just verify the rows exist + expect(screen.getByTestId("container-row-bucket-1")).toBeInTheDocument() + expect(screen.getByTestId("container-row-bucket-2")).toBeInTheDocument() + expect(screen.getByTestId("container-row-bucket-3")).toBeInTheDocument() + }) + }) + + describe("Modals", () => { + test("renders CreateBucketModal when createModalOpen is true", () => { + renderTableView({ createModalOpen: true }) + expect(screen.getByTestId("create-bucket-modal")).toBeInTheDocument() + }) + + test("does not render CreateBucketModal when createModalOpen is false", () => { + renderTableView({ createModalOpen: false }) + expect(screen.queryByTestId("create-bucket-modal")).not.toBeInTheDocument() + }) + + test("closes CreateBucketModal when Cancel clicked", async () => { + const user = userEvent.setup() + const mockSetCreateOpen = vi.fn() + renderTableView({ createModalOpen: true, setCreateModalOpen: mockSetCreateOpen }) + + const cancelButton = screen.getByText("Cancel") + await user.click(cancelButton) + + expect(mockSetCreateOpen).toHaveBeenCalledWith(false) + }) + }) + + describe("Date formatting", () => { + test("formats last_modified date", () => { + renderTableView() + // Date formatting is locale-dependent, just check it's not "N/A" + const cells = screen.getAllByText(/2024/) + expect(cells.length).toBeGreaterThan(0) + }) + + test("shows creationDate as fallback when last_modified is missing", () => { + renderTableView() + // Should show creation date for containers without last_modified + const cells = screen.getAllByText(/2024/) + expect(cells.length).toBeGreaterThan(0) + }) + }) +}) diff --git a/apps/aurora-portal/src/client/routes/_auth/projects/$projectId/storage/-components/Ceph/Containers/ContainerTableView.tsx b/apps/aurora-portal/src/client/routes/_auth/projects/$projectId/storage/-components/Ceph/Containers/ContainerTableView.tsx new file mode 100644 index 000000000..4aa942d48 --- /dev/null +++ b/apps/aurora-portal/src/client/routes/_auth/projects/$projectId/storage/-components/Ceph/Containers/ContainerTableView.tsx @@ -0,0 +1,294 @@ +import { useRef, useEffect, useState } from "react" +import { useVirtualizer } from "@tanstack/react-virtual" +import { useNavigate, useParams } from "@tanstack/react-router" +import { + Checkbox, + DataGrid, + DataGridHeadCell, + DataGridRow, + DataGridCell, + PopupMenu, + PopupMenuItem, + PopupMenuOptions, +} from "@cloudoperators/juno-ui-components" +import { Trans, useLingui } from "@lingui/react/macro" +import { Container } from "@/server/Storage/types/ceph" +import { formatBytesBinary } from "@/client/utils/formatBytes" +import { CreateBucketModal } from "./CreateBucketModal" +import { EmptyBucketModal } from "./EmptyBucketModal" +import { DeleteBucketModal } from "./DeleteBucketModal" + +interface ContainerTableViewProps { + containers: Container[] + createModalOpen: boolean + setCreateModalOpen: (open: boolean) => void + onCreateSuccess: (bucketName: string) => void + onCreateError: (bucketName: string, errorMessage: string) => void + onEmptySuccess: (bucketName: string, deletedCount: number) => void + onEmptyError: (bucketName: string, errorMessage: string) => void + onDeleteSuccess: (bucketName: string) => void + onDeleteError: (bucketName: string, errorMessage: string) => void + selectedContainers: string[] + setSelectedContainers: (containers: string[]) => void +} + +export const ContainerTableView = ({ + containers, + createModalOpen, + setCreateModalOpen, + onCreateSuccess, + onCreateError, + onEmptySuccess, + onEmptyError, + onDeleteSuccess, + onDeleteError, + selectedContainers, + setSelectedContainers, +}: ContainerTableViewProps) => { + const { projectId, provider } = useParams({ strict: false }) + const { t } = useLingui() + const navigate = useNavigate() + + const parentRef = useRef(null) + const [scrollbarWidth, setScrollbarWidth] = useState(0) + const [emptyModalBucket, setEmptyModalBucket] = useState(null) + const [deleteModalBucket, setDeleteModalBucket] = useState(null) + + // Calculate scrollbar width + useEffect(() => { + if (parentRef.current) { + const width = parentRef.current.offsetWidth - parentRef.current.clientWidth + setScrollbarWidth(width) + } + }, [containers.length]) + + // Format date to localized string + const formatDate = (dateString: string): string => { + try { + const date = new Date(dateString) + return date.toLocaleString() + } catch { + return t`N/A` + } + } + + const rowVirtualizer = useVirtualizer({ + count: containers.length, + getScrollElement: () => parentRef.current, + estimateSize: () => 48, // Estimated row height + overscan: 10, + }) + + const selectedSet = new Set(selectedContainers) + const allSelected = containers.length > 0 && containers.every((c) => selectedSet.has(c.name)) + + const handleSelectAll = () => { + if (allSelected) { + setSelectedContainers([]) + } else { + setSelectedContainers(containers.map((c) => c.name)) + } + } + + const handleSelectContainer = (containerName: string) => { + if (selectedContainers.includes(containerName)) { + setSelectedContainers(selectedContainers.filter((name) => name !== containerName)) + } else { + setSelectedContainers([...selectedContainers, containerName]) + } + } + + if (!containers || containers.length === 0) { + return ( + + + +
+

+ No buckets found +

+

+ + There are no buckets available with the current search criteria. Try adjusting your search term. + +

+
+
+
+
+ ) + } + + // Define column template — 6 columns: checkbox, name, count, last modified, size, actions menu + const gridColumnTemplate = "40px minmax(200px, 2fr) minmax(100px, 1fr) minmax(180px, 2fr) minmax(100px, 1fr) 60px" + + const allContainersCount = containers.length + + return ( + <> +
+ {/* Table Header with scrollbar padding */} +
+ + + + + + + Bucket Name + + + Object Count + + + Last Modified + + + Total Size + + + + +
+ + {/* Virtualized Table Body with dynamic height */} +
+
+ {rowVirtualizer.getVirtualItems().map((virtualRow) => { + const container = containers[virtualRow.index] + const isSelected = selectedContainers.includes(container.name) + + const handleRowNavigate = () => + navigate({ + to: "/projects/$projectId/storage/$provider/containers/$containerName/objects", + params: { + projectId: projectId ?? "", + provider: (provider as string) ?? "ceph", + containerName: container.name, + }, + }) + + return ( +
{ + if (e.key === "Enter" || e.key === " ") { + e.preventDefault() + handleRowNavigate() + } + }} + > + e.stopPropagation()} + onKeyDown={(e) => { + if (e.key === "Enter" || e.key === " ") { + e.stopPropagation() + } + }} + > + handleSelectContainer(container.name)} + data-testid={`select-container-${container.name}`} + /> + + + + {container.name} + + + {container.count.toLocaleString()} + {formatDate(container.last_modified || container.creationDate || "")} + {formatBytesBinary(container.bytes)} + e.stopPropagation()}> + + + setEmptyModalBucket(container)} + data-testid={`empty-action-${container.name}`} + /> + setDeleteModalBucket(container)} + data-testid={`delete-action-${container.name}`} + /> + + + +
+ ) + })} +
+
+ + {/* Footer with count */} +
+ {allContainersCount === 1 ? ( + {allContainersCount} bucket + ) : ( + {allContainersCount} buckets + )} +
+
+ + setCreateModalOpen(false)} + onSuccess={onCreateSuccess} + onError={onCreateError} + /> + + setEmptyModalBucket(null)} + onSuccess={onEmptySuccess} + onError={onEmptyError} + /> + + setDeleteModalBucket(null)} + onSuccess={onDeleteSuccess} + onError={onDeleteError} + /> + + ) +} diff --git a/apps/aurora-portal/src/client/routes/_auth/projects/$projectId/storage/-components/Ceph/Containers/ContainerToastNotifications.test.tsx b/apps/aurora-portal/src/client/routes/_auth/projects/$projectId/storage/-components/Ceph/Containers/ContainerToastNotifications.test.tsx new file mode 100644 index 000000000..c972859d3 --- /dev/null +++ b/apps/aurora-portal/src/client/routes/_auth/projects/$projectId/storage/-components/Ceph/Containers/ContainerToastNotifications.test.tsx @@ -0,0 +1,416 @@ +import React from "react" +import { describe, it, expect, vi, beforeEach } from "vitest" +import { render, screen } from "@testing-library/react" +import { I18nProvider } from "@lingui/react" +import { i18n } from "@lingui/core" +import { + getBucketCreatedToast, + getBucketCreateErrorToast, + getBucketEmptiedToast, + getBucketEmptyErrorToast, + getBucketDeletedToast, + getBucketDeleteErrorToast, + getBucketsEmptyCompleteToast, +} from "./ContainerToastNotifications" + +describe("ContainerToastNotifications", () => { + const mockOnDismiss = vi.fn() + const defaultConfig = { onDismiss: mockOnDismiss } + + beforeEach(() => { + vi.clearAllMocks() + i18n.activate("en") + }) + + // ── getBucketCreatedToast ──────────────────────────────────────────────────── + + describe("getBucketCreatedToast", () => { + it("returns success toast with correct structure", () => { + const toast = getBucketCreatedToast("my-bucket", defaultConfig) + expect(toast.variant).toBe("success") + expect(toast.autoDismiss).toBe(true) + expect(toast.autoDismissTimeout).toBe(5000) + expect(toast.onDismiss).toBe(mockOnDismiss) + expect(toast.children).toBeDefined() + }) + + it("renders correct message content", () => { + const toast = getBucketCreatedToast("my-bucket", defaultConfig) + render({toast.children as React.ReactNode}) + expect(screen.getByText("Bucket Created")).toBeInTheDocument() + expect(screen.getByText(/my-bucket/)).toBeInTheDocument() + expect(screen.getByText(/was successfully created/)).toBeInTheDocument() + }) + + it("uses custom autoDismissTimeout when provided", () => { + const toast = getBucketCreatedToast("my-bucket", { onDismiss: mockOnDismiss, autoDismissTimeout: 3000 }) + expect(toast.autoDismissTimeout).toBe(3000) + }) + + it("handles bucket names with special characters", () => { + const toast = getBucketCreatedToast("my-bucket-2024", defaultConfig) + render({toast.children as React.ReactNode}) + expect(screen.getByText(/my-bucket-2024/)).toBeInTheDocument() + }) + + it("handles empty bucket name", () => { + const toast = getBucketCreatedToast("", defaultConfig) + render({toast.children as React.ReactNode}) + expect(screen.getByText("Bucket Created")).toBeInTheDocument() + }) + }) + + // ── getBucketCreateErrorToast ──────────────────────────────────────────────── + + describe("getBucketCreateErrorToast", () => { + it("returns error toast with correct structure", () => { + const toast = getBucketCreateErrorToast("my-bucket", "Conflict", defaultConfig) + expect(toast.variant).toBe("error") + expect(toast.autoDismiss).toBe(true) + expect(toast.autoDismissTimeout).toBe(5000) + expect(toast.onDismiss).toBe(mockOnDismiss) + expect(toast.children).toBeDefined() + }) + + it("renders correct error message content", () => { + const toast = getBucketCreateErrorToast("my-bucket", "Conflict", defaultConfig) + render({toast.children as React.ReactNode}) + expect(screen.getByText("Failed to Create Bucket")).toBeInTheDocument() + expect(screen.getByText(/my-bucket/)).toBeInTheDocument() + expect(screen.getByText(/Could not create bucket/)).toBeInTheDocument() + expect(screen.getByText(/Conflict/)).toBeInTheDocument() + }) + + it("uses custom autoDismissTimeout when provided", () => { + const toast = getBucketCreateErrorToast("my-bucket", "err", { + onDismiss: mockOnDismiss, + autoDismissTimeout: 10000, + }) + expect(toast.autoDismissTimeout).toBe(10000) + }) + + it("handles different error messages", () => { + const toast = getBucketCreateErrorToast("my-bucket", "Bucket already exists", defaultConfig) + render({toast.children as React.ReactNode}) + expect(screen.getByText(/Bucket already exists/)).toBeInTheDocument() + }) + + it("handles long error messages", () => { + const longMessage = "The server encountered an internal error and was unable to complete your request" + const toast = getBucketCreateErrorToast("my-bucket", longMessage, defaultConfig) + render({toast.children as React.ReactNode}) + expect(screen.getByText(/The server encountered an internal error/)).toBeInTheDocument() + }) + + it("handles empty error message", () => { + const toast = getBucketCreateErrorToast("my-bucket", "", defaultConfig) + render({toast.children as React.ReactNode}) + expect(screen.getByText("Failed to Create Bucket")).toBeInTheDocument() + }) + }) + + // ── getBucketEmptiedToast ──────────────────────────────────────────────────── + + describe("getBucketEmptiedToast", () => { + it("returns success toast with correct structure", () => { + const toast = getBucketEmptiedToast("my-bucket", 5, defaultConfig) + expect(toast.variant).toBe("success") + expect(toast.autoDismiss).toBe(true) + expect(toast.autoDismissTimeout).toBe(5000) + expect(toast.onDismiss).toBe(mockOnDismiss) + expect(toast.children).toBeDefined() + }) + + it("renders plural message when multiple objects were deleted", () => { + const toast = getBucketEmptiedToast("my-bucket", 5, defaultConfig) + render({toast.children as React.ReactNode}) + expect(screen.getByText("Bucket Emptied")).toBeInTheDocument() + expect(screen.getByText(/my-bucket/)).toBeInTheDocument() + expect(screen.getByText(/5 objects deleted/)).toBeInTheDocument() + }) + + it("renders singular message when exactly 1 object was deleted", () => { + const toast = getBucketEmptiedToast("my-bucket", 1, defaultConfig) + render({toast.children as React.ReactNode}) + expect(screen.getByText(/1 object deleted/)).toBeInTheDocument() + }) + + it("renders already empty message when deletedCount is 0", () => { + const toast = getBucketEmptiedToast("my-bucket", 0, defaultConfig) + render({toast.children as React.ReactNode}) + expect(screen.getByText("Bucket Emptied")).toBeInTheDocument() + expect(screen.getByText(/was already empty/)).toBeInTheDocument() + }) + + it("uses custom autoDismissTimeout when provided", () => { + const toast = getBucketEmptiedToast("my-bucket", 3, { onDismiss: mockOnDismiss, autoDismissTimeout: 3000 }) + expect(toast.autoDismissTimeout).toBe(3000) + }) + + it("handles empty bucket name", () => { + const toast = getBucketEmptiedToast("", 2, defaultConfig) + render({toast.children as React.ReactNode}) + expect(screen.getByText("Bucket Emptied")).toBeInTheDocument() + }) + }) + + // ── getBucketEmptyErrorToast ──────────────────────────────────────────────── + + describe("getBucketEmptyErrorToast", () => { + it("returns error toast with correct structure", () => { + const toast = getBucketEmptyErrorToast("my-bucket", "Internal Server Error", defaultConfig) + expect(toast.variant).toBe("error") + expect(toast.autoDismiss).toBe(true) + expect(toast.autoDismissTimeout).toBe(5000) + expect(toast.onDismiss).toBe(mockOnDismiss) + expect(toast.children).toBeDefined() + }) + + it("renders correct error message content", () => { + const toast = getBucketEmptyErrorToast("my-bucket", "Internal Server Error", defaultConfig) + render({toast.children as React.ReactNode}) + expect(screen.getByText("Failed to Empty Bucket")).toBeInTheDocument() + expect(screen.getByText(/my-bucket/)).toBeInTheDocument() + expect(screen.getByText(/Could not empty bucket/)).toBeInTheDocument() + expect(screen.getByText(/Internal Server Error/)).toBeInTheDocument() + }) + + it("uses custom autoDismissTimeout when provided", () => { + const toast = getBucketEmptyErrorToast("my-bucket", "err", { + onDismiss: mockOnDismiss, + autoDismissTimeout: 10000, + }) + expect(toast.autoDismissTimeout).toBe(10000) + }) + + it("handles different error messages", () => { + const toast = getBucketEmptyErrorToast("my-bucket", "Bulk delete failed", defaultConfig) + render({toast.children as React.ReactNode}) + expect(screen.getByText(/Bulk delete failed/)).toBeInTheDocument() + }) + + it("handles empty error message", () => { + const toast = getBucketEmptyErrorToast("my-bucket", "", defaultConfig) + render({toast.children as React.ReactNode}) + expect(screen.getByText("Failed to Empty Bucket")).toBeInTheDocument() + }) + }) + + // ── getBucketDeletedToast ──────────────────────────────────────────────────── + + describe("getBucketDeletedToast", () => { + it("returns success toast with correct structure", () => { + const toast = getBucketDeletedToast("my-bucket", defaultConfig) + expect(toast.variant).toBe("success") + expect(toast.autoDismiss).toBe(true) + expect(toast.autoDismissTimeout).toBe(5000) + expect(toast.onDismiss).toBe(mockOnDismiss) + expect(toast.children).toBeDefined() + }) + + it("renders correct message content", () => { + const toast = getBucketDeletedToast("my-bucket", defaultConfig) + render({toast.children as React.ReactNode}) + expect(screen.getByText("Bucket Deleted")).toBeInTheDocument() + expect(screen.getByText(/my-bucket/)).toBeInTheDocument() + expect(screen.getByText(/was successfully deleted/)).toBeInTheDocument() + }) + + it("uses custom autoDismissTimeout when provided", () => { + const toast = getBucketDeletedToast("my-bucket", { onDismiss: mockOnDismiss, autoDismissTimeout: 3000 }) + expect(toast.autoDismissTimeout).toBe(3000) + }) + + it("handles bucket names with special characters", () => { + const toast = getBucketDeletedToast("my-bucket-2024", defaultConfig) + render({toast.children as React.ReactNode}) + expect(screen.getByText(/my-bucket-2024/)).toBeInTheDocument() + }) + }) + + // ── getBucketDeleteErrorToast ──────────────────────────────────────────────── + + describe("getBucketDeleteErrorToast", () => { + it("returns error toast with correct structure", () => { + const toast = getBucketDeleteErrorToast("my-bucket", "Forbidden", defaultConfig) + expect(toast.variant).toBe("error") + expect(toast.autoDismiss).toBe(true) + expect(toast.autoDismissTimeout).toBe(5000) + expect(toast.onDismiss).toBe(mockOnDismiss) + expect(toast.children).toBeDefined() + }) + + it("renders correct error message content", () => { + const toast = getBucketDeleteErrorToast("my-bucket", "Forbidden", defaultConfig) + render({toast.children as React.ReactNode}) + expect(screen.getByText("Failed to Delete Bucket")).toBeInTheDocument() + expect(screen.getByText(/my-bucket/)).toBeInTheDocument() + expect(screen.getByText(/Could not delete bucket/)).toBeInTheDocument() + expect(screen.getByText(/Forbidden/)).toBeInTheDocument() + }) + + it("uses custom autoDismissTimeout when provided", () => { + const toast = getBucketDeleteErrorToast("my-bucket", "err", { + onDismiss: mockOnDismiss, + autoDismissTimeout: 10000, + }) + expect(toast.autoDismissTimeout).toBe(10000) + }) + + it("handles different error messages", () => { + const toast = getBucketDeleteErrorToast("my-bucket", "Internal Server Error", defaultConfig) + render({toast.children as React.ReactNode}) + expect(screen.getByText(/Internal Server Error/)).toBeInTheDocument() + }) + + it("handles empty error message", () => { + const toast = getBucketDeleteErrorToast("my-bucket", "", defaultConfig) + render({toast.children as React.ReactNode}) + expect(screen.getByText("Failed to Delete Bucket")).toBeInTheDocument() + }) + }) + + // ── getBucketsEmptyCompleteToast ───────────────────────────────────────────── + + describe("getBucketsEmptyCompleteToast", () => { + it("returns success toast when no errors", () => { + const toast = getBucketsEmptyCompleteToast(5, 120, [], defaultConfig) + expect(toast.variant).toBe("success") + expect(toast.autoDismiss).toBe(true) + expect(toast.autoDismissTimeout).toBe(5000) + expect(toast.onDismiss).toBe(mockOnDismiss) + expect(toast.children).toBeDefined() + }) + + it("renders success message with correct bucket and object counts", () => { + const toast = getBucketsEmptyCompleteToast(5, 120, [], defaultConfig) + render({toast.children as React.ReactNode}) + expect(screen.getByText("All Buckets Emptied")).toBeInTheDocument() + expect(screen.getByText(/5 buckets/)).toBeInTheDocument() + expect(screen.getByText(/120 objects/)).toBeInTheDocument() + }) + + it("renders success message with singular bucket", () => { + const toast = getBucketsEmptyCompleteToast(1, 10, [], defaultConfig) + render({toast.children as React.ReactNode}) + expect(screen.getByText(/1 bucket/)).toBeInTheDocument() + expect(screen.getByText(/10 objects/)).toBeInTheDocument() + }) + + it("renders success message with singular object", () => { + const toast = getBucketsEmptyCompleteToast(2, 1, [], defaultConfig) + render({toast.children as React.ReactNode}) + expect(screen.getByText(/2 buckets/)).toBeInTheDocument() + expect(screen.getByText(/1 object/)).toBeInTheDocument() + }) + + it("returns warning toast when there are errors", () => { + const toast = getBucketsEmptyCompleteToast(3, 100, ["bucket1", "bucket2"], defaultConfig) + expect(toast.variant).toBe("warning") + expect(toast.autoDismiss).toBe(false) + expect(toast.onDismiss).toBe(mockOnDismiss) + }) + + it("renders warning message with correct counts when errors exist", () => { + const toast = getBucketsEmptyCompleteToast(3, 100, ["bucket1", "bucket2"], defaultConfig) + render({toast.children as React.ReactNode}) + expect(screen.getByText("Empty All Completed with Errors")).toBeInTheDocument() + expect(screen.getByText(/Successfully emptied 3 of 5 buckets/)).toBeInTheDocument() + expect(screen.getByText(/100 objects/)).toBeInTheDocument() + expect(screen.getByText(/2 buckets failed/)).toBeInTheDocument() + }) + + it("renders warning with singular failed bucket", () => { + const toast = getBucketsEmptyCompleteToast(2, 50, ["bucket1"], defaultConfig) + render({toast.children as React.ReactNode}) + expect(screen.getByText(/1 bucket failed/)).toBeInTheDocument() + }) + + it("uses custom autoDismissTimeout when provided", () => { + const toast = getBucketsEmptyCompleteToast(5, 120, [], { onDismiss: mockOnDismiss, autoDismissTimeout: 3000 }) + expect(toast.autoDismissTimeout).toBe(3000) + }) + + it("does not auto-dismiss when there are errors", () => { + const toast = getBucketsEmptyCompleteToast(3, 100, ["bucket1"], defaultConfig) + expect(toast.autoDismiss).toBe(false) + }) + + it("handles zero emptied buckets with errors", () => { + const toast = getBucketsEmptyCompleteToast(0, 0, ["bucket1", "bucket2", "bucket3"], defaultConfig) + render({toast.children as React.ReactNode}) + expect(screen.getByText(/0 of 3 buckets/)).toBeInTheDocument() + expect(screen.getByText(/3 buckets failed/)).toBeInTheDocument() + }) + }) + + // ── Toast configuration ────────────────────────────────────────────────────── + + describe("Toast configuration", () => { + it("all success toasts have success variant and autoDismiss", () => { + const successToasts = [ + getBucketCreatedToast("b", defaultConfig), + getBucketEmptiedToast("b", 5, defaultConfig), + getBucketDeletedToast("b", defaultConfig), + getBucketsEmptyCompleteToast(3, 100, [], defaultConfig), + ] + successToasts.forEach((toast) => { + expect(toast.variant).toBe("success") + expect(toast.autoDismiss).toBe(true) + expect(toast.onDismiss).toBe(mockOnDismiss) + }) + }) + + it("all error toasts have error variant and autoDismiss", () => { + const errorToasts = [ + getBucketCreateErrorToast("b", "err", defaultConfig), + getBucketEmptyErrorToast("b", "err", defaultConfig), + getBucketDeleteErrorToast("b", "err", defaultConfig), + ] + errorToasts.forEach((toast) => { + expect(toast.variant).toBe("error") + expect(toast.autoDismiss).toBe(true) + expect(toast.onDismiss).toBe(mockOnDismiss) + }) + }) + + it("calls onDismiss callback when invoked", () => { + const customOnDismiss = vi.fn() + const toast = getBucketCreatedToast("b", { onDismiss: customOnDismiss }) + toast.onDismiss?.() + expect(customOnDismiss).toHaveBeenCalledTimes(1) + }) + + it("accepts custom autoDismissTimeout for all toast types", () => { + const customTimeout = 7500 + const toasts = [ + getBucketCreatedToast("b", { onDismiss: mockOnDismiss, autoDismissTimeout: customTimeout }), + getBucketCreateErrorToast("b", "err", { onDismiss: mockOnDismiss, autoDismissTimeout: customTimeout }), + getBucketEmptiedToast("b", 5, { onDismiss: mockOnDismiss, autoDismissTimeout: customTimeout }), + getBucketEmptyErrorToast("b", "err", { onDismiss: mockOnDismiss, autoDismissTimeout: customTimeout }), + getBucketDeletedToast("b", { onDismiss: mockOnDismiss, autoDismissTimeout: customTimeout }), + getBucketDeleteErrorToast("b", "err", { onDismiss: mockOnDismiss, autoDismissTimeout: customTimeout }), + getBucketsEmptyCompleteToast(3, 100, [], { onDismiss: mockOnDismiss, autoDismissTimeout: customTimeout }), + ] + toasts.forEach((toast) => { + expect(toast.autoDismissTimeout).toBe(customTimeout) + }) + }) + + it("all toasts return a ReactNode as children", () => { + const toasts = [ + getBucketCreatedToast("b", defaultConfig), + getBucketCreateErrorToast("b", "err", defaultConfig), + getBucketEmptiedToast("b", 5, defaultConfig), + getBucketEmptyErrorToast("b", "err", defaultConfig), + getBucketDeletedToast("b", defaultConfig), + getBucketDeleteErrorToast("b", "err", defaultConfig), + getBucketsEmptyCompleteToast(3, 100, [], defaultConfig), + ] + toasts.forEach((toast) => { + expect(toast.children).toBeTruthy() + expect(typeof toast.children).toBe("object") + }) + }) + }) +}) diff --git a/apps/aurora-portal/src/client/routes/_auth/projects/$projectId/storage/-components/Ceph/Containers/ContainerToastNotifications.tsx b/apps/aurora-portal/src/client/routes/_auth/projects/$projectId/storage/-components/Ceph/Containers/ContainerToastNotifications.tsx new file mode 100644 index 000000000..b8019dea8 --- /dev/null +++ b/apps/aurora-portal/src/client/routes/_auth/projects/$projectId/storage/-components/Ceph/Containers/ContainerToastNotifications.tsx @@ -0,0 +1,175 @@ +import { ReactNode } from "react" +import { ToastProps } from "@cloudoperators/juno-ui-components" +import { Trans, Plural } from "@lingui/react/macro" +import { Stack } from "@cloudoperators/juno-ui-components/index" + +interface NotificationTextProps { + title: ReactNode + description: ReactNode +} + +export function NotificationText({ title, description }: NotificationTextProps) { + return ( + + {title} + {description} + + ) +} + +interface ToastConfig { + onDismiss: () => void + autoDismissTimeout?: number +} + +export const getBucketCreatedToast = (bucketName: string, config: ToastConfig): ToastProps => ({ + variant: "success", + children: ( + Bucket Created} + description={Bucket "{bucketName}" was successfully created.} + /> + ), + autoDismiss: true, + autoDismissTimeout: config.autoDismissTimeout ?? 5000, + onDismiss: config.onDismiss, +}) + +export const getBucketCreateErrorToast = ( + bucketName: string, + errorMessage: string, + config: ToastConfig +): ToastProps => ({ + variant: "error", + children: ( + Failed to Create Bucket} + description={ + + Could not create bucket "{bucketName}": {errorMessage} + + } + /> + ), + autoDismiss: true, + autoDismissTimeout: config.autoDismissTimeout ?? 5000, + onDismiss: config.onDismiss, +}) + +export const getBucketEmptiedToast = (bucketName: string, deletedCount: number, config: ToastConfig): ToastProps => ({ + variant: "success", + children: ( + Bucket Emptied} + description={ + deletedCount === 0 ? ( + Bucket "{bucketName}" was already empty. + ) : deletedCount === 1 ? ( + + Bucket "{bucketName}" was successfully emptied. {deletedCount} object deleted. + + ) : ( + + Bucket "{bucketName}" was successfully emptied. {deletedCount} objects deleted. + + ) + } + /> + ), + autoDismiss: true, + autoDismissTimeout: config.autoDismissTimeout ?? 5000, + onDismiss: config.onDismiss, +}) + +export const getBucketEmptyErrorToast = ( + bucketName: string, + errorMessage: string, + config: ToastConfig +): ToastProps => ({ + variant: "error", + children: ( + Failed to Empty Bucket} + description={ + + Could not empty bucket "{bucketName}": {errorMessage} + + } + /> + ), + autoDismiss: true, + autoDismissTimeout: config.autoDismissTimeout ?? 5000, + onDismiss: config.onDismiss, +}) + +export const getBucketDeletedToast = (bucketName: string, config: ToastConfig): ToastProps => ({ + variant: "success", + children: ( + Bucket Deleted} + description={Bucket "{bucketName}" was successfully deleted.} + /> + ), + autoDismiss: true, + autoDismissTimeout: config.autoDismissTimeout ?? 5000, + onDismiss: config.onDismiss, +}) + +export const getBucketDeleteErrorToast = ( + bucketName: string, + errorMessage: string, + config: ToastConfig +): ToastProps => ({ + variant: "error", + children: ( + Failed to Delete Bucket} + description={ + + Could not delete bucket "{bucketName}": {errorMessage} + + } + /> + ), + autoDismiss: true, + autoDismissTimeout: config.autoDismissTimeout ?? 5000, + onDismiss: config.onDismiss, +}) + +export const getBucketsEmptyCompleteToast = ( + emptiedCount: number, + totalDeleted: number, + errors: string[], + config: ToastConfig +): ToastProps => { + const hasErrors = errors.length > 0 + const totalBuckets = emptiedCount + errors.length + const errorsLength = errors.length + + return { + variant: hasErrors ? "warning" : "success", + children: ( + Empty All Completed with Errors : All Buckets Emptied} + description={ + hasErrors ? ( + + Successfully emptied {emptiedCount} of {totalBuckets}{" "} + , deleting {totalDeleted}{" "} + . {errorsLength}{" "} + failed. + + ) : ( + + Successfully emptied {emptiedCount} , deleting{" "} + {totalDeleted} . + + ) + } + /> + ), + autoDismiss: !hasErrors, // Don't auto-dismiss if there are errors + autoDismissTimeout: config.autoDismissTimeout ?? 5000, + onDismiss: config.onDismiss, + } +} diff --git a/apps/aurora-portal/src/client/routes/_auth/projects/$projectId/storage/-components/Ceph/Containers/CreateBucketModal.test.tsx b/apps/aurora-portal/src/client/routes/_auth/projects/$projectId/storage/-components/Ceph/Containers/CreateBucketModal.test.tsx new file mode 100644 index 000000000..cc24ad877 --- /dev/null +++ b/apps/aurora-portal/src/client/routes/_auth/projects/$projectId/storage/-components/Ceph/Containers/CreateBucketModal.test.tsx @@ -0,0 +1,760 @@ +import { describe, test, expect, vi, beforeEach } from "vitest" +import { render, screen, waitFor, act } from "@testing-library/react" +import userEvent from "@testing-library/user-event" +import { PortalProvider } from "@cloudoperators/juno-ui-components" +import { i18n } from "@lingui/core" +import { I18nProvider } from "@lingui/react" +import { CreateBucketModal } from "./CreateBucketModal" + +// ─── Mock useProjectId ──────────────────────────────────────────────────────── + +const mockProjectId = "test-project-123" + +vi.mock("@/client/hooks/useProjectId", () => ({ + useProjectId: () => mockProjectId, +})) + +// ─── tRPC mock ──────────────────────────────────────────────────────────────── + +type MutationOptions = { + onSuccess?: () => void + onError?: (error: { message: string }) => void + onSettled?: () => void +} + +const { mockInvalidate, mockMutate, mockReset, mockState } = vi.hoisted(() => { + const mockState = { + mutationError: null as string | null, + isPending: false, + capturedOptions: {} as MutationOptions, + } + const mockMutate = vi.fn().mockImplementation(() => { + if (mockState.mutationError) { + mockState.capturedOptions.onError?.({ message: mockState.mutationError }) + } else { + mockState.capturedOptions.onSuccess?.() + } + mockState.capturedOptions.onSettled?.() + }) + return { + mockInvalidate: vi.fn(), + mockMutate, + mockReset: vi.fn(), + mockState, + } +}) + +vi.mock("@/client/trpcClient", () => ({ + trpcReact: { + useUtils: () => ({ + storage: { + ceph: { + containers: { list: { invalidate: mockInvalidate } }, + }, + }, + }), + storage: { + ceph: { + containers: { + create: { + useMutation: (options: MutationOptions) => { + mockState.capturedOptions = options ?? {} + return { mutate: mockMutate, isPending: mockState.isPending, reset: mockReset } + }, + }, + }, + }, + }, + }, +})) + +// ─── Render helper ──────────────────────────────────────────────────────────── + +const renderModal = ({ + isOpen = true, + onClose = vi.fn(), + onSuccess = vi.fn(), + onError = vi.fn(), +}: { + isOpen?: boolean + onClose?: () => void + onSuccess?: (bucketName: string) => void + onError?: (bucketName: string, errorMessage: string) => void +} = {}) => + render( + + + + + + ) + +// ─── Tests ──────────────────────────────────────────────────────────────────── + +describe("CreateBucketModal", () => { + beforeEach(async () => { + vi.clearAllMocks() + mockState.mutationError = null + mockState.capturedOptions = {} + mockState.isPending = false + await act(async () => { + i18n.activate("en") + }) + }) + + describe("Visibility", () => { + test("does not render when isOpen is false", () => { + renderModal({ isOpen: false }) + expect(screen.queryByText(/Create Bucket/i)).not.toBeInTheDocument() + }) + + test("renders when isOpen is true", () => { + renderModal() + expect(screen.getByText("Create Bucket")).toBeInTheDocument() + }) + }) + + describe("UI elements", () => { + test("renders modal title", () => { + renderModal() + expect(screen.getByText("Create Bucket")).toBeInTheDocument() + }) + + test("renders info message about S3 naming rules", () => { + renderModal() + expect(screen.getByText(/S3 bucket names must be 3-63 characters long/)).toBeInTheDocument() + expect(screen.getByText(/contain only lowercase letters, numbers, periods, and hyphens/)).toBeInTheDocument() + }) + + test("renders bucket name input", () => { + renderModal() + expect(screen.getByLabelText(/Bucket name/i)).toBeInTheDocument() + }) + + test("bucket name input has placeholder", () => { + renderModal() + expect(screen.getByPlaceholderText("my-bucket-name")).toBeInTheDocument() + }) + + test("renders Create button", () => { + renderModal() + expect(screen.getByRole("button", { name: /^Create$/i })).toBeInTheDocument() + }) + + test("renders Cancel button", () => { + renderModal() + expect(screen.getByRole("button", { name: /Cancel/i })).toBeInTheDocument() + }) + + test("Create button is disabled when input is empty", () => { + renderModal() + expect(screen.getByRole("button", { name: /^Create$/i })).toBeDisabled() + }) + + test("input has autofocus", () => { + renderModal() + expect(screen.getByLabelText(/Bucket name/i)).toHaveFocus() + }) + }) + + describe("Valid bucket names", () => { + test("accepts valid bucket name with lowercase letters", async () => { + const user = userEvent.setup() + renderModal() + + const input = screen.getByLabelText(/Bucket name/i) + await user.type(input, "mybucket") + + expect(screen.getByRole("button", { name: /^Create$/i })).not.toBeDisabled() + expect(input).not.toHaveAttribute("aria-invalid", "true") + }) + + test("accepts valid bucket name with numbers", async () => { + const user = userEvent.setup() + renderModal() + + const input = screen.getByLabelText(/Bucket name/i) + await user.type(input, "my-bucket-123") + + expect(screen.getByRole("button", { name: /^Create$/i })).not.toBeDisabled() + }) + + test("accepts valid bucket name with periods", async () => { + const user = userEvent.setup() + renderModal() + + const input = screen.getByLabelText(/Bucket name/i) + await user.type(input, "my.bucket.name") + + expect(screen.getByRole("button", { name: /^Create$/i })).not.toBeDisabled() + }) + + test("accepts valid bucket name with hyphens", async () => { + const user = userEvent.setup() + renderModal() + + const input = screen.getByLabelText(/Bucket name/i) + await user.type(input, "my-bucket-name") + + expect(screen.getByRole("button", { name: /^Create$/i })).not.toBeDisabled() + }) + + test("accepts 3-character bucket name", async () => { + const user = userEvent.setup() + renderModal() + + const input = screen.getByLabelText(/Bucket name/i) + await user.type(input, "abc") + + expect(screen.getByRole("button", { name: /^Create$/i })).not.toBeDisabled() + }) + + test("accepts 63-character bucket name", async () => { + const user = userEvent.setup() + renderModal() + + const input = screen.getByLabelText(/Bucket name/i) + const longName = "a".repeat(63) + await user.type(input, longName) + + expect(screen.getByRole("button", { name: /^Create$/i })).not.toBeDisabled() + }) + }) + + describe("Invalid bucket names - Length validation", () => { + test("shows error for empty bucket name on submit", async () => { + const user = userEvent.setup() + renderModal() + + // Type something first, then clear to trigger validation + const input = screen.getByLabelText(/Bucket name/i) + await user.type(input, "test") + + // Enable the button by having valid input + expect(screen.getByRole("button", { name: /^Create$/i })).not.toBeDisabled() + + // Now clear the input + await user.clear(input) + + // Try to submit + const createButton = screen.getByRole("button", { name: /^Create$/i }) + + // Button should be disabled with empty input + expect(createButton).toBeDisabled() + }) + + test("shows error for bucket name less than 3 characters", async () => { + const user = userEvent.setup() + renderModal() + + const input = screen.getByLabelText(/Bucket name/i) + await user.type(input, "ab") + + const createButton = screen.getByRole("button", { name: /^Create$/i }) + await user.click(createButton) + + await waitFor(() => { + expect(screen.getByText(/at least 3 characters/i)).toBeInTheDocument() + }) + }) + + test("shows error for bucket name more than 63 characters", async () => { + const user = userEvent.setup() + renderModal() + + const input = screen.getByLabelText(/Bucket name/i) + const longName = "a".repeat(64) + await user.type(input, longName) + + const createButton = screen.getByRole("button", { name: /^Create$/i }) + await user.click(createButton) + + await waitFor(() => { + expect(screen.getByText(/63 characters or fewer/i)).toBeInTheDocument() + }) + }) + }) + + describe("Invalid bucket names - Character validation", () => { + test("shows error for uppercase letters", async () => { + const user = userEvent.setup() + renderModal() + + const input = screen.getByLabelText(/Bucket name/i) + await user.type(input, "MyBucket") + + const createButton = screen.getByRole("button", { name: /^Create$/i }) + await user.click(createButton) + + await waitFor(() => { + const errorText = screen.getByText(/only lowercase letters/i, { + selector: ".juno-form-hint-error", + }) + expect(errorText).toBeInTheDocument() + }) + }) + + test("shows error for special characters", async () => { + const user = userEvent.setup() + renderModal() + + const input = screen.getByLabelText(/Bucket name/i) + await user.type(input, "my_bucket") + + const createButton = screen.getByRole("button", { name: /^Create$/i }) + await user.click(createButton) + + await waitFor(() => { + const errorText = screen.getByText(/only lowercase letters/i, { + selector: ".juno-form-hint-error", + }) + expect(errorText).toBeInTheDocument() + }) + }) + + test("shows error for spaces", async () => { + const user = userEvent.setup() + renderModal() + + const input = screen.getByLabelText(/Bucket name/i) + await user.type(input, "my bucket") + + const createButton = screen.getByRole("button", { name: /^Create$/i }) + await user.click(createButton) + + await waitFor(() => { + const errorText = screen.getByText(/only lowercase letters/i, { + selector: ".juno-form-hint-error", + }) + expect(errorText).toBeInTheDocument() + }) + }) + + test("shows error for starting with hyphen", async () => { + const user = userEvent.setup() + renderModal() + + const input = screen.getByLabelText(/Bucket name/i) + await user.type(input, "-mybucket") + + const createButton = screen.getByRole("button", { name: /^Create$/i }) + await user.click(createButton) + + await waitFor(() => { + expect(screen.getByText(/start and end with a letter or number/i)).toBeInTheDocument() + }) + }) + + test("shows error for ending with hyphen", async () => { + const user = userEvent.setup() + renderModal() + + const input = screen.getByLabelText(/Bucket name/i) + await user.type(input, "mybucket-") + + const createButton = screen.getByRole("button", { name: /^Create$/i }) + await user.click(createButton) + + await waitFor(() => { + expect(screen.getByText(/start and end with a letter or number/i)).toBeInTheDocument() + }) + }) + + test("shows error for starting with period", async () => { + const user = userEvent.setup() + renderModal() + + const input = screen.getByLabelText(/Bucket name/i) + await user.type(input, ".mybucket") + + const createButton = screen.getByRole("button", { name: /^Create$/i }) + await user.click(createButton) + + await waitFor(() => { + expect(screen.getByText(/start and end with a letter or number/i)).toBeInTheDocument() + }) + }) + + test("shows error for ending with period", async () => { + const user = userEvent.setup() + renderModal() + + const input = screen.getByLabelText(/Bucket name/i) + await user.type(input, "mybucket.") + + const createButton = screen.getByRole("button", { name: /^Create$/i }) + await user.click(createButton) + + await waitFor(() => { + expect(screen.getByText(/start and end with a letter or number/i)).toBeInTheDocument() + }) + }) + + test("shows error for consecutive periods", async () => { + const user = userEvent.setup() + renderModal() + + const input = screen.getByLabelText(/Bucket name/i) + await user.type(input, "my..bucket") + + const createButton = screen.getByRole("button", { name: /^Create$/i }) + await user.click(createButton) + + await waitFor(() => { + expect(screen.getByText(/not contain consecutive periods/i)).toBeInTheDocument() + }) + }) + }) + + describe("Invalid bucket names - IP address format", () => { + test("shows error for IP address format", async () => { + const user = userEvent.setup() + renderModal() + + const input = screen.getByLabelText(/Bucket name/i) + await user.type(input, "192.168.1.1") + + const createButton = screen.getByRole("button", { name: /^Create$/i }) + await user.click(createButton) + + await waitFor(() => { + expect(screen.getByText(/not be formatted as an IP address/i)).toBeInTheDocument() + }) + }) + }) + + describe("Invalid bucket names - Reserved prefixes", () => { + test("shows error for xn-- prefix", async () => { + const user = userEvent.setup() + renderModal() + + const input = screen.getByLabelText(/Bucket name/i) + await user.type(input, "xn--mybucket") + + const createButton = screen.getByRole("button", { name: /^Create$/i }) + await user.click(createButton) + + await waitFor(() => { + expect(screen.getByText(/not start with reserved prefix "xn--"/i)).toBeInTheDocument() + }) + }) + + test("shows error for sthree- prefix", async () => { + const user = userEvent.setup() + renderModal() + + const input = screen.getByLabelText(/Bucket name/i) + await user.type(input, "sthree-mybucket") + + const createButton = screen.getByRole("button", { name: /^Create$/i }) + await user.click(createButton) + + await waitFor(() => { + expect(screen.getByText(/not start with reserved prefix "sthree-"/i)).toBeInTheDocument() + }) + }) + + test("shows error for amzn-s3-demo- prefix", async () => { + const user = userEvent.setup() + renderModal() + + const input = screen.getByLabelText(/Bucket name/i) + await user.type(input, "amzn-s3-demo-bucket") + + const createButton = screen.getByRole("button", { name: /^Create$/i }) + await user.click(createButton) + + await waitFor(() => { + expect(screen.getByText(/not start with reserved prefix "amzn-s3-demo-"/i)).toBeInTheDocument() + }) + }) + }) + + describe("Invalid bucket names - Reserved suffixes", () => { + test("shows error for -s3alias suffix", async () => { + const user = userEvent.setup() + renderModal() + + const input = screen.getByLabelText(/Bucket name/i) + await user.type(input, "mybucket-s3alias") + + const createButton = screen.getByRole("button", { name: /^Create$/i }) + await user.click(createButton) + + await waitFor(() => { + expect(screen.getByText(/not end with reserved suffix "-s3alias"/i)).toBeInTheDocument() + }) + }) + + test("shows error for --ol-s3 suffix", async () => { + const user = userEvent.setup() + renderModal() + + const input = screen.getByLabelText(/Bucket name/i) + await user.type(input, "mybucket--ol-s3") + + const createButton = screen.getByRole("button", { name: /^Create$/i }) + await user.click(createButton) + + await waitFor(() => { + expect(screen.getByText(/not end with reserved suffix "--ol-s3"/i)).toBeInTheDocument() + }) + }) + }) + + describe("Bucket creation", () => { + test("calls mutation with correct parameters on submit", async () => { + const user = userEvent.setup() + renderModal() + + const input = screen.getByLabelText(/Bucket name/i) + await user.type(input, "my-test-bucket") + + const createButton = screen.getByRole("button", { name: /^Create$/i }) + await user.click(createButton) + + await waitFor(() => { + expect(mockMutate).toHaveBeenCalledWith({ + project_id: mockProjectId, + bucketName: "my-test-bucket", + }) + }) + }) + + test("trims whitespace from bucket name", async () => { + const user = userEvent.setup() + renderModal() + + const input = screen.getByLabelText(/Bucket name/i) + await user.type(input, " my-bucket ") + + const createButton = screen.getByRole("button", { name: /^Create$/i }) + await user.click(createButton) + + await waitFor(() => { + expect(mockMutate).toHaveBeenCalledWith({ + project_id: mockProjectId, + bucketName: "my-bucket", + }) + }) + }) + + test("submits on Enter key press", async () => { + const user = userEvent.setup() + renderModal() + + const input = screen.getByLabelText(/Bucket name/i) + await user.type(input, "my-bucket{Enter}") + + await waitFor(() => { + expect(mockMutate).toHaveBeenCalledWith({ + project_id: mockProjectId, + bucketName: "my-bucket", + }) + }) + }) + + test("disables input and button while creating", () => { + mockState.isPending = true + renderModal() + + expect(screen.getByLabelText(/Bucket name/i)).toBeDisabled() + expect(screen.getByRole("button", { name: /^Create$/i })).toBeDisabled() + }) + }) + + describe("Success handling", () => { + test("calls onSuccess with bucket name", async () => { + const user = userEvent.setup() + const mockOnSuccess = vi.fn() + renderModal({ onSuccess: mockOnSuccess }) + + const input = screen.getByLabelText(/Bucket name/i) + await user.type(input, "my-bucket") + + const createButton = screen.getByRole("button", { name: /^Create$/i }) + await user.click(createButton) + + await waitFor(() => { + expect(mockOnSuccess).toHaveBeenCalledWith("my-bucket") + }) + }) + + test("invalidates containers query on success", async () => { + const user = userEvent.setup() + renderModal() + + const input = screen.getByLabelText(/Bucket name/i) + await user.type(input, "my-bucket") + + const createButton = screen.getByRole("button", { name: /^Create$/i }) + await user.click(createButton) + + await waitFor(() => { + expect(mockInvalidate).toHaveBeenCalledTimes(1) + }) + }) + + test("closes modal on success", async () => { + const user = userEvent.setup() + const mockOnClose = vi.fn() + renderModal({ onClose: mockOnClose }) + + const input = screen.getByLabelText(/Bucket name/i) + await user.type(input, "my-bucket") + + const createButton = screen.getByRole("button", { name: /^Create$/i }) + await user.click(createButton) + + await waitFor(() => { + expect(mockOnClose).toHaveBeenCalledTimes(1) + }) + }) + + test("resets mutation state on close", async () => { + const user = userEvent.setup() + renderModal() + + const input = screen.getByLabelText(/Bucket name/i) + await user.type(input, "my-bucket") + + const createButton = screen.getByRole("button", { name: /^Create$/i }) + await user.click(createButton) + + await waitFor(() => { + expect(mockReset).toHaveBeenCalledTimes(1) + }) + }) + }) + + describe("Error handling", () => { + test("calls onError with bucket name and error message", async () => { + const user = userEvent.setup() + const mockOnError = vi.fn() + mockState.mutationError = "Bucket already exists" + renderModal({ onError: mockOnError }) + + const input = screen.getByLabelText(/Bucket name/i) + await user.type(input, "existing-bucket") + + const createButton = screen.getByRole("button", { name: /^Create$/i }) + await user.click(createButton) + + await waitFor(() => { + expect(mockOnError).toHaveBeenCalledWith("existing-bucket", "Bucket already exists") + }) + }) + + test("closes modal on error", async () => { + const user = userEvent.setup() + const mockOnClose = vi.fn() + mockState.mutationError = "Creation failed" + renderModal({ onClose: mockOnClose }) + + const input = screen.getByLabelText(/Bucket name/i) + await user.type(input, "my-bucket") + + const createButton = screen.getByRole("button", { name: /^Create$/i }) + await user.click(createButton) + + await waitFor(() => { + expect(mockOnClose).toHaveBeenCalledTimes(1) + }) + }) + }) + + describe("Modal close behavior", () => { + test("closes modal when Cancel button is clicked", async () => { + const user = userEvent.setup() + const mockOnClose = vi.fn() + renderModal({ onClose: mockOnClose }) + + const cancelButton = screen.getByRole("button", { name: /Cancel/i }) + await user.click(cancelButton) + + expect(mockOnClose).toHaveBeenCalledTimes(1) + }) + + test("clears input when modal closes", async () => { + const user = userEvent.setup() + const mockOnClose = vi.fn() + renderModal({ onClose: mockOnClose }) + + const input = screen.getByLabelText(/Bucket name/i) + await user.type(input, "my-bucket") + + const cancelButton = screen.getByRole("button", { name: /Cancel/i }) + await user.click(cancelButton) + + expect(mockOnClose).toHaveBeenCalled() + }) + + test("clears error when modal closes", async () => { + const user = userEvent.setup() + const mockOnClose = vi.fn() + renderModal({ onClose: mockOnClose }) + + const input = screen.getByLabelText(/Bucket name/i) + await user.type(input, "ab") + + const createButton = screen.getByRole("button", { name: /^Create$/i }) + await user.click(createButton) + + await waitFor(() => { + expect(screen.getByText(/at least 3 characters/i)).toBeInTheDocument() + }) + + const cancelButton = screen.getByRole("button", { name: /Cancel/i }) + await user.click(cancelButton) + + expect(mockOnClose).toHaveBeenCalled() + }) + }) + + describe("Real-time validation", () => { + test("clears error when user fixes invalid input", async () => { + const user = userEvent.setup() + renderModal() + + const input = screen.getByLabelText(/Bucket name/i) + await user.type(input, "ab") + + const createButton = screen.getByRole("button", { name: /^Create$/i }) + await user.click(createButton) + + await waitFor(() => { + expect(screen.getByText(/at least 3 characters/i)).toBeInTheDocument() + }) + + await user.type(input, "c") + + await waitFor(() => { + expect(screen.queryByText(/at least 3 characters/i)).not.toBeInTheDocument() + }) + }) + + test("shows error immediately when user types invalid character after initial validation", async () => { + const user = userEvent.setup() + renderModal() + + const input = screen.getByLabelText(/Bucket name/i) + await user.type(input, "ab") + + const createButton = screen.getByRole("button", { name: /^Create$/i }) + await user.click(createButton) + + await waitFor(() => { + expect(screen.getByText(/at least 3 characters/i)).toBeInTheDocument() + }) + + await user.type(input, "C") + + await waitFor(() => { + const errorText = screen.getByText(/only lowercase letters/i, { + selector: ".juno-form-hint-error", + }) + expect(errorText).toBeInTheDocument() + }) + }) + }) +}) diff --git a/apps/aurora-portal/src/client/routes/_auth/projects/$projectId/storage/-components/Ceph/Containers/CreateBucketModal.tsx b/apps/aurora-portal/src/client/routes/_auth/projects/$projectId/storage/-components/Ceph/Containers/CreateBucketModal.tsx new file mode 100644 index 000000000..4e1f6fe10 --- /dev/null +++ b/apps/aurora-portal/src/client/routes/_auth/projects/$projectId/storage/-components/Ceph/Containers/CreateBucketModal.tsx @@ -0,0 +1,165 @@ +import { useState } from "react" +import { Trans, useLingui } from "@lingui/react/macro" +import { trpcReact } from "@/client/trpcClient" +import { Modal, TextInput, Stack, Message } from "@cloudoperators/juno-ui-components" +import { useProjectId } from "@/client/hooks/useProjectId" + +interface CreateBucketModalProps { + isOpen: boolean + onClose: () => void + onSuccess?: (bucketName: string) => void + onError?: (bucketName: string, errorMessage: string) => void +} + +// S3 bucket naming validation patterns +const S3_BUCKET_NAME_REGEX = /^[a-z0-9][a-z0-9.-]*[a-z0-9]$/ +const IP_ADDRESS_REGEX = /^(\d{1,3}\.){3}\d{1,3}$/ +const RESERVED_PREFIXES = ["xn--", "sthree-", "amzn-s3-demo-"] +const RESERVED_SUFFIXES = ["-s3alias", "--ol-s3", ".mrap", "--x-s3", "--table-s3"] + +export const CreateBucketModal = ({ isOpen, onClose, onSuccess, onError }: CreateBucketModalProps) => { + const { t } = useLingui() + const projectId = useProjectId() + const [bucketName, setBucketName] = useState("") + const [nameError, setNameError] = useState(null) + + const utils = trpcReact.useUtils() + + const createBucketMutation = trpcReact.storage.ceph.containers.create.useMutation({ + onSuccess: () => { + utils.storage.ceph.containers.list.invalidate() + const name = bucketName.trim() + onSuccess?.(name) + }, + onError: (error) => { + onError?.(bucketName.trim(), error.message) + }, + onSettled: () => { + handleClose() + }, + }) + + const handleClose = () => { + setBucketName("") + setNameError(null) + createBucketMutation.reset() + onClose() + } + + const validateName = (name: string): boolean => { + const trimmed = name.trim() + + // Length validation + if (!trimmed) { + setNameError(t`Bucket name is required`) + return false + } + if (trimmed.length < 3) { + setNameError(t`Bucket name must be at least 3 characters`) + return false + } + if (trimmed.length > 63) { + setNameError(t`Bucket name must be 63 characters or fewer`) + return false + } + + // Character set validation (lowercase letters, numbers, periods, hyphens) + if (!S3_BUCKET_NAME_REGEX.test(trimmed)) { + setNameError(t`Bucket name must contain only lowercase letters, numbers, periods, and hyphens`) + return false + } + + // Must start and end with letter or number (already covered by regex, but explicit message) + if (!/^[a-z0-9]/.test(trimmed) || !/[a-z0-9]$/.test(trimmed)) { + setNameError(t`Bucket name must start and end with a letter or number`) + return false + } + + // No consecutive periods + if (trimmed.includes("..")) { + setNameError(t`Bucket name must not contain consecutive periods`) + return false + } + + // Not formatted as IP address + if (IP_ADDRESS_REGEX.test(trimmed)) { + setNameError(t`Bucket name must not be formatted as an IP address`) + return false + } + + // Check reserved prefixes + for (const prefix of RESERVED_PREFIXES) { + if (trimmed.startsWith(prefix)) { + setNameError(t`Bucket name must not start with reserved prefix "${prefix}"`) + return false + } + } + + // Check reserved suffixes + for (const suffix of RESERVED_SUFFIXES) { + if (trimmed.endsWith(suffix)) { + setNameError(t`Bucket name must not end with reserved suffix "${suffix}"`) + return false + } + } + + setNameError(null) + return true + } + + const handleNameChange = (e: React.ChangeEvent) => { + const value = e.target.value + setBucketName(value) + if (nameError) validateName(value) + } + + const handleSubmit = () => { + if (!validateName(bucketName)) return + createBucketMutation.mutate({ + project_id: projectId, + bucketName: bucketName.trim(), + }) + } + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === "Enter") { + handleSubmit() + } + } + + if (!isOpen) return null + + return ( + + + + + S3 bucket names must be 3-63 characters long and contain only lowercase letters, numbers, periods, and + hyphens. They must start and end with a letter or number, and be globally unique within the cluster. + + + + + + ) +} diff --git a/apps/aurora-portal/src/client/routes/_auth/projects/$projectId/storage/-components/Ceph/Containers/CredentialPrompt.test.tsx b/apps/aurora-portal/src/client/routes/_auth/projects/$projectId/storage/-components/Ceph/Containers/CredentialPrompt.test.tsx new file mode 100644 index 000000000..f8a3debf0 --- /dev/null +++ b/apps/aurora-portal/src/client/routes/_auth/projects/$projectId/storage/-components/Ceph/Containers/CredentialPrompt.test.tsx @@ -0,0 +1,262 @@ +import { describe, test, expect, vi, beforeEach } from "vitest" +import { render, screen, waitFor, act } from "@testing-library/react" +import userEvent from "@testing-library/user-event" +import { PortalProvider } from "@cloudoperators/juno-ui-components" +import { i18n } from "@lingui/core" +import { I18nProvider } from "@lingui/react" +import { CredentialPrompt } from "./CredentialPrompt" + +// ─── Mock useProjectId ──────────────────────────────────────────────────────── + +const mockProjectId = "test-project-123" + +vi.mock("@/client/hooks/useProjectId", () => ({ + useProjectId: () => mockProjectId, +})) + +// ─── tRPC mock ──────────────────────────────────────────────────────────────── + +type MutationOptions = { + onSuccess?: () => void + onError?: (error: { message: string }) => void +} + +const { mockInvalidate, mockMutate, mockState } = vi.hoisted(() => { + const mockState = { + mutationError: null as string | null, + isPending: false, + capturedOptions: {} as MutationOptions, + } + const mockMutate = vi.fn().mockImplementation(() => { + if (mockState.mutationError) { + mockState.capturedOptions.onError?.({ message: mockState.mutationError }) + } else { + mockState.capturedOptions.onSuccess?.() + } + }) + return { mockInvalidate: vi.fn(), mockMutate, mockState } +}) + +vi.mock("@/client/trpcClient", () => ({ + trpcReact: { + useUtils: () => ({ + storage: { + ceph: { + ec2Credentials: { list: { invalidate: mockInvalidate } }, + }, + }, + }), + storage: { + ceph: { + ec2Credentials: { + create: { + useMutation: (options: MutationOptions) => { + mockState.capturedOptions = options ?? {} + return { mutate: mockMutate, isPending: mockState.isPending } + }, + }, + }, + }, + }, + }, +})) + +// ─── Render helper ──────────────────────────────────────────────────────────── + +const renderCredentialPrompt = ({ onSuccess = vi.fn() }: { onSuccess?: () => void } = {}) => + render( + + + + + + ) + +// ─── Tests ──────────────────────────────────────────────────────────────────── + +describe("CredentialPrompt", () => { + beforeEach(async () => { + vi.clearAllMocks() + mockState.mutationError = null + mockState.capturedOptions = {} + mockState.isPending = false + await act(async () => { + i18n.activate("en") + }) + }) + + describe("Rendering", () => { + test("renders setup prompt with correct title", () => { + renderCredentialPrompt() + expect(screen.getByText("S3 Object Storage — Setup Required")).toBeInTheDocument() + }) + + test("renders explanatory text about EC2 credentials", () => { + renderCredentialPrompt() + expect( + screen.getByText(/S3 Object Storage requires EC2 credentials \(access key \+ secret key\)/) + ).toBeInTheDocument() + expect(screen.getByText(/You need to create credentials before accessing S3 resources/)).toBeInTheDocument() + }) + + test("renders Create Credential button", () => { + renderCredentialPrompt() + expect(screen.getByRole("button", { name: /Create Credential/i })).toBeInTheDocument() + }) + + test("button is not disabled by default", () => { + renderCredentialPrompt() + expect(screen.getByRole("button", { name: /Create Credential/i })).not.toBeDisabled() + }) + }) + + describe("Button interaction", () => { + test("calls mutation with project_id when clicked", async () => { + const user = userEvent.setup() + renderCredentialPrompt() + + const button = screen.getByRole("button", { name: /Create Credential/i }) + await user.click(button) + + expect(mockMutate).toHaveBeenCalledTimes(1) + expect(mockMutate).toHaveBeenCalledWith({ project_id: mockProjectId }) + }) + + test("shows Creating... text when mutation is pending", () => { + mockState.isPending = true + renderCredentialPrompt() + + expect(screen.getByRole("button", { name: /Creating.../i })).toBeInTheDocument() + expect(screen.queryByRole("button", { name: /Create Credential/i })).not.toBeInTheDocument() + }) + + test("disables button when mutation is pending", () => { + mockState.isPending = true + renderCredentialPrompt() + + expect(screen.getByRole("button", { name: /Creating.../i })).toBeDisabled() + }) + }) + + describe("Success handling", () => { + test("calls onSuccess callback when credential creation succeeds", async () => { + const user = userEvent.setup() + const mockOnSuccess = vi.fn() + renderCredentialPrompt({ onSuccess: mockOnSuccess }) + + const button = screen.getByRole("button", { name: /Create Credential/i }) + await user.click(button) + + await waitFor(() => { + expect(mockOnSuccess).toHaveBeenCalledTimes(1) + }) + }) + + test("invalidates ec2Credentials query when creation succeeds", async () => { + const user = userEvent.setup() + renderCredentialPrompt() + + const button = screen.getByRole("button", { name: /Create Credential/i }) + await user.click(button) + + await waitFor(() => { + expect(mockInvalidate).toHaveBeenCalledTimes(1) + }) + }) + }) + + describe("Error handling", () => { + test("shows error toast when credential creation fails", async () => { + const user = userEvent.setup() + mockState.mutationError = "Failed to create EC2 credential" + renderCredentialPrompt() + + const button = screen.getByRole("button", { name: /Create Credential/i }) + await user.click(button) + + await waitFor(() => { + expect(screen.getByText(/Failed to create credential:/)).toBeInTheDocument() + expect(screen.getByText(/Failed to create EC2 credential/)).toBeInTheDocument() + }) + }) + + test("error toast has error variant and auto-dismiss", async () => { + const user = userEvent.setup() + mockState.mutationError = "Permission denied" + renderCredentialPrompt() + + const button = screen.getByRole("button", { name: /Create Credential/i }) + await user.click(button) + + await waitFor(() => { + const toast = screen.getByText(/Failed to create credential:/).closest(".juno-toast") + expect(toast).toBeInTheDocument() + expect(toast).toHaveClass("juno-toast-error") + }) + }) + + test("does not call onSuccess callback when creation fails", async () => { + const user = userEvent.setup() + const mockOnSuccess = vi.fn() + mockState.mutationError = "Creation failed" + renderCredentialPrompt({ onSuccess: mockOnSuccess }) + + const button = screen.getByRole("button", { name: /Create Credential/i }) + await user.click(button) + + await waitFor(() => { + expect(screen.getByText(/Failed to create credential:/)).toBeInTheDocument() + }) + + expect(mockOnSuccess).not.toHaveBeenCalled() + }) + + test("shows different error messages based on error content", async () => { + const user = userEvent.setup() + mockState.mutationError = "Quota exceeded" + renderCredentialPrompt() + + const button = screen.getByRole("button", { name: /Create Credential/i }) + await user.click(button) + + await waitFor(() => { + expect(screen.getByText(/Quota exceeded/)).toBeInTheDocument() + }) + }) + + test("error toast can be dismissed", async () => { + const user = userEvent.setup() + mockState.mutationError = "Test error" + renderCredentialPrompt() + + const button = screen.getByRole("button", { name: /Create Credential/i }) + await user.click(button) + + await waitFor(() => { + expect(screen.getByText(/Failed to create credential:/)).toBeInTheDocument() + }) + + const closeButton = screen.getByRole("button", { name: /close/i }) + await user.click(closeButton) + + await waitFor(() => { + expect(screen.queryByText(/Failed to create credential:/)).not.toBeInTheDocument() + }) + }) + }) + + describe("Layout and styling", () => { + test("renders content in a centered vertical stack", () => { + const { container } = renderCredentialPrompt() + const stack = container.querySelector(".juno-stack") + expect(stack).toBeInTheDocument() + expect(stack).toHaveClass("jn:flex", "jn:flex-col") + }) + + test("title uses heading style", () => { + renderCredentialPrompt() + const title = screen.getByText("S3 Object Storage — Setup Required") + expect(title.tagName).toBe("H2") + }) + }) +}) diff --git a/apps/aurora-portal/src/client/routes/_auth/projects/$projectId/storage/-components/Ceph/Containers/DeleteBucketModal.test.tsx b/apps/aurora-portal/src/client/routes/_auth/projects/$projectId/storage/-components/Ceph/Containers/DeleteBucketModal.test.tsx new file mode 100644 index 000000000..1b7dc148d --- /dev/null +++ b/apps/aurora-portal/src/client/routes/_auth/projects/$projectId/storage/-components/Ceph/Containers/DeleteBucketModal.test.tsx @@ -0,0 +1,519 @@ +import { describe, test, expect, vi, beforeEach } from "vitest" +import { render, screen, waitFor, act } from "@testing-library/react" +import userEvent from "@testing-library/user-event" +import { PortalProvider } from "@cloudoperators/juno-ui-components" +import { i18n } from "@lingui/core" +import { I18nProvider } from "@lingui/react" +import { DeleteBucketModal } from "./DeleteBucketModal" +import type { Container } from "@/server/Storage/types/ceph" + +// ─── Mock useProjectId ──────────────────────────────────────────────────────── + +const mockProjectId = "test-project-123" + +vi.mock("@/client/hooks/useProjectId", () => ({ + useProjectId: () => mockProjectId, +})) + +// ─── Mock clipboard API ─────────────────────────────────────────────────────── + +const mockWriteText = vi.fn().mockResolvedValue(undefined) + +Object.assign(navigator, { + clipboard: { + writeText: mockWriteText, + }, +}) + +// ─── tRPC mock ──────────────────────────────────────────────────────────────── + +type DeleteMutationOptions = { + onSuccess?: () => void + onError?: (error: { message: string }) => void + onSettled?: () => void +} + +const { mockInvalidate, mockMutate, mockReset, mockState } = vi.hoisted(() => { + const mockState = { + mutationError: null as string | null, + isPending: false, + isLoading: false, + objectsData: { objects: [], folders: [], isTruncated: false }, + objectsError: null as string | null, + capturedOptions: {} as DeleteMutationOptions, + } + const mockMutate = vi.fn().mockImplementation((_variables: unknown, options?: DeleteMutationOptions) => { + // Merge options from both useMutation and mutate call + const mergedOptions = { ...mockState.capturedOptions, ...options } + if (mockState.mutationError) { + mergedOptions.onError?.({ message: mockState.mutationError }) + } else { + mergedOptions.onSuccess?.() + } + mergedOptions.onSettled?.() + }) + return { + mockInvalidate: vi.fn(), + mockMutate, + mockReset: vi.fn(), + mockState, + } +}) + +vi.mock("@/client/trpcClient", () => ({ + trpcReact: { + useUtils: () => ({ + storage: { + ceph: { + containers: { list: { invalidate: mockInvalidate } }, + }, + }, + }), + storage: { + ceph: { + objects: { + list: { + useQuery: (_params: unknown, options: { enabled: boolean }) => { + if (!options.enabled) { + return { data: undefined, isLoading: false, error: null } + } + return { + data: mockState.objectsData, + isLoading: mockState.isLoading, + error: mockState.objectsError ? { message: mockState.objectsError } : null, + } + }, + }, + }, + containers: { + delete: { + useMutation: (options: DeleteMutationOptions) => { + mockState.capturedOptions = options ?? {} + return { mutate: mockMutate, isPending: mockState.isPending, reset: mockReset } + }, + }, + }, + }, + }, + }, +})) + +// ─── Fixtures ───────────────────────────────────────────────────────────────── + +const mockEmptyBucket: Container = { + name: "my-test-bucket", + creationDate: "2024-01-15T10:00:00Z", + count: 0, + bytes: 0, +} + +const mockNonEmptyBucket: Container = { + name: "bucket-with-files", + creationDate: "2024-01-15T10:00:00Z", + count: 5, + bytes: 1024, +} + +// ─── Render helper ──────────────────────────────────────────────────────────── + +const renderModal = ({ + isOpen = true, + bucket = mockEmptyBucket, + onClose = vi.fn(), + onSuccess = vi.fn(), + onError = vi.fn(), +}: { + isOpen?: boolean + bucket?: Container | null + onClose?: () => void + onSuccess?: (bucketName: string) => void + onError?: (bucketName: string, errorMessage: string) => void +} = {}) => + render( + + + + + + ) + +// ─── Tests ──────────────────────────────────────────────────────────────────── + +describe("DeleteBucketModal", () => { + beforeEach(async () => { + vi.clearAllMocks() + mockState.mutationError = null + mockState.capturedOptions = {} + mockState.isPending = false + mockState.isLoading = false + mockState.objectsData = { objects: [], folders: [], isTruncated: false } + mockState.objectsError = null + await act(async () => { + i18n.activate("en") + }) + }) + + describe("Visibility", () => { + test("does not render when isOpen is false", () => { + renderModal({ isOpen: false }) + expect(screen.queryByText(/Delete Bucket/i)).not.toBeInTheDocument() + }) + + test("does not render when bucket is null", () => { + renderModal({ bucket: null }) + expect(screen.queryByText(/Delete Bucket/i)).not.toBeInTheDocument() + }) + + test("renders when isOpen is true and bucket is provided", () => { + renderModal() + expect(screen.getByRole("heading", { name: "Delete Bucket" })).toBeInTheDocument() + }) + }) + + describe("UI elements for empty bucket", () => { + test("renders modal title", () => { + renderModal() + expect(screen.getByRole("heading", { name: "Delete Bucket" })).toBeInTheDocument() + }) + + test("renders warning message", () => { + renderModal() + expect(screen.getByText(/This action is irreversible/)).toBeInTheDocument() + expect(screen.getByText(/Deleting a bucket permanently removes it/)).toBeInTheDocument() + }) + + test("displays bucket name to delete", () => { + renderModal() + expect(screen.getByText("Bucket to delete:")).toBeInTheDocument() + expect(screen.getByText(mockEmptyBucket.name)).toBeInTheDocument() + }) + + test("renders confirmation input", () => { + renderModal() + expect(screen.getByLabelText(/Type the bucket name to confirm/i)).toBeInTheDocument() + }) + + test("confirmation input has bucket name as placeholder", () => { + renderModal() + expect(screen.getByPlaceholderText(mockEmptyBucket.name)).toBeInTheDocument() + }) + + test("renders Delete Bucket button", () => { + renderModal() + expect(screen.getByRole("button", { name: /^Delete Bucket$/i })).toBeInTheDocument() + }) + + test("renders Cancel button", () => { + renderModal() + expect(screen.getByRole("button", { name: /Cancel/i })).toBeInTheDocument() + }) + + test("renders Copy button", () => { + renderModal() + expect(screen.getByRole("button", { name: /Copy/i })).toBeInTheDocument() + }) + + test("Delete button is disabled when confirmation name is empty", () => { + renderModal() + expect(screen.getByRole("button", { name: /^Delete Bucket$/i })).toBeDisabled() + }) + }) + + describe("Loading state", () => { + test("shows loading spinner when checking bucket contents", () => { + mockState.isLoading = true + renderModal() + + expect(screen.getByText(/Checking bucket contents.../)).toBeInTheDocument() + // Spinner is present in the DOM + const spinnerContainer = screen.getByText(/Checking bucket contents.../).closest(".juno-stack") + expect(spinnerContainer).toBeInTheDocument() + }) + + test("does not show warning message while loading", () => { + mockState.isLoading = true + renderModal() + + expect(screen.queryByText(/This action is irreversible/)).not.toBeInTheDocument() + }) + + test("disables Delete button while loading", () => { + mockState.isLoading = true + renderModal() + + expect(screen.getByRole("button", { name: /^Delete Bucket$/i })).toBeDisabled() + }) + }) + + describe("Non-empty bucket", () => { + test("shows error message when bucket contains objects", () => { + mockState.objectsData = { objects: [{ key: "file.txt" }] as never, folders: [], isTruncated: false } + renderModal({ bucket: mockNonEmptyBucket }) + + expect(screen.getByText(/This bucket contains 1 object and cannot be deleted/)).toBeInTheDocument() + expect(screen.getByText(/Delete all objects first/)).toBeInTheDocument() + }) + + test("shows plural form for multiple objects", () => { + mockState.objectsData = { + objects: [{ key: "file1.txt" }, { key: "file2.txt" }, { key: "file3.txt" }] as never, + folders: [], + isTruncated: false, + } + renderModal({ bucket: mockNonEmptyBucket }) + + expect(screen.getByText(/This bucket contains 3 objects and cannot be deleted/)).toBeInTheDocument() + }) + + test("does not show confirmation input for non-empty bucket", () => { + mockState.objectsData = { objects: [{ key: "file.txt" }] as never, folders: [], isTruncated: false } + renderModal({ bucket: mockNonEmptyBucket }) + + expect(screen.queryByLabelText(/Type the bucket name to confirm/i)).not.toBeInTheDocument() + }) + + test("disables Delete button for non-empty bucket", () => { + mockState.objectsData = { objects: [{ key: "file.txt" }] as never, folders: [], isTruncated: false } + renderModal({ bucket: mockNonEmptyBucket }) + + expect(screen.getByRole("button", { name: /^Delete Bucket$/i })).toBeDisabled() + }) + }) + + describe("Objects query error", () => { + test("shows error message when objects query fails", () => { + mockState.objectsError = "Failed to fetch objects" + renderModal() + + expect(screen.getByText(/Failed to check bucket contents:/)).toBeInTheDocument() + expect(screen.getByText(/Failed to fetch objects/)).toBeInTheDocument() + }) + + test("disables Delete button when objects query fails", () => { + mockState.objectsError = "Failed to fetch objects" + renderModal() + + expect(screen.getByRole("button", { name: /^Delete Bucket$/i })).toBeDisabled() + }) + }) + + describe("Copy bucket name", () => { + test("shows Copy button", () => { + renderModal() + expect(screen.getByRole("button", { name: /Copy/i })).toBeInTheDocument() + }) + + test("displays bucket name in a code block", () => { + renderModal() + const codeBlock = screen.getByText(mockEmptyBucket.name).closest("div.font-mono") + expect(codeBlock).toBeInTheDocument() + }) + }) + + describe("Name confirmation validation", () => { + test("Delete button disabled when name does not match", async () => { + const user = userEvent.setup({ delay: null }) + renderModal() + + const input = screen.getByLabelText(/Type the bucket name to confirm/i) + await user.clear(input) + await user.type(input, "wrong-name") + + // Button should stay disabled + expect(screen.getByRole("button", { name: /^Delete Bucket$/i })).toBeDisabled() + }) + + test("enables Delete button when name matches exactly", async () => { + const user = userEvent.setup({ delay: null }) + renderModal() + + const input = screen.getByLabelText(/Type the bucket name to confirm/i) + await user.clear(input) + await user.type(input, mockEmptyBucket.name) + + await waitFor( + () => { + expect(screen.getByRole("button", { name: /^Delete Bucket$/i })).not.toBeDisabled() + }, + { timeout: 3000 } + ) + }) + + test("Delete button disabled with wrong name", async () => { + const user = userEvent.setup({ delay: null }) + renderModal() + + const input = screen.getByLabelText(/Type the bucket name to confirm/i) + await user.clear(input) + await user.type(input, "wrong") + + expect(screen.getByRole("button", { name: /^Delete Bucket$/i })).toBeDisabled() + }) + }) + + describe("Bucket deletion", () => { + test("calls mutation with correct parameters", async () => { + const user = userEvent.setup({ delay: null }) + renderModal() + + const input = screen.getByLabelText(/Type the bucket name to confirm/i) + await user.clear(input) + await user.type(input, mockEmptyBucket.name) + + const deleteButton = screen.getByRole("button", { name: /^Delete Bucket$/i }) + await user.click(deleteButton) + + await waitFor( + () => { + expect(mockMutate).toHaveBeenCalledWith( + { + project_id: mockProjectId, + bucketName: mockEmptyBucket.name, + }, + expect.objectContaining({ + onSuccess: expect.any(Function), + onError: expect.any(Function), + }) + ) + }, + { timeout: 3000 } + ) + }) + + test("disables input and button while deleting", () => { + mockState.isPending = true + renderModal() + + expect(screen.getByLabelText(/Type the bucket name to confirm/i)).toBeDisabled() + expect(screen.getByRole("button", { name: /^Delete Bucket$/i })).toBeDisabled() + }) + }) + + describe("Success handling", () => { + test("calls onSuccess with bucket name", async () => { + const user = userEvent.setup({ delay: null }) + const mockOnSuccess = vi.fn() + renderModal({ onSuccess: mockOnSuccess }) + + const input = screen.getByLabelText(/Type the bucket name to confirm/i) + await user.clear(input) + await user.type(input, mockEmptyBucket.name) + + const deleteButton = screen.getByRole("button", { name: /^Delete Bucket$/i }) + await user.click(deleteButton) + + await waitFor( + () => { + expect(mockOnSuccess).toHaveBeenCalledWith(mockEmptyBucket.name) + }, + { timeout: 3000 } + ) + }) + + test("invalidates containers query on success", async () => { + const user = userEvent.setup({ delay: null }) + renderModal() + + const input = screen.getByLabelText(/Type the bucket name to confirm/i) + await user.clear(input) + await user.type(input, mockEmptyBucket.name) + + const deleteButton = screen.getByRole("button", { name: /^Delete Bucket$/i }) + await user.click(deleteButton) + + await waitFor( + () => { + expect(mockInvalidate).toHaveBeenCalledTimes(1) + }, + { timeout: 3000 } + ) + }) + + test("closes modal on success", async () => { + const user = userEvent.setup({ delay: null }) + const mockOnClose = vi.fn() + renderModal({ onClose: mockOnClose }) + + const input = screen.getByLabelText(/Type the bucket name to confirm/i) + await user.clear(input) + await user.type(input, mockEmptyBucket.name) + + const deleteButton = screen.getByRole("button", { name: /^Delete Bucket$/i }) + await user.click(deleteButton) + + await waitFor( + () => { + expect(mockOnClose).toHaveBeenCalledTimes(1) + }, + { timeout: 3000 } + ) + }) + }) + + describe("Error handling", () => { + test("calls onError with bucket name and error message", async () => { + const user = userEvent.setup({ delay: null }) + const mockOnError = vi.fn() + mockState.mutationError = "Bucket not found" + renderModal({ onError: mockOnError }) + + const input = screen.getByLabelText(/Type the bucket name to confirm/i) + await user.clear(input) + await user.type(input, mockEmptyBucket.name) + + const deleteButton = screen.getByRole("button", { name: /^Delete Bucket$/i }) + await user.click(deleteButton) + + await waitFor( + () => { + expect(mockOnError).toHaveBeenCalledWith(mockEmptyBucket.name, "Bucket not found") + }, + { timeout: 3000 } + ) + }) + + test("closes modal on error", async () => { + const user = userEvent.setup({ delay: null }) + const mockOnClose = vi.fn() + mockState.mutationError = "Deletion failed" + renderModal({ onClose: mockOnClose }) + + const input = screen.getByLabelText(/Type the bucket name to confirm/i) + await user.clear(input) + await user.type(input, mockEmptyBucket.name) + + const deleteButton = screen.getByRole("button", { name: /^Delete Bucket$/i }) + await user.click(deleteButton) + + await waitFor( + () => { + expect(mockOnClose).toHaveBeenCalledTimes(1) + }, + { timeout: 3000 } + ) + }) + }) + + describe("Modal close behavior", () => { + test("closes modal when Cancel button is clicked", async () => { + const user = userEvent.setup({ delay: null }) + const mockOnClose = vi.fn() + renderModal({ onClose: mockOnClose }) + + const cancelButton = screen.getByRole("button", { name: /Cancel/i }) + await user.click(cancelButton) + + expect(mockOnClose).toHaveBeenCalledTimes(1) + }) + + test("resets mutation state when modal closes", async () => { + const user = userEvent.setup({ delay: null }) + renderModal() + + const cancelButton = screen.getByRole("button", { name: /Cancel/i }) + await user.click(cancelButton) + + expect(mockReset).toHaveBeenCalled() + }) + }) +}) diff --git a/apps/aurora-portal/src/client/routes/_auth/projects/$projectId/storage/-components/Ceph/Containers/DeleteBucketModal.tsx b/apps/aurora-portal/src/client/routes/_auth/projects/$projectId/storage/-components/Ceph/Containers/DeleteBucketModal.tsx new file mode 100644 index 000000000..31ed3d573 --- /dev/null +++ b/apps/aurora-portal/src/client/routes/_auth/projects/$projectId/storage/-components/Ceph/Containers/DeleteBucketModal.tsx @@ -0,0 +1,180 @@ +import { useState, useEffect } from "react" +import { Trans, useLingui, Plural } from "@lingui/react/macro" +import { trpcReact } from "@/client/trpcClient" +import { Modal, TextInput, Stack, Message, Spinner, Button } from "@cloudoperators/juno-ui-components" +import type { Container } from "@/server/Storage/types/ceph" +import { useProjectId } from "@/client/hooks/useProjectId" + +interface DeleteBucketModalProps { + isOpen: boolean + bucket: Container | null + onClose: () => void + onSuccess?: (bucketName: string) => void + onError?: (bucketName: string, errorMessage: string) => void +} + +export const DeleteBucketModal = ({ isOpen, bucket, onClose, onSuccess, onError }: DeleteBucketModalProps) => { + const { t } = useLingui() + const projectId = useProjectId() + const [confirmName, setConfirmName] = useState("") + const [nameError, setNameError] = useState(null) + const [copied, setCopied] = useState(false) + + const handleCopyName = () => { + if (!bucket) return + navigator.clipboard.writeText(bucket.name).then(() => { + setCopied(true) + setTimeout(() => setCopied(false), 2000) + }) + } + + const utils = trpcReact.useUtils() + + // Fetch actual objects to get accurate real-time state + const { + data: objects, + isLoading: isLoadingObjects, + error: objectsError, + } = trpcReact.storage.ceph.objects.list.useQuery( + { project_id: projectId ?? "", containerName: bucket?.name ?? "", maxKeys: 1 }, + { enabled: isOpen && bucket !== null } + ) + + const deleteBucketMutation = trpcReact.storage.ceph.containers.delete.useMutation({ + onSettled: () => { + utils.storage.ceph.containers.list.invalidate() + handleClose() + }, + }) + + useEffect(() => { + if (!isOpen) { + setConfirmName("") + setNameError(null) + deleteBucketMutation.reset() + } + }, [isOpen, bucket?.name]) + + const handleClose = () => { + setConfirmName("") + setNameError(null) + deleteBucketMutation.reset() + onClose() + } + + const handleConfirmNameChange = (e: React.ChangeEvent) => { + const value = e.target.value + setConfirmName(value) + if (nameError) setNameError(null) + } + + const handleSubmit = () => { + if (!bucket) return + if (objectsError) return + if (confirmName.trim() !== bucket.name) { + setNameError(t`Bucket name does not match`) + return + } + + // Capture bucket name before async operation to avoid dereferencing null bucket in callbacks + const bucketName = bucket.name + + deleteBucketMutation.mutate( + { project_id: projectId, bucketName }, + { + onSuccess: () => { + onSuccess?.(bucketName) + }, + onError: (error) => { + onError?.(bucketName, error.message) + }, + } + ) + } + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === "Enter") handleSubmit() + } + + if (!isOpen || !bucket) return null + + const actualObjectCount = objects?.objects?.length ?? 0 + const isNonEmpty = actualObjectCount > 0 + const errorMessage = objectsError?.message + + return ( + + + {isLoadingObjects ? ( + + + + Checking bucket contents... + + + ) : isNonEmpty ? ( + + + This bucket contains {actualObjectCount} {" "} + and cannot be deleted. Delete all objects first. + + + ) : ( + <> + + + This action is irreversible. Deleting a bucket permanently removes it and cannot be undone. The bucket + must be empty before deletion. + + + + +
+ + Bucket to delete: + +
+
{bucket.name}
+
+ + + + )} + + {objectsError && ( + + Failed to check bucket contents: {errorMessage} + + )} +
+
+ ) +} diff --git a/apps/aurora-portal/src/client/routes/_auth/projects/$projectId/storage/-components/Ceph/Containers/EmptyBucketModal.test.tsx b/apps/aurora-portal/src/client/routes/_auth/projects/$projectId/storage/-components/Ceph/Containers/EmptyBucketModal.test.tsx new file mode 100644 index 000000000..8328f4b87 --- /dev/null +++ b/apps/aurora-portal/src/client/routes/_auth/projects/$projectId/storage/-components/Ceph/Containers/EmptyBucketModal.test.tsx @@ -0,0 +1,482 @@ +import { describe, test, expect, vi, beforeEach } from "vitest" +import { render, screen, waitFor, act } from "@testing-library/react" +import userEvent from "@testing-library/user-event" +import { PortalProvider } from "@cloudoperators/juno-ui-components" +import { i18n } from "@lingui/core" +import { I18nProvider } from "@lingui/react" +import { EmptyBucketModal } from "./EmptyBucketModal" +import type { Container } from "@/server/Storage/types/ceph" + +// ─── Mock useProjectId ──────────────────────────────────────────────────────── + +const mockProjectId = "test-project-123" + +vi.mock("@/client/hooks/useProjectId", () => ({ + useProjectId: () => mockProjectId, +})) + +// ─── Mock clipboard API ─────────────────────────────────────────────────────── + +const mockWriteText = vi.fn().mockResolvedValue(undefined) + +Object.assign(navigator, { + clipboard: { + writeText: mockWriteText, + }, +}) + +// ─── tRPC mock ──────────────────────────────────────────────────────────────── + +type MutationOptions = { + onSuccess?: (deletedCount: number) => void + onError?: (error: { message: string }) => void + onSettled?: () => void +} + +const { mockInvalidate, mockMutate, mockReset, mockState } = vi.hoisted(() => { + const mockState = { + mutationError: null as string | null, + isPending: false, + capturedOptions: {} as MutationOptions, + } + const mockMutate = vi.fn().mockImplementation((_variables: unknown, options?: MutationOptions) => { + // Merge options from both useMutation and mutate call + const mergedOptions = { ...mockState.capturedOptions, ...options } + if (mockState.mutationError) { + mergedOptions.onError?.({ message: mockState.mutationError }) + } else { + mergedOptions.onSuccess?.(5) + } + mergedOptions.onSettled?.() + }) + return { + mockInvalidate: vi.fn(), + mockMutate, + mockReset: vi.fn(), + mockState, + } +}) + +vi.mock("@/client/trpcClient", () => ({ + trpcReact: { + useUtils: () => ({ + storage: { + ceph: { + containers: { list: { invalidate: mockInvalidate } }, + }, + }, + }), + storage: { + ceph: { + objects: { + deleteAll: { + useMutation: (options: MutationOptions) => { + mockState.capturedOptions = options ?? {} + return { mutate: mockMutate, isPending: mockState.isPending, reset: mockReset } + }, + }, + }, + }, + }, + }, +})) + +// ─── Fixtures ───────────────────────────────────────────────────────────────── + +const mockEmptyBucket: Container = { + name: "empty-bucket", + creationDate: "2024-01-15T10:00:00Z", + count: 0, + bytes: 0, +} + +const mockNonEmptyBucket: Container = { + name: "bucket-with-files", + creationDate: "2024-01-15T10:00:00Z", + count: 5, + bytes: 1024, +} + +const mockSingleObjectBucket: Container = { + name: "single-object-bucket", + creationDate: "2024-01-15T10:00:00Z", + count: 1, + bytes: 512, +} + +// ─── Render helper ──────────────────────────────────────────────────────────── + +const renderModal = ({ + isOpen = true, + bucket = mockNonEmptyBucket, + onClose = vi.fn(), + onSuccess = vi.fn(), + onError = vi.fn(), +}: { + isOpen?: boolean + bucket?: Container | null + onClose?: () => void + onSuccess?: (bucketName: string, deletedCount: number) => void + onError?: (bucketName: string, errorMessage: string) => void +} = {}) => + render( + + + + + + ) + +// ─── Tests ──────────────────────────────────────────────────────────────────── + +describe("EmptyBucketModal", () => { + beforeEach(async () => { + vi.clearAllMocks() + mockState.mutationError = null + mockState.capturedOptions = {} + mockState.isPending = false + await act(async () => { + i18n.activate("en") + }) + }) + + describe("Visibility", () => { + test("does not render when isOpen is false", () => { + renderModal({ isOpen: false }) + expect(screen.queryByText(/Empty Bucket/i)).not.toBeInTheDocument() + }) + + test("does not render when bucket is null", () => { + renderModal({ bucket: null }) + expect(screen.queryByText(/Empty Bucket/i)).not.toBeInTheDocument() + }) + + test("renders when isOpen is true and bucket is provided", () => { + renderModal() + expect(screen.getByRole("heading", { name: "Empty Bucket" })).toBeInTheDocument() + }) + }) + + describe("Empty bucket state", () => { + test("shows info message when bucket is already empty", () => { + renderModal({ bucket: mockEmptyBucket }) + expect(screen.getByText(/Nothing to do. Bucket is already empty./)).toBeInTheDocument() + }) + + test("shows Got it! button for empty bucket", () => { + renderModal({ bucket: mockEmptyBucket }) + expect(screen.getByRole("button", { name: /Got it!/i })).toBeInTheDocument() + }) + + test("does not show confirmation input for empty bucket", () => { + renderModal({ bucket: mockEmptyBucket }) + expect(screen.queryByLabelText(/Type the bucket name to confirm/i)).not.toBeInTheDocument() + }) + + test("closes modal when Got it! button is clicked", async () => { + const user = userEvent.setup({ delay: null }) + const mockOnClose = vi.fn() + renderModal({ bucket: mockEmptyBucket, onClose: mockOnClose }) + + const button = screen.getByRole("button", { name: /Got it!/i }) + await user.click(button) + + expect(mockOnClose).toHaveBeenCalledTimes(1) + }) + }) + + describe("Non-empty bucket UI", () => { + test("renders modal title", () => { + renderModal() + expect(screen.getByRole("heading", { name: "Empty Bucket" })).toBeInTheDocument() + }) + + test("shows warning message with object count (plural)", () => { + renderModal({ bucket: mockNonEmptyBucket }) + expect(screen.getByText(/Are you sure?/)).toBeInTheDocument() + expect(screen.getByText(/All 5 objects/)).toBeInTheDocument() + expect(screen.getByText(/will be permanently deleted/)).toBeInTheDocument() + }) + + test("shows warning message with object count (singular)", () => { + renderModal({ bucket: mockSingleObjectBucket }) + expect(screen.getByText(/All 1 object/)).toBeInTheDocument() + }) + + test("displays bucket name", () => { + renderModal() + expect(screen.getByText("Bucket to empty:")).toBeInTheDocument() + expect(screen.getByText(mockNonEmptyBucket.name)).toBeInTheDocument() + }) + + test("renders confirmation input", () => { + renderModal() + expect(screen.getByLabelText(/Type the bucket name to confirm/i)).toBeInTheDocument() + }) + + test("confirmation input has bucket name as placeholder", () => { + renderModal() + expect(screen.getByPlaceholderText(mockNonEmptyBucket.name)).toBeInTheDocument() + }) + + test("confirmation input has autofocus", () => { + renderModal() + expect(screen.getByLabelText(/Type the bucket name to confirm/i)).toHaveFocus() + }) + + test("renders Empty button", () => { + renderModal() + expect(screen.getByRole("button", { name: /^Empty$/i })).toBeInTheDocument() + }) + + test("renders Cancel button", () => { + renderModal() + expect(screen.getByRole("button", { name: /Cancel/i })).toBeInTheDocument() + }) + + test("renders Copy button", () => { + renderModal() + expect(screen.getByRole("button", { name: /Copy/i })).toBeInTheDocument() + }) + + test("Empty button is disabled when confirmation name is empty", () => { + renderModal() + expect(screen.getByRole("button", { name: /^Empty$/i })).toBeDisabled() + }) + }) + + describe("Copy bucket name", () => { + test("shows Copy button", () => { + renderModal() + expect(screen.getByRole("button", { name: /Copy/i })).toBeInTheDocument() + }) + + test("displays bucket name in a code block", () => { + renderModal() + const codeBlock = screen.getByText(mockNonEmptyBucket.name).closest("div.font-mono") + expect(codeBlock).toBeInTheDocument() + }) + }) + + describe("Name confirmation validation", () => { + test("Empty button disabled when name does not match", async () => { + const user = userEvent.setup({ delay: null }) + renderModal() + + const input = screen.getByLabelText(/Type the bucket name to confirm/i) + await user.clear(input) + await user.type(input, "wrong-name") + + expect(screen.getByRole("button", { name: /^Empty$/i })).toBeDisabled() + }) + + test("enables Empty button when name matches exactly", async () => { + const user = userEvent.setup({ delay: null }) + renderModal() + + const input = screen.getByLabelText(/Type the bucket name to confirm/i) + await user.clear(input) + await user.type(input, mockNonEmptyBucket.name) + + await waitFor( + () => { + expect(screen.getByRole("button", { name: /^Empty$/i })).not.toBeDisabled() + }, + { timeout: 3000 } + ) + }) + + test("trims whitespace from confirmation name", async () => { + const user = userEvent.setup({ delay: null }) + renderModal() + + const input = screen.getByLabelText(/Type the bucket name to confirm/i) + await user.clear(input) + await user.type(input, ` ${mockNonEmptyBucket.name} `) + + await waitFor( + () => { + expect(screen.getByRole("button", { name: /^Empty$/i })).not.toBeDisabled() + }, + { timeout: 3000 } + ) + }) + }) + + describe("Bucket emptying", () => { + test("calls mutation with correct parameters", async () => { + const user = userEvent.setup({ delay: null }) + renderModal() + + const input = screen.getByLabelText(/Type the bucket name to confirm/i) + await user.clear(input) + await user.type(input, mockNonEmptyBucket.name) + + const emptyButton = screen.getByRole("button", { name: /^Empty$/i }) + await user.click(emptyButton) + + await waitFor( + () => { + expect(mockMutate).toHaveBeenCalledWith( + { + project_id: mockProjectId, + containerName: mockNonEmptyBucket.name, + }, + expect.objectContaining({ + onSuccess: expect.any(Function), + onError: expect.any(Function), + }) + ) + }, + { timeout: 3000 } + ) + }) + + test("disables input and button while emptying", () => { + mockState.isPending = true + renderModal() + + expect(screen.getByLabelText(/Type the bucket name to confirm/i)).toBeDisabled() + expect(screen.getByRole("button", { name: /^Empty$/i })).toBeDisabled() + }) + }) + + describe("Success handling", () => { + test("calls onSuccess with bucket name and deleted count", async () => { + const user = userEvent.setup({ delay: null }) + const mockOnSuccess = vi.fn() + renderModal({ onSuccess: mockOnSuccess }) + + const input = screen.getByLabelText(/Type the bucket name to confirm/i) + await user.clear(input) + await user.type(input, mockNonEmptyBucket.name) + + const emptyButton = screen.getByRole("button", { name: /^Empty$/i }) + await user.click(emptyButton) + + await waitFor( + () => { + expect(mockOnSuccess).toHaveBeenCalledWith(mockNonEmptyBucket.name, 5) + }, + { timeout: 3000 } + ) + }) + + test("invalidates containers query on success", async () => { + const user = userEvent.setup({ delay: null }) + renderModal() + + const input = screen.getByLabelText(/Type the bucket name to confirm/i) + await user.clear(input) + await user.type(input, mockNonEmptyBucket.name) + + const emptyButton = screen.getByRole("button", { name: /^Empty$/i }) + await user.click(emptyButton) + + await waitFor( + () => { + expect(mockInvalidate).toHaveBeenCalledTimes(1) + }, + { timeout: 3000 } + ) + }) + + test("closes modal on success", async () => { + const user = userEvent.setup({ delay: null }) + const mockOnClose = vi.fn() + renderModal({ onClose: mockOnClose }) + + const input = screen.getByLabelText(/Type the bucket name to confirm/i) + await user.clear(input) + await user.type(input, mockNonEmptyBucket.name) + + const emptyButton = screen.getByRole("button", { name: /^Empty$/i }) + await user.click(emptyButton) + + await waitFor( + () => { + expect(mockOnClose).toHaveBeenCalledTimes(1) + }, + { timeout: 3000 } + ) + }) + }) + + describe("Error handling", () => { + test("calls onError with bucket name and error message", async () => { + const user = userEvent.setup({ delay: null }) + const mockOnError = vi.fn() + mockState.mutationError = "Failed to empty bucket" + renderModal({ onError: mockOnError }) + + const input = screen.getByLabelText(/Type the bucket name to confirm/i) + await user.clear(input) + await user.type(input, mockNonEmptyBucket.name) + + const emptyButton = screen.getByRole("button", { name: /^Empty$/i }) + await user.click(emptyButton) + + await waitFor( + () => { + expect(mockOnError).toHaveBeenCalledWith(mockNonEmptyBucket.name, "Failed to empty bucket") + }, + { timeout: 3000 } + ) + }) + + test("closes modal on error", async () => { + const user = userEvent.setup({ delay: null }) + const mockOnClose = vi.fn() + mockState.mutationError = "Empty failed" + renderModal({ onClose: mockOnClose }) + + const input = screen.getByLabelText(/Type the bucket name to confirm/i) + await user.clear(input) + await user.type(input, mockNonEmptyBucket.name) + + const emptyButton = screen.getByRole("button", { name: /^Empty$/i }) + await user.click(emptyButton) + + await waitFor( + () => { + expect(mockOnClose).toHaveBeenCalledTimes(1) + }, + { timeout: 3000 } + ) + }) + }) + + describe("Modal close behavior", () => { + test("closes modal when Cancel button is clicked", async () => { + const user = userEvent.setup({ delay: null }) + const mockOnClose = vi.fn() + renderModal({ onClose: mockOnClose }) + + const cancelButton = screen.getByRole("button", { name: /Cancel/i }) + await user.click(cancelButton) + + expect(mockOnClose).toHaveBeenCalledTimes(1) + }) + + test("resets mutation state when modal closes", async () => { + const user = userEvent.setup({ delay: null }) + renderModal() + + const cancelButton = screen.getByRole("button", { name: /Cancel/i }) + await user.click(cancelButton) + + expect(mockReset).toHaveBeenCalled() + }) + + test("clears copied state when modal closes", async () => { + const user = userEvent.setup({ delay: null }) + const mockOnClose = vi.fn() + renderModal({ onClose: mockOnClose }) + + const cancelButton = screen.getByRole("button", { name: /Cancel/i }) + await user.click(cancelButton) + + expect(mockOnClose).toHaveBeenCalled() + }) + }) +}) diff --git a/apps/aurora-portal/src/client/routes/_auth/projects/$projectId/storage/-components/Ceph/Containers/EmptyBucketModal.tsx b/apps/aurora-portal/src/client/routes/_auth/projects/$projectId/storage/-components/Ceph/Containers/EmptyBucketModal.tsx new file mode 100644 index 000000000..504354190 --- /dev/null +++ b/apps/aurora-portal/src/client/routes/_auth/projects/$projectId/storage/-components/Ceph/Containers/EmptyBucketModal.tsx @@ -0,0 +1,148 @@ +import { useState } from "react" +import { Trans, useLingui, Plural } from "@lingui/react/macro" +import { trpcReact } from "@/client/trpcClient" +import { Modal, TextInput, Stack, Message, Button } from "@cloudoperators/juno-ui-components" +import { Container } from "@/server/Storage/types/ceph" +import { useProjectId } from "@/client/hooks/useProjectId" + +interface EmptyBucketModalProps { + isOpen: boolean + bucket: Container | null + onClose: () => void + onSuccess?: (bucketName: string, deletedCount: number) => void + onError?: (bucketName: string, errorMessage: string) => void +} + +export const EmptyBucketModal = ({ isOpen, bucket, onClose, onSuccess, onError }: EmptyBucketModalProps) => { + const { t } = useLingui() + const projectId = useProjectId() + const [confirmName, setConfirmName] = useState("") + const [nameError, setNameError] = useState(null) + const [copied, setCopied] = useState(false) + + const utils = trpcReact.useUtils() + + const handleCopyName = () => { + if (!bucket) return + navigator.clipboard.writeText(bucket.name).then(() => { + setCopied(true) + setTimeout(() => setCopied(false), 2000) + }) + } + + const emptyBucketMutation = trpcReact.storage.ceph.objects.deleteAll.useMutation({ + onSettled: () => { + utils.storage.ceph.containers.list.invalidate() + handleClose() + }, + }) + + const handleClose = () => { + setConfirmName("") + setNameError(null) + setCopied(false) + emptyBucketMutation.reset() + onClose() + } + + const handleConfirmNameChange = (e: React.ChangeEvent) => { + const value = e.target.value + setConfirmName(value) + if (nameError) setNameError(null) + } + + const handleSubmit = () => { + if (!bucket) return + if (confirmName.trim() !== bucket.name) { + setNameError(t`Bucket name does not match`) + return + } + + // Capture bucket name before async operation to avoid dereferencing null bucket in callbacks + const bucketName = bucket.name + + emptyBucketMutation.mutate( + { + project_id: projectId, + containerName: bucketName, + }, + { + onSuccess: (deletedCount) => { + onSuccess?.(bucketName, deletedCount) + }, + onError: (error) => { + onError?.(bucketName, error.message) + }, + } + ) + } + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === "Enter") handleSubmit() + } + + if (!isOpen || !bucket) return null + + // Show info message if bucket is already empty + const isEmpty = bucket.count === 0 + const bucketCount = bucket.count + const bucketName = bucket.name + + return ( + + {isEmpty ? ( + + Nothing to do. Bucket is already empty. + + ) : ( + + + + Are you sure? All {bucketCount}{" "} + in bucket "{bucketName}" will be permanently + deleted. This action cannot be undone. + + + + +
+ + Bucket to empty: + +
+
{bucket.name}
+
+ + +
+ )} +
+ ) +} diff --git a/apps/aurora-portal/src/client/routes/_auth/projects/$projectId/storage/-components/Ceph/Containers/EmptyBucketsModal.test.tsx b/apps/aurora-portal/src/client/routes/_auth/projects/$projectId/storage/-components/Ceph/Containers/EmptyBucketsModal.test.tsx new file mode 100644 index 000000000..629c1840f --- /dev/null +++ b/apps/aurora-portal/src/client/routes/_auth/projects/$projectId/storage/-components/Ceph/Containers/EmptyBucketsModal.test.tsx @@ -0,0 +1,384 @@ +import { describe, test, expect, vi, beforeEach } from "vitest" +import { render, screen, waitFor, act } from "@testing-library/react" +import userEvent from "@testing-library/user-event" +import { PortalProvider } from "@cloudoperators/juno-ui-components" +import { i18n } from "@lingui/core" +import { I18nProvider } from "@lingui/react" +import { EmptyBucketsModal } from "./EmptyBucketsModal" +import type { Container } from "@/server/Storage/types/ceph" + +// ─── Mock useProjectId ──────────────────────────────────────────────────────── + +const mockProjectId = "test-project-123" + +vi.mock("@/client/hooks/useProjectId", () => ({ + useProjectId: () => mockProjectId, +})) + +// ─── tRPC mock ──────────────────────────────────────────────────────────────── + +const { mockInvalidate, mockMutateAsync, mockReset, mockState } = vi.hoisted(() => { + const mockState = { + isPending: false, + shouldFail: false, + failOnBucket: null as string | null, + } + const mockMutateAsync = vi.fn().mockImplementation(async (params: { containerName: string }) => { + if (mockState.shouldFail || params.containerName === mockState.failOnBucket) { + throw new Error(`Failed to empty bucket ${params.containerName}`) + } + // Return number of deleted objects + return 5 + }) + return { + mockInvalidate: vi.fn(), + mockMutateAsync, + mockReset: vi.fn(), + mockState, + } +}) + +vi.mock("@/client/trpcClient", () => ({ + trpcReact: { + useUtils: () => ({ + storage: { + ceph: { + containers: { list: { invalidate: mockInvalidate } }, + }, + }, + }), + storage: { + ceph: { + objects: { + deleteAll: { + useMutation: () => { + return { mutateAsync: mockMutateAsync, isPending: mockState.isPending, reset: mockReset } + }, + }, + }, + }, + }, + }, +})) + +// ─── Fixtures ───────────────────────────────────────────────────────────────── + +const mockBuckets: Container[] = [ + { name: "bucket-1", creationDate: "2024-01-15T10:00:00Z", count: 5, bytes: 1024 }, + { name: "bucket-2", creationDate: "2024-01-15T10:00:00Z", count: 3, bytes: 512 }, + { name: "bucket-3", creationDate: "2024-01-15T10:00:00Z", count: 0, bytes: 0 }, +] + +const mockSingleBucket: Container[] = [ + { name: "single-bucket", creationDate: "2024-01-15T10:00:00Z", count: 10, bytes: 2048 }, +] + +const mockManyBuckets: Container[] = Array.from({ length: 25 }, (_, i) => ({ + name: `bucket-${i + 1}`, + creationDate: "2024-01-15T10:00:00Z", + count: i + 1, + bytes: (i + 1) * 100, +})) + +// ─── Render helper ──────────────────────────────────────────────────────────── + +const renderModal = ({ + isOpen = true, + buckets = mockBuckets, + onClose = vi.fn(), + onComplete = vi.fn(), +}: { + isOpen?: boolean + buckets?: Container[] + onClose?: () => void + onComplete?: (result: { emptiedCount: number; totalDeleted: number; errors: string[] }) => void +} = {}) => + render( + + + + + + ) + +// ─── Tests ──────────────────────────────────────────────────────────────────── + +describe("EmptyBucketsModal", () => { + beforeEach(async () => { + vi.clearAllMocks() + mockState.isPending = false + mockState.shouldFail = false + mockState.failOnBucket = null + await act(async () => { + i18n.activate("en") + }) + }) + + describe("Visibility", () => { + test("does not render when isOpen is false", () => { + renderModal({ isOpen: false }) + expect(screen.queryByText(/Empty Buckets/i)).not.toBeInTheDocument() + }) + + test("does not render when buckets array is empty", () => { + renderModal({ buckets: [] }) + expect(screen.queryByText(/Empty Buckets/i)).not.toBeInTheDocument() + }) + + test("renders when isOpen is true and buckets are provided", () => { + renderModal() + expect(screen.getByRole("heading", { name: "Empty Buckets" })).toBeInTheDocument() + }) + }) + + describe("UI elements", () => { + test("renders modal title", () => { + renderModal() + expect(screen.getByRole("heading", { name: "Empty Buckets" })).toBeInTheDocument() + }) + + test("shows warning message with bucket count (plural)", () => { + renderModal({ buckets: mockBuckets }) + expect(screen.getByText(/This will permanently delete all objects from 3 selected buckets/)).toBeInTheDocument() + expect(screen.getByText(/This action cannot be undone/)).toBeInTheDocument() + }) + + test("shows warning message with bucket count (singular)", () => { + renderModal({ buckets: mockSingleBucket }) + expect(screen.getByText(/from 1 selected bucket/)).toBeInTheDocument() + }) + + test("displays list of buckets to empty", () => { + renderModal() + expect(screen.getByText("Buckets to empty:")).toBeInTheDocument() + expect(screen.getByText("bucket-1")).toBeInTheDocument() + expect(screen.getByText("bucket-2")).toBeInTheDocument() + expect(screen.getByText("bucket-3")).toBeInTheDocument() + }) + + test("shows object count for non-empty buckets", () => { + renderModal() + expect(screen.getByText(/\(5 objects\)/)).toBeInTheDocument() + expect(screen.getByText(/\(3 objects\)/)).toBeInTheDocument() + }) + + test("shows singular form for single object", () => { + const singleObjectBucket: Container[] = [ + { name: "bucket-1", creationDate: "2024-01-15T10:00:00Z", count: 1, bytes: 100 }, + ] + renderModal({ buckets: singleObjectBucket }) + expect(screen.getByText(/\(1 object\)/)).toBeInTheDocument() + }) + + test("does not show object count for empty buckets", () => { + const emptyBucket: Container[] = [ + { name: "empty-bucket", creationDate: "2024-01-15T10:00:00Z", count: 0, bytes: 0 }, + ] + renderModal({ buckets: emptyBucket }) + expect(screen.queryByText(/\(0 objects\)/)).not.toBeInTheDocument() + }) + + test("limits visible buckets to 20 and shows hidden count", () => { + renderModal({ buckets: mockManyBuckets }) + expect(screen.getByText("bucket-1")).toBeInTheDocument() + expect(screen.getByText("bucket-20")).toBeInTheDocument() + expect(screen.queryByText("bucket-21")).not.toBeInTheDocument() + expect(screen.getByText(/...and 5 more/)).toBeInTheDocument() + }) + + test("renders Empty button", () => { + renderModal() + expect(screen.getByRole("button", { name: /^Empty$/i })).toBeInTheDocument() + }) + + test("renders Cancel button", () => { + renderModal() + expect(screen.getByRole("button", { name: /Cancel/i })).toBeInTheDocument() + }) + }) + + describe("Bucket emptying process", () => { + test("calls mutateAsync for each bucket", async () => { + const user = userEvent.setup({ delay: null }) + renderModal({ buckets: mockBuckets }) + + const emptyButton = screen.getByRole("button", { name: /^Empty$/i }) + await user.click(emptyButton) + + await waitFor( + () => { + expect(mockMutateAsync).toHaveBeenCalledTimes(3) + expect(mockMutateAsync).toHaveBeenCalledWith({ + project_id: mockProjectId, + containerName: "bucket-1", + }) + expect(mockMutateAsync).toHaveBeenCalledWith({ + project_id: mockProjectId, + containerName: "bucket-2", + }) + expect(mockMutateAsync).toHaveBeenCalledWith({ + project_id: mockProjectId, + containerName: "bucket-3", + }) + }, + { timeout: 3000 } + ) + }) + }) + + describe("Success handling", () => { + test("calls onComplete with success result", async () => { + const user = userEvent.setup({ delay: null }) + const mockOnComplete = vi.fn() + renderModal({ buckets: mockBuckets, onComplete: mockOnComplete }) + + const emptyButton = screen.getByRole("button", { name: /^Empty$/i }) + await user.click(emptyButton) + + await waitFor( + () => { + expect(mockOnComplete).toHaveBeenCalledWith({ + emptiedCount: 3, + totalDeleted: 15, // 3 buckets * 5 objects each + errors: [], + }) + }, + { timeout: 3000 } + ) + }) + + test("invalidates containers query after success", async () => { + const user = userEvent.setup({ delay: null }) + renderModal({ buckets: mockBuckets }) + + const emptyButton = screen.getByRole("button", { name: /^Empty$/i }) + await user.click(emptyButton) + + await waitFor( + () => { + expect(mockInvalidate).toHaveBeenCalledTimes(1) + }, + { timeout: 3000 } + ) + }) + + test("closes modal after completion", async () => { + const user = userEvent.setup({ delay: null }) + const mockOnClose = vi.fn() + renderModal({ buckets: mockBuckets, onClose: mockOnClose }) + + const emptyButton = screen.getByRole("button", { name: /^Empty$/i }) + await user.click(emptyButton) + + await waitFor( + () => { + expect(mockOnClose).toHaveBeenCalledTimes(1) + }, + { timeout: 3000 } + ) + }) + }) + + describe("Error handling", () => { + test("continues with other buckets when one fails", async () => { + const user = userEvent.setup({ delay: null }) + const mockOnComplete = vi.fn() + mockState.failOnBucket = "bucket-2" + renderModal({ buckets: mockBuckets, onComplete: mockOnComplete }) + + const emptyButton = screen.getByRole("button", { name: /^Empty$/i }) + await user.click(emptyButton) + + await waitFor( + () => { + expect(mockOnComplete).toHaveBeenCalledWith({ + emptiedCount: 2, + totalDeleted: 10, // 2 successful buckets * 5 objects each + errors: ["bucket-2: Failed to empty bucket bucket-2"], + }) + }, + { timeout: 3000 } + ) + }) + + test("collects all errors from failed buckets", async () => { + const user = userEvent.setup({ delay: null }) + const mockOnComplete = vi.fn() + mockState.shouldFail = true + renderModal({ buckets: mockBuckets, onComplete: mockOnComplete }) + + const emptyButton = screen.getByRole("button", { name: /^Empty$/i }) + await user.click(emptyButton) + + await waitFor( + () => { + expect(mockOnComplete).toHaveBeenCalledWith({ + emptiedCount: 0, + totalDeleted: 0, + errors: [ + "bucket-1: Failed to empty bucket bucket-1", + "bucket-2: Failed to empty bucket bucket-2", + "bucket-3: Failed to empty bucket bucket-3", + ], + }) + }, + { timeout: 3000 } + ) + }) + + test("does not invalidate query when all operations fail", async () => { + const user = userEvent.setup({ delay: null }) + mockState.shouldFail = true + renderModal({ buckets: mockBuckets }) + + const emptyButton = screen.getByRole("button", { name: /^Empty$/i }) + await user.click(emptyButton) + + await waitFor( + () => { + expect(mockInvalidate).not.toHaveBeenCalled() + }, + { timeout: 3000 } + ) + }) + + test("invalidates query when some operations succeed", async () => { + const user = userEvent.setup({ delay: null }) + mockState.failOnBucket = "bucket-2" + renderModal({ buckets: mockBuckets }) + + const emptyButton = screen.getByRole("button", { name: /^Empty$/i }) + await user.click(emptyButton) + + await waitFor( + () => { + expect(mockInvalidate).toHaveBeenCalledTimes(1) + }, + { timeout: 3000 } + ) + }) + }) + + describe("Modal close behavior", () => { + test("closes modal when Cancel button is clicked", async () => { + const user = userEvent.setup({ delay: null }) + const mockOnClose = vi.fn() + renderModal({ onClose: mockOnClose }) + + const cancelButton = screen.getByRole("button", { name: /Cancel/i }) + await user.click(cancelButton) + + expect(mockOnClose).toHaveBeenCalledTimes(1) + }) + + test("resets mutation state when modal closes", async () => { + const user = userEvent.setup({ delay: null }) + renderModal() + + const cancelButton = screen.getByRole("button", { name: /Cancel/i }) + await user.click(cancelButton) + + expect(mockReset).toHaveBeenCalled() + }) + }) +}) diff --git a/apps/aurora-portal/src/client/routes/_auth/projects/$projectId/storage/-components/Ceph/Containers/EmptyBucketsModal.tsx b/apps/aurora-portal/src/client/routes/_auth/projects/$projectId/storage/-components/Ceph/Containers/EmptyBucketsModal.tsx new file mode 100644 index 000000000..483bf7343 --- /dev/null +++ b/apps/aurora-portal/src/client/routes/_auth/projects/$projectId/storage/-components/Ceph/Containers/EmptyBucketsModal.tsx @@ -0,0 +1,140 @@ +import { useState } from "react" +import { Trans, useLingui, Plural } from "@lingui/react/macro" +import { trpcReact } from "@/client/trpcClient" +import { Modal, Message, Spinner, Stack } from "@cloudoperators/juno-ui-components" +import { Container } from "@/server/Storage/types/ceph" +import { useProjectId } from "@/client/hooks/useProjectId" + +const MAX_VISIBLE = 20 + +interface EmptyBucketsResult { + emptiedCount: number + totalDeleted: number + errors: string[] +} + +interface EmptyBucketsModalProps { + isOpen: boolean + buckets: Container[] + onClose: () => void + onComplete?: (result: EmptyBucketsResult) => void +} + +export const EmptyBucketsModal = ({ isOpen, buckets, onClose, onComplete }: EmptyBucketsModalProps) => { + const { t } = useLingui() + const projectId = useProjectId() + const [progress, setProgress] = useState<{ current: number; total: number } | null>(null) + + const utils = trpcReact.useUtils() + const emptyBucketMutation = trpcReact.storage.ceph.objects.deleteAll.useMutation() + + const handleClose = () => { + emptyBucketMutation.reset() + setProgress(null) + onClose() + } + + const handleConfirm = async () => { + let emptiedCount = 0 + let totalDeleted = 0 + const errors: string[] = [] + const total = buckets.length + + for (let i = 0; i < buckets.length; i++) { + setProgress({ current: i + 1, total }) + const bucket = buckets[i] + + try { + const deleted = await emptyBucketMutation.mutateAsync({ + project_id: projectId, + containerName: bucket.name, + }) + totalDeleted += deleted + emptiedCount++ + } catch (err) { + const message = err instanceof Error ? err.message : String(err) + errors.push(`${bucket.name}: ${message}`) + } + } + + if (emptiedCount > 0) { + await utils.storage.ceph.containers.list.invalidate() + } + + onComplete?.({ emptiedCount, totalDeleted, errors }) + handleClose() + } + + if (!isOpen || buckets.length === 0) return null + + const totalCount = buckets.length + const visibleBuckets = buckets.slice(0, MAX_VISIBLE) + const hiddenCount = totalCount - visibleBuckets.length + const isPending = emptyBucketMutation.isPending || progress !== null + const progressCurrent = progress?.current + const progressTotal = progress?.total + + return ( + + {isPending ? ( + + + {progress && ( +

+ + Emptying bucket {progressCurrent} of {progressTotal}, please wait... + +

+ )} +
+ ) : ( + + + + This will permanently delete all objects from {totalCount} selected{" "} + . This action cannot be undone. + + + +
+

+ Buckets to empty: +

+
    + {visibleBuckets.map((bucket) => { + const bucketName = bucket.name + const bucketCount = bucket.count + return ( +
  • + {bucketName} + {bucketCount > 0 && ( + + ({bucketCount} ) + + )} +
  • + ) + })} + {hiddenCount > 0 && ( +
  • + ... and {hiddenCount} more +
  • + )} +
+
+
+ )} +
+ ) +} diff --git a/apps/aurora-portal/src/client/routes/_auth/projects/$projectId/storage/-components/Ceph/Containers/index.tsx b/apps/aurora-portal/src/client/routes/_auth/projects/$projectId/storage/-components/Ceph/Containers/index.tsx index 6c82b399f..bc4e04ea4 100644 --- a/apps/aurora-portal/src/client/routes/_auth/projects/$projectId/storage/-components/Ceph/Containers/index.tsx +++ b/apps/aurora-portal/src/client/routes/_auth/projects/$projectId/storage/-components/Ceph/Containers/index.tsx @@ -1,25 +1,262 @@ -import { Trans } from "@lingui/react/macro" -import { ContentHeading, Stack } from "@cloudoperators/juno-ui-components" -import { ContainerListView } from "./ContainerListView" +import { useState, startTransition } from "react" +import { Trans, useLingui } from "@lingui/react/macro" +import { useNavigate } from "@tanstack/react-router" +import { ListToolbar } from "@/client/components/ListToolbar" +import { SortSettings } from "@/client/components/ListToolbar/types" +import { Container } from "@/server/Storage/types/ceph" +import { trpcReact } from "@/client/trpcClient" +import { Button, Spinner, Stack, Toast, ToastProps } from "@cloudoperators/juno-ui-components" +import { ContainerTableView } from "./ContainerTableView" +import { + getBucketCreatedToast, + getBucketCreateErrorToast, + getBucketEmptiedToast, + getBucketEmptyErrorToast, + getBucketDeletedToast, + getBucketDeleteErrorToast, + getBucketsEmptyCompleteToast, +} from "./ContainerToastNotifications" +import { EmptyBucketsModal } from "./EmptyBucketsModal" +import { useProjectId } from "@/client/hooks/useProjectId" +import { Route } from "@/client/routes/_auth/projects/$projectId/storage/$provider/containers" export { ContainerListView } from "./ContainerListView" export { CredentialPrompt } from "./CredentialPrompt" +export { CreateBucketModal } from "./CreateBucketModal" +export { DeleteBucketModal } from "./DeleteBucketModal" +export { EmptyBucketModal } from "./EmptyBucketModal" +export { EmptyBucketsModal } from "./EmptyBucketsModal" +export * from "./ContainerToastNotifications" -/** - * Ceph Containers Page Component - * - * Main container page for Ceph/S3 object storage. - * Displays list of containers (buckets) with usage information. - */ export const CephContainers = () => { - return ( - - - - Containers - + const { t } = useLingui() + const projectId = useProjectId() + const navigate = useNavigate({ from: Route.fullPath }) + + // Sort and search state are persisted in the URL so they survive navigation, + // browser back/forward, and deep links. + const { sortBy, sortDirection, search: searchParam = "" } = Route.useSearch() + + const [createModalOpen, setCreateModalOpen] = useState(false) + const [emptyAllModalOpen, setEmptyAllModalOpen] = useState(false) + const [selectedContainers, setSelectedContainers] = useState([]) + const [toastData, setToastData] = useState(null) + + const handleToastDismiss = () => setToastData(null) + + const handleCreateSuccess = (bucketName: string) => { + setToastData(getBucketCreatedToast(bucketName, { onDismiss: handleToastDismiss })) + } + + const handleCreateError = (bucketName: string, errorMessage: string) => { + setToastData(getBucketCreateErrorToast(bucketName, errorMessage, { onDismiss: handleToastDismiss })) + } + + const handleEmptySuccess = (bucketName: string, deletedCount: number) => { + setToastData(getBucketEmptiedToast(bucketName, deletedCount, { onDismiss: handleToastDismiss })) + } + + const handleEmptyError = (bucketName: string, errorMessage: string) => { + setToastData(getBucketEmptyErrorToast(bucketName, errorMessage, { onDismiss: handleToastDismiss })) + } + + const handleDeleteSuccess = (bucketName: string) => { + setToastData(getBucketDeletedToast(bucketName, { onDismiss: handleToastDismiss })) + } + + const handleDeleteError = (bucketName: string, errorMessage: string) => { + setToastData(getBucketDeleteErrorToast(bucketName, errorMessage, { onDismiss: handleToastDismiss })) + } + + const handleEmptyAllComplete = ({ + emptiedCount, + totalDeleted, + errors, + }: { + emptiedCount: number + totalDeleted: number + errors: string[] + }) => { + if (errors.length === 0) { + setSelectedContainers([]) + } else { + // Extract the bucket name from each error string formatted as ": ". + // Using exact name extraction avoids the false-positive substring match that + // errorMessage.includes(bucketName) would produce (e.g. "foo" matched inside "foobar" error). + const failedBucketNames = new Set(errors.map((e) => e.split(": ")[0])) + setSelectedContainers((prev) => prev.filter((name) => failedBucketNames.has(name))) + } + setToastData(getBucketsEmptyCompleteToast(emptiedCount, totalDeleted, errors, { onDismiss: handleToastDismiss })) + } + + const sortSettings: SortSettings = { + options: [ + { label: t`Name`, value: "name" }, + { label: t`Object Count`, value: "count" }, + { label: t`Total Size`, value: "bytes" }, + { label: t`Last Modified`, value: "last_modified" }, + ], + sortBy: sortBy ?? "name", + sortDirection: sortDirection ?? "asc", + } + + // Fetch buckets from tRPC + const { + data: buckets, + isLoading, + error, + } = trpcReact.storage.ceph.containers.list.useQuery( + { + project_id: projectId, + includeMetadata: true, // Fetch full metadata for table view with sorting + }, + { + enabled: !!projectId, + retry: false, // Don't retry on NO_CEPH_CREDENTIALS error + } + ) + + // Sort buckets based on sort settings + const sortContainers = (containers: Container[]): Container[] => { + return [...containers].sort((a, b) => { + let comparison: number + + switch (sortBy ?? "name") { + case "name": + comparison = a.name.localeCompare(b.name) + break + case "count": + comparison = a.count - b.count + break + case "bytes": + comparison = a.bytes - b.bytes + break + case "last_modified": + if (!a.last_modified && !b.last_modified) { + return 0 + } + if (!a.last_modified) { + return 1 + } + if (!b.last_modified) { + return -1 + } + comparison = new Date(a.last_modified).getTime() - new Date(b.last_modified).getTime() + break + default: + comparison = a.name.localeCompare(b.name) + } + + return (sortDirection ?? "asc") === "desc" ? -comparison : comparison + }) + } + + // Filter buckets based on search term + const filteredContainers = (buckets || []).filter((container) => + container.name.toLowerCase().includes(searchParam.toLowerCase()) + ) + + // Apply sorting to filtered buckets + const sortedContainers = sortContainers(filteredContainers) + + const handleSearchChange = (term: string | number | string[] | undefined) => { + const value = typeof term === "string" ? term : "" + startTransition(() => { + navigate({ + search: (prev) => ({ ...prev, search: value || undefined }), + }) + }) + } + + const handleSortChange = (newSortSettings: SortSettings) => { + const resolvedSortBy = (newSortSettings.sortBy?.toString() || "name") as + | "name" + | "count" + | "bytes" + | "last_modified" + const resolvedDirection = (newSortSettings.sortDirection || "asc") as "asc" | "desc" + startTransition(() => { + navigate({ + search: (prev) => ({ + ...prev, + sortBy: resolvedSortBy, + sortDirection: resolvedDirection, + }), + }) + }) + } + + // Handle loading state + if (isLoading) { + return ( + + + Loading Buckets... + + ) + } + + // Handle error state + if (error) { + const errorMessage = error.message + + return ( + + Error Loading Buckets: {errorMessage} - - + ) + } + + // Resolve selected Container objects from the full unfiltered list so + // the modal always operates on what was actually selected — not the filtered + // subset currently visible in the table. + const selectedContainerSummaries = (buckets || []).filter((c) => selectedContainers.includes(c.name)) + const hasSelection = selectedContainerSummaries.length > 0 + const selectedCount = selectedContainerSummaries.length + + return ( +
+ + + + + } + /> + + + + setEmptyAllModalOpen(false)} + onComplete={handleEmptyAllComplete} + /> + + {toastData && ( + + )} +
) } diff --git a/apps/aurora-portal/src/client/routes/_auth/projects/$projectId/storage/ceph/containers/$containerName/objects/index.tsx b/apps/aurora-portal/src/client/routes/_auth/projects/$projectId/storage/ceph/containers/$containerName/objects/index.tsx deleted file mode 100644 index 585e2bf03..000000000 --- a/apps/aurora-portal/src/client/routes/_auth/projects/$projectId/storage/ceph/containers/$containerName/objects/index.tsx +++ /dev/null @@ -1,23 +0,0 @@ -import { createFileRoute } from "@tanstack/react-router" -import { Trans } from "@lingui/react/macro" -import { ContentHeading, Stack } from "@cloudoperators/juno-ui-components" -import { ObjectBrowserView } from "../../../../-components/Ceph/Objects" -import type { RouteInfo } from "@/client/routes/routeInfo" - -export const Route = createFileRoute("/_auth/projects/$projectId/storage/ceph/containers/$containerName/objects/")({ - staticData: { section: "storage", service: "ceph-containers" } satisfies RouteInfo, - component: S3ObjectsPage, -}) - -function S3ObjectsPage() { - const { containerName } = Route.useParams() - - return ( - - - Container: {containerName} - - - - ) -} diff --git a/apps/aurora-portal/src/client/routes/_auth/projects/$projectId/storage/ceph/containers/index.tsx b/apps/aurora-portal/src/client/routes/_auth/projects/$projectId/storage/ceph/containers/index.tsx deleted file mode 100644 index af76990b1..000000000 --- a/apps/aurora-portal/src/client/routes/_auth/projects/$projectId/storage/ceph/containers/index.tsx +++ /dev/null @@ -1,21 +0,0 @@ -import { createFileRoute } from "@tanstack/react-router" -import { Trans } from "@lingui/react/macro" -import { ContentHeading, Stack } from "@cloudoperators/juno-ui-components" -import { ContainerListView } from "../../-components/Ceph/Containers" -import type { RouteInfo } from "@/client/routes/routeInfo" - -export const Route = createFileRoute("/_auth/projects/$projectId/storage/ceph/containers/")({ - staticData: { section: "storage", service: "ceph-containers" } satisfies RouteInfo, - component: S3BucketsPage, -}) - -function S3BucketsPage() { - return ( - - - Containers - - - - ) -} diff --git a/apps/aurora-portal/src/client/routes/_auth/projects/$projectId/storage/ceph/index.tsx b/apps/aurora-portal/src/client/routes/_auth/projects/$projectId/storage/ceph/index.tsx deleted file mode 100644 index 0553b0cbd..000000000 --- a/apps/aurora-portal/src/client/routes/_auth/projects/$projectId/storage/ceph/index.tsx +++ /dev/null @@ -1,43 +0,0 @@ -import { createFileRoute, Outlet } from "@tanstack/react-router" -import { trpcReact } from "@/client/trpcClient" -import { useProjectId } from "@/client/hooks/useProjectId" -import { CredentialPrompt } from "../-components/Ceph/Containers" -import { Spinner, Stack } from "@cloudoperators/juno-ui-components" -import { Trans } from "@lingui/react/macro" - -export const Route = createFileRoute("/_auth/projects/$projectId/storage/ceph/")({ - component: S3Layout, -}) - -function S3Layout() { - const projectId = useProjectId() - const utils = trpcReact.useUtils() - - const { data: status, isLoading } = trpcReact.storage.ceph.containers.status.useQuery( - { project_id: projectId ?? "" }, - { enabled: !!projectId } - ) - - if (isLoading) { - return ( - - - - Checking S3 credentials... - - - ) - } - - if (!status?.hasCredentials) { - return ( - { - await utils.storage.ceph.containers.status.refetch() - }} - /> - ) - } - - return -} diff --git a/apps/aurora-portal/src/locales/de/messages.po b/apps/aurora-portal/src/locales/de/messages.po index 77019eaae..e3da5422f 100644 --- a/apps/aurora-portal/src/locales/de/messages.po +++ b/apps/aurora-portal/src/locales/de/messages.po @@ -31,9 +31,18 @@ msgstr "\"{objectName}\" wurde erfolgreich nach {destination} verschoben." msgid "\"{objectName}\" was successfully uploaded." msgstr "\"{objectName}\" wurde erfolgreich hochgeladen." +msgid "{allContainersCount} bucket" +msgstr "" + +msgid "{allContainersCount} buckets" +msgstr "" + msgid "{allCount} items" msgstr "{allCount} Elemente" +msgid "{bucketCount, plural, one {object} other {objects}}" +msgstr "" + msgid "{customMinutes} minutes" msgstr "{customMinutes} Minuten" @@ -61,6 +70,9 @@ msgstr "{progressPct}%" msgid "{totalCount, plural, one {{totalCount} container} other {{totalCount} containers}}" msgstr "{totalCount, plural, one {{totalCount} Container} other {{totalCount} Container}}" +msgid "<0>Are you sure? All {bucketCount} {bucketCount, plural, one {object} other {objects}} in bucket \"{bucketName}\" will be permanently deleted. This action cannot be undone." +msgstr "" + msgid "<0>Are you sure? All objects in the selected containers will be permanently deleted. This cannot be undone." msgstr "<0>Sind Sie sicher? Alle Objekte in den ausgewählten Containern werden dauerhaft gelöscht. Dies kann nicht rückgängig gemacht werden." @@ -223,6 +235,9 @@ msgstr "Sicherheitsgruppenregel hinzufügen" msgid "Add Tenant Access" msgstr "Projektzugriff hinzufügen" +msgid "All Buckets Emptied" +msgstr "" + msgid "All containers" msgstr "Alle Container" @@ -325,6 +340,75 @@ msgstr "Boot-RAM" msgid "Boot size" msgstr "Boot-Größe" +msgid "Bucket \"{bucketName}\" was already empty." +msgstr "" + +msgid "Bucket \"{bucketName}\" was successfully created." +msgstr "" + +msgid "Bucket \"{bucketName}\" was successfully deleted." +msgstr "" + +msgid "Bucket \"{bucketName}\" was successfully emptied. {deletedCount} object deleted." +msgstr "" + +msgid "Bucket \"{bucketName}\" was successfully emptied. {deletedCount} objects deleted." +msgstr "" + +msgid "Bucket Created" +msgstr "" + +msgid "Bucket Deleted" +msgstr "" + +msgid "Bucket Emptied" +msgstr "" + +msgid "Bucket name" +msgstr "" + +msgid "Bucket Name" +msgstr "" + +msgid "Bucket name does not match" +msgstr "" + +msgid "Bucket name is required" +msgstr "" + +msgid "Bucket name must be 63 characters or fewer" +msgstr "" + +msgid "Bucket name must be at least 3 characters" +msgstr "" + +msgid "Bucket name must contain only lowercase letters, numbers, periods, and hyphens" +msgstr "" + +msgid "Bucket name must not be formatted as an IP address" +msgstr "" + +msgid "Bucket name must not contain consecutive periods" +msgstr "" + +msgid "Bucket name must not end with reserved suffix \"{suffix}\"" +msgstr "" + +msgid "Bucket name must not start with reserved prefix \"{prefix}\"" +msgstr "" + +msgid "Bucket name must start and end with a letter or number" +msgstr "" + +msgid "Bucket to delete:" +msgstr "" + +msgid "Bucket to empty:" +msgstr "" + +msgid "Buckets to empty:" +msgstr "" + msgid "Bulk upload of archive files" msgstr "Bulk-Upload von Archivdateien" @@ -349,7 +433,7 @@ msgstr "" msgid "Certificate not found" msgstr "" -msgid "Checking S3 credentials..." +msgid "Checking bucket contents..." msgstr "" msgid "Checksum" @@ -451,9 +535,6 @@ msgstr "Container aktualisiert" msgid "Container:" msgstr "Container:" -msgid "Container: {containerName}" -msgstr "" - msgid "Containers" msgstr "Container" @@ -469,6 +550,9 @@ msgstr "Zu leerende Container ({totalCount})" msgid "Content type" msgstr "Content type" +msgid "Copied" +msgstr "" + msgid "Copied to clipboard!" msgstr "In die Zwischenablage kopiert!" @@ -502,6 +586,9 @@ msgstr "Wird kopiert..." msgid "Could not copy \"{objectName}\": {errorMessage}" msgstr "\"{objectName}\" konnte nicht kopiert werden: {errorMessage}" +msgid "Could not create bucket \"{bucketName}\": {errorMessage}" +msgstr "" + msgid "Could not create container \"{containerName}\": {errorMessage}" msgstr "Container \"{containerName}\" konnte nicht erstellt werden: {errorMessage}" @@ -511,6 +598,9 @@ msgstr "Ordner \"{folderName}\" konnte nicht erstellt werden: {errorMessage}" msgid "Could not delete \"{objectName}\": {errorMessage}" msgstr "\"{objectName}\" konnte nicht gelöscht werden: {errorMessage}" +msgid "Could not delete bucket \"{bucketName}\": {errorMessage}" +msgstr "" + msgid "Could not delete container \"{containerName}\": {errorMessage}" msgstr "Container \"{containerName}\" konnte nicht gelöscht werden: {errorMessage}" @@ -520,6 +610,9 @@ msgstr "Ordner \"{folderName}\" konnte nicht gelöscht werden: {errorMessage}" msgid "Could not download \"{objectName}\": {errorMessage}" msgstr "\"{objectName}\" konnte nicht heruntergeladen werden: {errorMessage}" +msgid "Could not empty bucket \"{bucketName}\": {errorMessage}" +msgstr "" + msgid "Could not empty container \"{containerName}\": {errorMessage}" msgstr "Container \"{containerName}\" konnte nicht geleert werden: {errorMessage}" @@ -544,6 +637,9 @@ msgstr "CPU" msgid "Create" msgstr "Erstellen" +msgid "Create Bucket" +msgstr "" + msgid "Create Certificate" msgstr "" @@ -646,6 +742,12 @@ msgstr "Alle löschen" msgid "Delete All ({selectedCount})" msgstr "Alle löschen ({selectedCount})" +msgid "Delete bucket" +msgstr "" + +msgid "Delete Bucket" +msgstr "" + msgid "Delete CA" msgstr "Delete CA" @@ -844,12 +946,24 @@ msgstr "Alle leeren" msgid "Empty All ({selectedCount})" msgstr "Alle leeren ({selectedCount})" +msgid "Empty All Completed with Errors" +msgstr "" + +msgid "Empty Bucket" +msgstr "" + +msgid "Empty Buckets" +msgstr "" + msgid "Empty Containers" msgstr "Container leeren" msgid "Empty:" msgstr "Leeren:" +msgid "Emptying bucket {progressCurrent} of {progressTotal}, please wait..." +msgstr "" + msgid "Emptying container {progressCurrent} of {progressTotal}, please wait..." msgstr "Container {progressCurrent} von {progressTotal} wird geleert, bitte warten..." @@ -934,6 +1048,9 @@ msgstr "Fehler – Flavor-Details" msgid "Error - Image Details" msgstr "Fehler – Image-Details" +msgid "Error Loading Buckets: {errorMessage}" +msgstr "" + msgid "Error loading Certificate" msgstr "" @@ -997,6 +1114,9 @@ msgstr "Fehler beim Hinzufügen des Mitglieds" msgid "Failed to add tenant access to flavor. Please try again." msgstr "Fehler beim Hinzufügen des Zugriffs für das Project zum Flavor. Bitte versuchen Sie es erneut." +msgid "Failed to check bucket contents: {errorMessage}" +msgstr "" + msgid "Failed to Copy Object" msgstr "Fehler beim Kopieren des Objekts" @@ -1006,6 +1126,9 @@ msgstr "Fehler beim Kopieren des Objekts: {errorMessage}" msgid "Failed to copy the temporary URL to the clipboard" msgstr "Fehler beim Kopieren der temporären URL in die Zwischenablage" +msgid "Failed to Create Bucket" +msgstr "" + msgid "Failed to Create Container" msgstr "Fehler beim Erstellen des Containers" @@ -1039,6 +1162,9 @@ msgstr "Fehler beim Deaktivieren der Images" msgid "Failed to delete {failedCount} of {totalCount} image(s). Some images may be protected or in use." msgstr "{failedCount} von {totalCount} Image(s) konnten nicht gelöscht werden. Einige Images sind möglicherweise geschützt oder werden verwendet." +msgid "Failed to Delete Bucket" +msgstr "" + msgid "Failed to Delete Container" msgstr "Fehler beim Löschen des Containers" @@ -1069,6 +1195,9 @@ msgstr "Das Löschen des Flavor ist fehlgeschlagen. Bitte versuchen Sie es erneu msgid "Failed to Download" msgstr "Fehler beim Herunterladen" +msgid "Failed to Empty Bucket" +msgstr "" + msgid "Failed to Empty Container" msgstr "Fehler beim Leeren des Containers" @@ -1288,6 +1417,9 @@ msgstr "Wird generiert..." msgid "Get Involved" msgstr "Mitmachen" +msgid "Got it!" +msgstr "" + msgid "Grant access to a user from a different project." msgstr "Zugriff für einen Benutzer aus einem anderen Projekt gewähren." @@ -1498,6 +1630,9 @@ msgstr "Mehr laden" msgid "Loading ACLs..." msgstr "ACLs werden geladen..." +msgid "Loading Buckets..." +msgstr "" + msgid "Loading Certificate Authority Details..." msgstr "" @@ -1714,6 +1849,9 @@ msgstr "Muss eine gültige IPv4- oder IPv6-Adresse sein (z. B.: 172.24.4.228 ode msgid "Must be a valid PQDN or FQDN (alphanumeric and hyphens only, cannot start or end with hyphen)." msgstr "Muss eine gültige PQDN oder FQDN sein (nur alphanumerische Zeichen und Bindestriche, darf nicht mit einem Bindestrich beginnen oder enden)." +msgid "my-bucket-name" +msgstr "" + msgid "N/A" msgstr "keine Angabe" @@ -1747,6 +1885,9 @@ msgstr "new-folder-name" msgid "No" msgstr "Nein" +msgid "No buckets found" +msgstr "" + msgid "No Certificates issued by this Certificate Authority found" msgstr "" @@ -1834,6 +1975,9 @@ msgstr "Hinweis: Für <0>statische und dynamische große Objekte werden nur msgid "Note: The 'stateful' attribute cannot be changed if this security group is currently in use by one or more ports." msgstr "Hinweis: Das Attribut 'stateful' kann nicht geändert werden, wenn diese Security Group derzeit von einem oder mehreren Ports verwendet wird." +msgid "Nothing to do. Bucket is already empty." +msgstr "" + msgid "Object \"{objectName}\" was permanently deleted." msgstr "Objekt \"{objectName}\" wurde dauerhaft gelöscht." @@ -2206,6 +2350,9 @@ msgstr "RX/TX Factor" msgid "RX/TX Factor must be an integer ≥ 1." msgstr "RX/TX Faktor muss eine ganze Zahl ≥ 1 sein." +msgid "S3 bucket names must be 3-63 characters long and contain only lowercase letters, numbers, periods, and hyphens. They must start and end with a letter or number, and be globally unique within the cluster." +msgstr "" + msgid "S3 Object Storage — Setup Required" msgstr "" @@ -2440,6 +2587,12 @@ msgstr "{successCount} von {totalCount} Image(s) erfolgreich deaktiviert" msgid "Successfully deleted {successCount} of {totalCount} image(s)" msgstr "{successCount} von {totalCount} Image(s) erfolgreich gelöscht" +msgid "Successfully emptied {emptiedCount} {emptiedCount, plural, one {bucket} other {buckets}}, deleting {totalDeleted} {totalDeleted, plural, one {object} other {objects}}." +msgstr "" + +msgid "Successfully emptied {emptiedCount} of {totalBuckets} {totalBuckets, plural, one {bucket} other {buckets}}, deleting {totalDeleted} {totalDeleted, plural, one {object} other {objects}}. {errorsLength} {errorsLength, plural, one {bucket} other {buckets}} failed." +msgstr "" + msgid "Suggested Images" msgstr "Empfohlene Images" @@ -2578,6 +2731,9 @@ msgstr "Der Text muss “detach” in Kleinbuchstaben entsprechen." msgid "The text must match “release” in lowercase." msgstr "Der Text muss “release” in Kleinbuchstaben entsprechen." +msgid "There are no buckets available with the current search criteria. Try adjusting your search term." +msgstr "" + msgid "There are no Certificates available for this Certificate Authority." msgstr "" @@ -2620,12 +2776,18 @@ msgstr "Diese Aktion kann nicht rückgängig gemacht werden. Die Security Group msgid "This action cannot be undone. The target project will lose access to this security group immediately." msgstr "Diese Aktion kann nicht rückgängig gemacht werden. Das Zielprojekt verliert sofort den Zugriff auf diese Sicherheitsgruppe." +msgid "This action is irreversible. Deleting a bucket permanently removes it and cannot be undone. The bucket must be empty before deletion." +msgstr "" + msgid "This action is permanent. All objects in the container will be deleted and this cannot be undone." msgstr "Diese Aktion ist dauerhaft. Alle Objekte im Container werden gelöscht und dies kann nicht rückgängig gemacht werden." msgid "This action is permanent. The address will be removed from your project and returned to the public pool. This action cannot be undone." msgstr "Diese Aktion ist dauerhaft. Die Adresse wird aus Ihrem Projekt entfernt und dem öffentlichen Pool zurückgegeben. Dies kann nicht rückgängig gemacht werden." +msgid "This bucket contains {actualObjectCount} {actualObjectCount, plural, one {object} other {objects}} and cannot be deleted. Delete all objects first." +msgstr "" + msgid "This container appears empty — the object count may not have synced yet due to a recent operation." msgstr "Dieser Container scheint leer zu sein — die Objekt-Anzahl ist möglicherweise aufgrund einer kürzlichen Operation noch nicht synchronisiert." @@ -2683,6 +2845,9 @@ msgstr "Dieses Projekt hat bereits Zugriff auf den Flavor." msgid "This tenant does not have access to the flavor." msgstr "Dieses Projekt hat keinen Zugriff auf den Flavor." +msgid "This will permanently delete all objects from {totalCount} selected {totalCount, plural, one {bucket} other {buckets}}. This action cannot be undone." +msgstr "" + msgid "To confirm this action, type the word <0>“detach” in the field below." msgstr "Um diese Aktion zu bestätigen, geben Sie das Wort <0>“detach” in das Feld unten ein." @@ -2755,6 +2920,9 @@ msgstr "Geben Sie den Container-Namen zur Bestätigung ein" msgid "Type name" msgstr "Name eingeben" +msgid "Type the bucket name to confirm" +msgstr "" + msgid "Type to search containers..." msgstr "Zum Suchen von Containern eingeben..." diff --git a/apps/aurora-portal/src/locales/de/messages.ts b/apps/aurora-portal/src/locales/de/messages.ts index 046c0f5b0..0db50c102 100644 --- a/apps/aurora-portal/src/locales/de/messages.ts +++ b/apps/aurora-portal/src/locales/de/messages.ts @@ -1 +1 @@ -/*eslint-disable*/import type{Messages}from"@lingui/core";export const messages=JSON.parse("{\"+0B+ue\":[\"Projekte\"],\"+9CXS9\":[\"Images deaktivieren\"],\"+Jcye3\":[\"Keyname\"],\"+Lt5cp\":[\"Sie sind nicht berechtigt, Projektzugriffe hinzuzufügen. Bitte melden Sie sich erneut an.\"],\"+Nhol2\":[\"Certificate not found\"],\"+NwLgN\":[\"Durch die Aktivierung dieses Images kann es wieder zum Starten neuer Instances verwendet werden.\"],\"+Nx1wc\":[\"Floating IPs konnten nicht geladen werden\"],\"+OEi73\":[\"Object Storage (Swift)\"],\"+YQ9qu\":[\"Container: \",[\"containerName\"]],\"+nQTmZ\":[\"Dieses Projekt hat keinen Zugriff auf den Flavor.\"],\"+p6nHr\":[\"Fehler beim Laden der Objekt-Metadaten: \",[\"metadataErrorMessage\"]],\"+zy2Nq\":[\"Typ\"],\"/1MfrG\":[\"\\\"\",[\"objectName\"],\"\\\" konnte nicht heruntergeladen werden: \",[\"errorMessage\"]],\"/2a/eI\":[\"Flavor wird geladen...\"],\"/9Squ9\":[\"Sie haben keine Berechtigung, die Details dieses Flavors anzuzeigen.\"],\"/BZLRP\":[\"Um diese Aktion zu bestätigen, geben Sie das Wort <0>“detach” in das Feld unten ein.\"],\"/EcdUM\":[\"Ihre Aktion ist erforderlich\"],\"/HgF9q\":[\"Sortieren nach\"],\"/InK0O\":[\"Gesamtspeicher\"],\"/LqWNN\":[\"\\\"\",[\"objectName\"],\"\\\" konnte nicht gelöscht werden: \",[\"errorMessage\"]],\"/NeNjH\":[\"Eigenschaften des Containers \\\"\",[\"containerName\"],\"\\\" wurden erfolgreich aktualisiert.\"],\"/Nmxy/\":[\"Keine Keypaare verfügbar\"],\"/QIkBY\":[\"<0>Sicher & Zuverlässig: Ihre Daten und Operationen sind mit Sicherheitsstandards auf Unternehmensebene und robuster Zuverlässigkeit geschützt.\"],\"/Qox3b\":[\"Ein Ordner mit diesem Namen existiert bereits\"],\"/Z2leb\":[\"No containers found.\"],\"/Z5n1b\":[\"Ordner erstellen unter:\"],\"/bUiYk\":[\"Router-ID\"],\"/eFtWI\":[\"RBAC-Richtlinien\"],\"/xnbdQ\":[\"Der angegebene Benutzer hat Zugriff. Ein Token für den Benutzer (auf ein beliebiges Projekt bezogen) muss in der Anfrage enthalten sein.\"],\"01/uUD\":[\"Segmente beibehalten (nur Manifest löschen)\"],\"07WXfc\":[\"Der Server hat ein unerwartetes Datenformat für die Metadata zurückgegeben.\"],\"0BSSYj\":[\"Ein Serverfehler ist aufgetreten, während der Projektzugriff entfernt wurde. Bitte versuchen Sie es später noch einmal.\"],\"0Gd0NU\":[\"Geteilt\"],\"0P2gFy\":[\"Die gesuchte Seite existiert nicht.\"],\"0WsqO0\":[\"Container geleert\"],\"0cVgUw\":[\"Filtern nach\"],\"0eY8Mz\":[\"Für dieses Projekt sind keine Floating IPs verfügbar. Floating IPs ermöglichen es, öffentliche IP-Adressen Instances zuzuordnen.\"],\"0kCt7e\":[\"Die angegebenen Flavor-Daten sind ungültig. Bitte überprüfen Sie Ihre Eingabe.\"],\"0kc0zi\":[\"Beim Löschen des Metadata ist ein Serverfehler aufgetreten. Bitte versuchen Sie es später erneut.\"],\"0o0OhW\":[\"No objects found.\"],\"0p+s6m\":[\"Typ: \",[\"typeValue\"],\", Code: \",[\"codeValue\"]],\"0u9jhd\":[\"Das Trennen dieser Floating IP hebt die Zuordnung zum aktuellen Port auf. Die Instance ist über diese Adresse nicht mehr erreichbar.\"],\"16085O\":[\"IP-Version\"],\"1H2g6v\":[\"Objekt wird verschoben...\"],\"1NS3nd\":[[\"emptiedCount\",\"plural\",{\"one\":[\"#\",\" Container\"],\"other\":[\"#\",\" Container\"]}],\" erfolgreich geleert. \",[\"totalDeleted\",\"plural\",{\"one\":[\"#\",\" Objekt\"],\"other\":[\"#\",\" Objekte\"]}],\" insgesamt gelöscht.\"],\"1RwosK\":[\"Zielprojekt-ID ist erforderlich\"],\"1UzENP\":[\"Nein\"],\"1VDqZj\":[\"<0>Zukunftssicher: Aurora ist darauf ausgelegt, sich mit den neuesten Trends in der Cloud-Technologie weiterzuentwickeln und stellt so sicher, dass Ihre Lösung immer auf dem neuesten Stand ist.\"],\"1iQtS2\":[\"Erste \",[\"actualObjectCount\"],\" von \",[\"total\"],\" Objekten werden angezeigt\"],\"1iUuTT\":[\"Ihre Sitzung ist abgelaufen. Bitte melden Sie sich erneut an.\"],\"1ojTVo\":[\"DNS-Domain auswählen.\"],\"1pGUZa\":[\"Sitzung läuft ab in\"],\"1pdLQw\":[\"Image nicht gefunden\"],\"1rLu3+\":[\"Container \\\"\",[\"containerName\"],\"\\\" konnte nicht geleert werden: \",[\"errorMessage\"]],\"1rPB1p\":[\"Der Flavor oder das Projekt konnte nicht gefunden werden. Bitte überprüfen Sie, ob sie existieren.\"],\"1t/NnN\":[\"Ablehnen\"],\"1zZ1IK\":[\"Hallo\"],\"20E+79\":[\"Sie müssen sich anmelden, um auf diese Seite zuzugreifen.\"],\"20Kpaw\":[\"Flavor \\\"\",[\"flavorName\"],\"\\\" wurde erfolgreich gelöscht.\"],\"20axE5\":[\"Vom Projekt geteilt\"],\"23wBCX\":[\"Öffentlicher Lesezugriff\"],\"2G6hLq\":[\"Lösche \",[\"specKey\"]],\"2Inn83\":[\"Bulk-Upload von Archivdateien\"],\"2TtIL2\":[\"Gespeichert als X-Object-Meta-*-Header. Schlüssel sind nicht zwischen Groß- und Kleinschreibung unterscheidend.\"],\"2cJIlz\":[\"Floating Network-ID\"],\"2d/OiW\":[\"Geben Sie Ihren Benutzernamen ein\"],\"2dnZwV\":[\"Ordner \\\"\",[\"folderName\"],\"\\\" konnte nicht gelöscht werden: \",[\"errorMessage\"]],\"2gH+i8\":[\"Sie sind nicht berechtigt, Flavors zu löschen. Bitte melden Sie sich erneut an.\"],\"2lq0gq\":[\"<0>Eigenschaften von <1>\",[\"displayName\"],\"\"],\"2mbisJ\":[\"Metadaten \\\"\",[\"trimmedKey\"],\"\\\" wurden erfolgreich hinzugefügt.\"],\"2pnrGl\":[\"Erwartetes Format: JJJJ-MM-TT HH:MM:SS\"],\"2q/Q7x\":[\"Sichtbarkeit\"],\"2ysnjX\":[\"<0>Erhöhte Produktivität: Durch die Reduzierung der betrieblichen Komplexität hilft Aurora Ihrem Team, sich auf das Wesentliche zu konzentrieren – Innovationen voranzutreiben und Geschäftserfolg zu sichern.\"],\"2zceEg\":[\"Diese Aktion kann nicht rückgängig gemacht werden. Das Image wird dauerhaft gelöscht.\"],\"33F2A+\":[\"Geben Sie den Container-Namen zur Bestätigung ein\"],\"3AUpb4\":[\"Alle löschen (\",[\"selectedCount\"],\")\"],\"3Qn0me\":[\"Mitglied hinzufügen\"],\"3dBmvU\":[\"Der Container kann nicht gelöscht werden, da er Objekte enthält. Leeren Sie den Container zuerst.\"],\"3n+vCm\":[\"Benutzerdefinierte Dauer (Minuten)\"],\"3nWqQW\":[\"Sie sind nicht berechtigt, Metadata anzusehen. Bitte melden Sie sich erneut an.\"],\"3nh/7E\":[\"Wenn diese Option aktiviert ist, wird dieser Flavor für alle Projekte verfügbar sein. Wenn sie deaktiviert ist, muss der Zugriff explizit für bestimmte Projekt gewährt werden.\"],\"3oChIh\":[\"<0>Vereinheitlichtes Cloud-Management: Konsolidiert alle Ihre Cloud-Ressourcen in einer intuitiven Oberfläche.\"],\"3oc18/\":[\"Private Flavors konnten nicht geladen werden. Die angezeigte Liste ist möglicherweise unvollständig.\"],\"3q1GLx\":[\"Datei-Upload ausstehend...\"],\"3x7Sws\":[\"Security Group-Details werden geladen...\"],\"4+2wZO\":[\"Back to Certificate Authorities Details page\"],\"47eI0x\":[\"Die Beschreibung muss mindestens 1 Zeichen lang sein.\"],\"4EZrJN\":[\"Regeln\"],\"4O2AH3\":[\"Mitglied \\\"\",[\"memberIdToRemove\"],\"\\\" wurde erfolgreich entfernt.\"],\"4fh0Wj\":[\"Boot-Größe\"],\"4fvDRe\":[\"Zu aktivierende Images:\"],\"4fvcmm\":[\"Objekt wird hochgeladen als: <0>\",[\"selectedObjectName\"],\"\"],\"4h3Eyf\":[\"\\\"\",[\"objectName\"],\"\\\" wurde erfolgreich hochgeladen.\"],\"4kjaAc\":[\"Keine Servergruppen verfügbar\"],\"4mbrAq\":[\"1 Minute\"],\"4opp4r\":[\"Security Groups\"],\"4pOfUd\":[\"Unsere Mission\"],\"4t33sh\":[\"Fehler beim Aktualisieren des Objekts\"],\"4uXhtt\":[\"CIDR\"],\"4utWB4\":[\"Serverrolle:\"],\"5/wyf8\":[\"Floating IP eingeben\"],\"56IxdF\":[\"Fehler beim Laden der Container-Objekte: \",[\"errorMessage\"]],\"5BLR6Q\":[\"IPv4\"],\"5JDSvn\":[\"Maximale Metadaten-Wertlänge\"],\"5M4Te3\":[\"DNS\"],\"5MF8U2\":[\"Fehler beim Aktualisieren des Containers\"],\"5Okch2\":[\"Leeren:\"],\"5Yrl6N\":[\"Lädt Servergruppen\"],\"5aNQ3F\":[\"\\\"\",[\"objectName\"],\"\\\" wurde erfolgreich nach \",[\"destination\"],\" kopiert.\"],\"5g7owI\":[\"Floating IP wird aktualisiert...\"],\"5y3O+A\":[\"Deaktivieren\"],\"6+7EwD\":[\"Objekte als Index bereitstellen, wenn Dateiname ist:\"],\"6+OdGi\":[\"Protokoll\"],\"6/xipy\":[\"Container-Format\"],\"644xgx\":[\"Geschützt\"],\"6BDqha\":[\"Limits\"],\"6CDYXS\":[\"Statisches Website-Serving\"],\"6GBt0m\":[\"Metadata\"],\"6H/Lg1\":[\"Dies ist ein öffentliches Image. Alle Benutzer haben Zugriff darauf. Eine explizite Freigabe ist nicht erforderlich.\"],\"6KRclz\":[\"Ordner erstellt\"],\"6Kjltl\":[\"Zugriffskontrolle für Container:\"],\"6OopEX\":[\"Container geleert\"],\"6Rnrsz\":[\"Zugriff verwalten - \",[\"flavorName\"]],\"6X/9Di\":[\"\\\"\",[\"objectName\"],\"\\\" wurde erfolgreich nach \",[\"destination\"],\" verschoben.\"],\"6YtxFj\":[\"Name\"],\"6jAi8c\":[\"Bereich\"],\"6luZQA\":[\"Objekt verschoben\"],\"6oolxV\":[\"Dieser Metadata-Key existiert bereits. Bitte verwenden Sie einen anderen Key.\"],\"6qzsuS\":[\"Schreib-ACLs\"],\"6sxz+g\":[\"Port-Name\"],\"6w+VnM\":[\"Container erstellt\"],\"6z9W13\":[\"Neustart\"],\"76RKuS\":[\"ICMP-Code\"],\"78+riR\":[\"Sie sind nicht berechtigt, Projektzugriffe zu entfernen. Bitte melden Sie sich erneut an.\"],\"7AfIPZ\":[\"Floating Network\"],\"7BpykL\":[\"Fehler beim Erstellen des Extra-Specs. Bitte versuchen Sie es erneut.\"],\"7L01XJ\":[\"Aktionen\"],\"7NC3vm\":[\"Subnet\"],\"7NSdfG\":[\"Container \",[\"progressCurrent\"],\" von \",[\"progressTotal\"],\" wird geleert, bitte warten...\"],\"7Q24LN\":[\"Richtlinie\"],\"7T1fHv\":[\"Fehler beim Entfernen des Mitglieds \\\"\",[\"memberIdToRemove\"],\"\\\"\"],\"7UlHhT\":[\"Metadaten \\\"\",[\"keyToDelete\"],\"\\\" wurden erfolgreich gelöscht.\"],\"7XQ3QJ\":[\"Abgelehnter Referrer: \",[\"host\"]],\"7ZnTL8\":[\"Fehler beim Aktualisieren des Objekts: \",[\"mutationErrorMessage\"]],\"7a4DvD\":[\"Keine Server verfügbar\"],\"7d1a0d\":[\"Öffentlich\"],\"7flw0l\":[\"Der Projektzugriff für \\\"\",[\"trimmedTenantId\"],\"\\\" wurde erfolgreich hinzugefügt.\"],\"7huC4O\":[\"There are no Certificates available for this Certificate Authority.\"],\"7sMeHQ\":[\"Key\"],\"88kg0+\":[\"Erstellt am\"],\"8AriEH\":[\"Image-Instanz \\\"\",[\"imageName\"],\"\\\" wurde erstellt\"],\"8S2nDL\":[\"No PCAs found\"],\"8TSI9h\":[\"Durch die Deaktivierung dieses Images kann es nicht mehr zum Starten neuer Instances verwendet werden. Bestehende Instances sind nicht betroffen.\"],\"8Tg/JR\":[\"Benutzerdefiniert\"],\"8ZOb7O\":[[\"numberDeleted\"],\" Objekt wurde dauerhaft gelöscht.\"],\"8ZsakT\":[\"Passwort\"],\"8c3/77\":[\"Maximale Metadaten-Namenslänge\"],\"8jLXs3\":[\"Versionierte Schreibvorgänge\"],\"8s0tOH\":[\"Sie haben keine Berechtigung, diesem Flavor Projektzugriffe hinzuzufügen.\"],\"8t1+HU\":[[\"successCount\"],\" Image(s) deaktiviert, aber \",[\"failedCount\"],\" Image(s) konnten nicht deaktiviert werden.\"],\"8uPTwT\":[[\"filteredCount\",\"plural\",{\"one\":[[\"filteredCount\"],\" von \",[\"totalCount\"],\" Container\"],\"other\":[[\"filteredCount\"],\" von \",[\"totalCount\"],\" Containern\"]}]],\"8wdCNd\":[\"tcp, udp, icmp oder Protokollnummer\"],\"8zAn1f\":[\"Fehler beim Löschen des Flavors. Bitte versuchen Sie es erneut.\"],\"98Fs4G\":[\"Image wird erstellt...\"],\"9J93Xr\":[\"Container-Name darf keine Schrägstriche enthalten\"],\"9SX0bO\":[\"Das Image \\\"\",[\"imageName\"],\"\\\" konnte nicht aktualisiert werden: \"],\"9X8lAk\":[\"Zuweisen\"],\"9doWrf\":[\"Fehler beim Hinzufügen des Mitglieds\"],\"9dsDHD\":[\"Das Image \\\"\",[\"imageId\"],\"\\\" konnte nicht reaktiviert werden: \",[\"message\"]],\"9iz2XW\":[\"Image kann nicht aktualisiert werden\"],\"9njIiV\":[\"Fehler beim Aktivieren der Images\"],\"9rz81C\":[\"Geräte-ID\"],\"9v5VLp\":[\"Keine benutzerdefinierten Eigenschaften definiert\"],\"9vSW3U\":[\"Rekursiv löschen\"],\"9x6EkK\":[\"Dies ist ein öffentlicher Flavor. Alle Projekte haben Zugriff darauf.\"],\"A7CVME\":[\"Wählen Sie zuerst das Festplattenformat\"],\"AB4Tnl\":[\"Bitte wählen Sie eine Datei zum Hochladen aus\"],\"AGXLLY\":[\"Image-Datei kann nicht hochgeladen werden\"],\"AJRhSM\":[\"Root Disk muss eine ganze Zahl ≥ 0 sein.\"],\"AN0DBJ\":[\"Enter drücken zum Hinzufügen\"],\"AX9Juz\":[\"Die ID darf nur alphanumerische Zeichen, Bindestriche, Unterstriche und Punkte enthalten.\"],\"AZyHwC\":[\"Muss eine gültige IPv4- oder IPv6-Adresse sein (z. B.: 172.24.4.228 oder 2001:db8::1).\"],\"Ac6dy9\":[\"Name eingeben\"],\"AdtLNV\":[\"Stellen Sie sicher, dass ACL-Einträge gültig sind — korrekte Projekt-IDs, Benutzer-IDs und Formate liegen in Ihrer Verantwortung. Ungültige Einträge können stillschweigend unbeabsichtigten Zugriff gewähren oder verweigern.\"],\"AeXO77\":[\"Account\"],\"Afh/Lb\":[\"Zielordner auswählen\"],\"AlbUVn\":[\"<0>Optimierte Skalierbarkeit: Aurora ist für Unternehmen jeder Größe konzipiert und wächst mit Ihnen mit, unterstützt einfache Umgebungen und komplexe Multi-Cloud-Setups gleichermaßen.\"],\"Alx2/L\":[\"In neuem Tab öffnen\"],\"AuQtzx\":[\"Muss eine nicht-negative Ganzzahl sein\"],\"AxZkIr\":[\"Disk (GiB)\"],\"B2Czeb\":[\"Min. RAM\"],\"B2i9cQ\":[\"Zu löschende Objekte (\",[\"totalCount\"],\")\"],\"B3toQF\":[\"Objekte\"],\"B4Jzm7\":[\"Ceph\"],\"BCJPTn\":[\"Zugriff für alle Benutzer aus diesem Projekt gewähren.\"],\"BCXapL\":[\"Fehler beim Laden der Container-Eigenschaften: \",[\"errorMessage\"]],\"BJt+PJ\":[\"Fehler beim Löschen des Containers\"],\"BMTd81\":[\"Diese Aktion kann nicht rückgängig gemacht werden. Das Zielprojekt verliert sofort den Zugriff auf diese Sicherheitsgruppe.\"],\"BMogtG\":[\"Issue End Entity Certificate\"],\"BOQYRn\":[\"Lädt Key Pairs...\"],\"BP4Fwj\":[\"Fehler beim Laden der Objekte: \",[\"errorMessage\"]],\"BSaBkZ\":[\"Objekte — \",[\"containerName\"]],\"BYH/2L\":[\"Image kann nicht deaktiviert werden\"],\"BZpsYm\":[\"Failed to load containers: \",[\"errorMessage\"]],\"BgMp/T\":[\"Ungültige Formatkombination für das ausgewählte Festplattenformat\"],\"Blsc/x\":[\"Delete Certificate Authority\"],\"BoIAP6\":[\"Die ID des Netzwerks, das der Floating IP zugeordnet ist.\"],\"BoPocW\":[\"MD5-Prüfsumme\"],\"BrrIs8\":[\"Storage\"],\"CA8ZeT\":[\"Sichtbarkeit des Images \\\"\",[\"imageName\"],\"\\\" auf \",[\"visibility\"],\" aktualisiert\"],\"CBFSfX\":[\"Bitte korrigieren Sie die Validierungsfehler unten.\"],\"CFMxC8\":[\"Images gelöscht\"],\"CMVP7y\":[\"Diese Aktion kann nicht rückgängig gemacht werden. Die Regel wird dauerhaft gelöscht.\"],\"CgZxr7\":[\"Min. RAM (MB)\"],\"ChOuUj\":[\"Floating IP nicht gefunden\"],\"Cj2Gtd\":[\"Größe\"],\"ClGcRq\":[\"Container\"],\"Cu6xuZ\":[\"Dies ist ein <0>dynamisches großes Objekt (DLO)-Manifest. Metadaten-Änderungen gelten nur für das Manifest — Segment-Objekte sind nicht betroffen.\"],\"CunRry\":[\"Ungültiges Projekt-ID-Format. Muss 32 hexadezimale Zeichen sein (z. B. b90f9c4bc76140e18540b2cec1299e2a) oder UUID-Format (z. B. 12345678-1234-1234-1234-123456789abc)\"],\"Cxgv2U\":[\"Min. Disk\"],\"D/8vkD\":[\"Es wird in Ihrer Image-Liste angezeigt.\"],\"D3IRXw\":[\"Floating IP wird getrennt...\"],\"D7qT9F\":[\"Warum Aurora wählen?\"],\"DDRhQm\":[\"Ihre Sitzung ist abgelaufen.\"],\"DHrCY6\":[\"Common name\"],\"DJT9tB\":[\"Account-Quotas\"],\"DKkOPx\":[\"Zusätzliche Spezifikationen\"],\"DNVql8\":[\"Vollständiges Lifecycle-Management von Floating IPs, einschließlich Zuweisung, Port-Zuordnung/-Aufhebung, DNS-Einstellungen und Löschung\"],\"DcMIiu\":[\"ACLs für Container \\\"\",[\"containerName\"],\"\\\" konnten nicht aktualisiert werden: \",[\"errorMessage\"]],\"Df0YHr\":[\"Security Group aktualisieren\"],\"Dh1qvV\":[\"Sie sind dabei, \",[\"deletableCount\"],\" Image(s) zu löschen. Diese Aktion kann nicht rückgängig gemacht werden.\"],\"Dia2Ue\":[\"Für diese Security Group sind keine RBAC-Richtlinien vorhanden\"],\"Do5/uH\":[\"Der Flavor oder das Projekt konnte nicht gefunden werden. Möglicherweise wurde es bereits entfernt.\"],\"Dqnh7K\":[\"Spezifischer Referrer: \",[\"host\"]],\"Dt5W9T\":[\"RBAC-Richtlinie entfernen\"],\"DvB4XF\":[\"Legen Sie Ihre Datei hier ab\"],\"E/QGRL\":[\"Deaktiviert\"],\"E4QYe7\":[\"Empfohlene Images\"],\"E6nRW7\":[\"URL kopieren\"],\"EF2EU9\":[\"Wird gelöscht...\"],\"EPMHs9\":[\"Sie haben keine Berechtigung, Flavors in diesem Projekt zu löschen.\"],\"EQnVgi\":[\"Der Flavor-Service ist für dieses Projekt nicht verfügbar.\"],\"EdQY6l\":[\"Keine\"],\"Ef7StM\":[\"Unknown\"],\"Enpdmy\":[\"Geben Sie <0>entfernen zur Bestätigung ein:\"],\"EoKe5U\":[\"Domain\"],\"Eq5PsT\":[\"\\\"detach\\\" zur Bestätigung eingeben\"],\"EqSPkP\":[\"Lädt Flavors...\"],\"Erlvqg\":[\"Objektname darf keine führenden oder nachfolgenden Leerzeichen haben\"],\"ExLULX\":[\"Image-Name\"],\"EztMB8\":[\"Fehler beim Abrufen der Flavors vom Server.\"],\"F02e8I\":[\"Keine benutzerdefinierten Metadaten. Klicken Sie auf \\\"Eigenschaft hinzufügen\\\", um eine zu erstellen.\"],\"F6YIQe\":[\"Effiziente Massenlöschung\"],\"FKL6Jv\":[\"z. B. .r:*,.rlistings\"],\"FNcMGM\":[\"Creation Date\"],\"FOcBn3\":[\"Trennen\"],\"FQBaXG\":[\"Aktivieren\"],\"FRtmJJ\":[\"Storage-Container nicht gefunden\"],\"FSbpS7\":[\"CPU\"],\"FjONW3\":[\"Fehler beim Laden des Flavors\"],\"FjPnAE\":[\"Fehler beim Laden der Security Group\"],\"Flugry\":[[\"progressPct\"],\"%\"],\"FwSyEp\":[\"Das angegebene Projekt existiert nicht oder Sie haben keine Berechtigung, es zu teilen.\"],\"Fzrzfe\":[\"Ordnername ist erforderlich\"],\"G6AP+o\":[\"Geteilt:\"],\"GDx4dP\":[\"Manage your Certificate\"],\"GEgjm+\":[\"Objekte werden geladen...\"],\"GPuCEo\":[\"Leer lassen für alle Typen\"],\"GSIPwA\":[\"Temporäre URL\"],\"GbKqnI\":[[\"successCount\"],\" Image(s) aktiviert, aber \",[\"failedCount\"],\" Image(s) konnten nicht aktiviert werden.\"],\"Gfx1qQ\":[\"Inhalt kann nicht geladen werden\"],\"GxkJXS\":[\"Wird hochgeladen...\"],\"Gyd3No\":[\"Kein spezifischer Projektzugriff für diesen privaten Flavor konfiguriert. Klicken Sie auf \\\"Projektzugriff hinzufügen\\\", um Zugriff zu gewähren.\"],\"H+a5j6\":[\"Freigeben\"],\"H4Qwmp\":[\"Keine Objekte entsprechen Ihrer Suche. Versuchen Sie, Ihren Suchbegriff anzupassen.\"],\"H7u085\":[\"Noch kein Projekt hat Zugriff auf dieses Image. Klicken Sie auf \\\"Projektzugriff hinzufügen\\\", um Zugriff zu gewähren.\"],\"HAkrpK\":[\"Bei Aurora ist es unsere Mission, eine zentrale Plattform bereitzustellen, die das Cloud-Management vereinheitlicht. Wir streben danach, die Komplexitäten der Bereitstellung, Konfiguration und Skalierung von Ressourcen über verschiedene Cloud-Umgebungen hinweg zu vereinfachen und gleichzeitig ein nahtloses Wachstum für Ihr Unternehmen zu ermöglichen.\"],\"HBpi4q\":[\"Lädt Images...\"],\"HG0uMz\":[\"Back to Certificate Authorities\"],\"HM56Bx\":[\"Creating...\"],\"HNlEFZ\":[\"delete\"],\"HQH8HM\":[\"\\\"\",[\"objectName\"],\"\\\" konnte nicht aktualisiert werden: \",[\"errorMessage\"]],\"HVdrr1\":[\"Beliebiger Referrer\"],\"HivZR9\":[\"Create Credential\"],\"Hivb/4\":[\"Der Server hat Probleme. Bitte versuchen Sie es später erneut.\"],\"Hiw1Ha\":[\"Keine Container gefunden\"],\"HlwgQN\":[\"Objekt \\\"\",[\"objectName\"],\"\\\" wurde dauerhaft gelöscht.\"],\"HuA8iQ\":[\"Floating IP wird zugewiesen...\"],\"HxTYrE\":[\"Der Flavor konnte nicht gefunden werden. Möglicherweise wurde er gelöscht.\"],\"I5kZVK\":[\"Entfernte Quelle\"],\"INUP6f\":[\"<0>Mühelose Ressourcenbereitstellung: Stellen Sie Ressourcen wie Server, Netzwerke und Volumes schnell bereit, konfigurieren Sie diese und setzen Sie sie mit nur wenigen Klicks ein.\"],\"IOkHLC\":[\"Fehler beim Kopieren des Objekts: \",[\"errorMessage\"]],\"IQSLN+\":[\"Error loading Certificate Authority\"],\"IUwGEM\":[\"Änderungen speichern\"],\"IWF68U\":[\"Speicherübersicht\"],\"IZ6Mh2\":[\"Geben Sie den Domain ein\"],\"IbYr/u\":[\"Content type\"],\"Io2Dvq\":[\"Certificate Authority not found\"],\"Ioblgz\":[\"Diese Aktion ist dauerhaft. Alle Objekte im Container werden gelöscht und dies kann nicht rückgängig gemacht werden.\"],\"J4DKSM\":[\"Container-Format ist erforderlich\"],\"J6EOll\":[\"Objekt verschieben/umbenennen:\"],\"J7+bZb\":[\"Ordner gelöscht\"],\"J9QcnV\":[[\"successCount\"],\" von \",[\"totalCount\"],\" Image(s) erfolgreich aktiviert\"],\"J9cmxx\":[\"Fehler beim Aktualisieren der Sichtbarkeit auf \",[\"newVisibility\"]],\"JB0bhm\":[\"Mitmachen\"],\"JNGYAW\":[\"Container-Name ist erforderlich\"],\"JT3I1g\":[\"Flavor löschen\"],\"JeRXll\":[\"Dieser Schlüssel ist reserviert und wird separat verwaltet\"],\"JfWCsP\":[\"Teilweise erfolgreich deaktiviert\"],\"Jh4rAZ\":[\"Fehler beim Laden des Images\"],\"Jim5X9\":[\"Stateful\"],\"JoECY1\":[\"Die bereitgestellten Metadata-Daten sind ungültig. Bitte überprüfen Sie Ihre Eingabe.\"],\"JpZn1L\":[\"Bereits deaktiviert (wird übersprungen)\"],\"JrmKyf\":[\"Fehlgeschlagen: \",[\"errorDetails\"]],\"JtHgVz\":[\"Images löschen\"],\"K+e/0e\":[\"RAM (MiB)\"],\"K3bUTE\":[\"Minimale Festplatte muss 0 oder größer sein\"],\"K8Qnlj\":[\"Wird verschoben...\"],\"K9eC8x\":[\"Dies kann auf unzureichende Berechtigungen oder ein vorübergehendes Serviceproblem zurückzuführen sein. Bitte überprüfen Sie Ihre Zugriffsrechte oder aktualisieren Sie die Seite.\"],\"KDw4GX\":[\"Erneut versuchen\"],\"KJC+M7\":[\"Serverfehler beim Abrufen der Flavor-Details. Bitte versuchen Sie es später erneut.\"],\"KOpPMt\":[\"Gesamtspeicher-Quota\"],\"KSW/GC\":[\"Keine Flavors verfügbar. Filtern Sie neu oder erstellen Sie einen neuen Flavor.\"],\"KZN4Lc\":[\"Alle löschen\"],\"Km4AGG\":[\"Sicherheitsgruppe wird erstellt...\"],\"KoQP4F\":[\"Ein Serverfehler ist aufgetreten, während der Projektzugriff hinzugefügt wurde. Bitte versuchen Sie es später noch einmal.\"],\"KsIM0b\":[\"Boot-RAM\"],\"KsnZ3m\":[\"Ordner \\\"\",[\"folderName\"],\"\\\" wurde erfolgreich erstellt.\"],\"KzUd7m\":[\"new-folder-name\"],\"LI70tz\":[\"Error loading Certificate\"],\"LI8Z2I\":[[\"rowDisplayName\"],\" herunterladen\"],\"LK0pQN\":[\"Festplattenformat ist erforderlich\"],\"LMdsuJ\":[\"Port (von)\"],\"LQQCas\":[\"Ordner \\\"\",[\"folderName\"],\"\\\" und \",[\"deletedCount\"],\" Objekt wurden dauerhaft gelöscht.\"],\"Llcakz\":[\"Aktualisiert am\"],\"LqMb+g\":[\"Um diese Aktion zu bestätigen, geben Sie das Wort <0>\\\"release\\\" in das Feld unten ein.\"],\"LtI9AS\":[\"Eigentümer\"],\"Lylr9Z\":[\"Objekt kopiert\"],\"M470oJ\":[\"Der Flavor konnte nicht gefunden werden oder hat keine Metadata.\"],\"M5Epeo\":[\"Image-Details bearbeiten\"],\"M5RhXF\":[\"Wird entfernt...\"],\"M5rEN5\":[\"Sitzung abgelaufen\"],\"M9H+/G\":[\"Projekte\"],\"MEIAzV\":[\"Unbenannt\"],\"MILoeL\":[\"Services\"],\"MJtNLd\":[\"Zu löschende Images:\"],\"MOug+V\":[\"Geben Sie ein Tag ein und drücken Sie Enter oder klicken Sie auf Hinzufügen\"],\"MRB7nI\":[\"Richtung\"],\"MXoA/6\":[\"Objekt hochladen\"],\"MXw7Fr\":[\"Servername\"],\"MZGbkp\":[\"VCPUs\"],\"MbKJNP\":[\"Sie haben keine Berechtigung, auf die Zugriffsinformationen für diesen Flavor zuzugreifen.\"],\"MgZyuJ\":[\"Sie sind dabei, <0>\",[\"deactivatedCount\"],\" Image(s) zu aktivieren. Aktivierte Images stehen zum Starten neuer Instances zur Verfügung.\"],\"MmtQVF\":[\"Ungültiger Wert für die Einstellung des öffentlichen Flavors.\"],\"Mt6sRo\":[\"Sie sind nicht berechtigt, auf die Zugriffsinformationen des Flavors zuzugreifen. Bitte melden Sie sich erneut an.\"],\"MtzSbv\":[\" Objektname ist erforderlich\"],\"MuKU9V\":[\"Failed to load objects: \",[\"errorMessage\"]],\"N2S1rs\":[\"Leeren\"],\"N5I2RJ\":[\"\\\"release\\\" zur Bestätigung eingeben\"],\"N5vGcw\":[\"Geben Sie Ihre Anmeldedaten ein, um auf Ihr Konto zuzugreifen.\"],\"NH2fsP\":[\"Bereits deaktiviert (wird übersprungen):\"],\"NOdFZR\":[\"Wird generiert...\"],\"NQU1Nn\":[\"Container-Name kopieren\"],\"NRMm0E\":[\"Dieses Projekt hat bereits Zugriff auf den Flavor.\"],\"NRP2uq\":[\"Objekt teilen:\"],\"NRVSdy\":[\"Mitglieds-ID\"],\"NW4PIb\":[\"Ordner \\\"\",[\"folderName\"],\"\\\" konnte nicht erstellt werden: \",[\"errorMessage\"]],\"NZJhro\":[\"Objektname darf keine Schrägstriche enthalten\"],\"Nc7QKU\":[\"Feste IP-Adresse\"],\"NeUjqc\":[\"Dateilisting aktivieren\"],\"NixRmA\":[\"Min. Disk (GB)\"],\"NlcF/v\":[\"Kein Flavor für die Löschung ausgewählt.\"],\"NopYGU\":[\"Festplattenformat\"],\"Np28ib\":[\"oder per Drag & Drop\"],\"Nu4oKW\":[\"Beschreibung\"],\"Nvfd2b\":[\"Versionierung ist aktiviert\"],\"O80bQY\":[\"Objekt-Eigenschaften werden geladen...\"],\"O8tK4v\":[\"Regel hinzufügen\"],\"ONWvwQ\":[\"Hochladen\"],\"OR475H\":[\"Netzwerk\"],\"OSlLnz\":[\"Image-Sichtbarkeit\"],\"OYHzN1\":[\"Tags\"],\"OZImTR\":[\"Container-Listing-Limit\"],\"OaSktR\":[\"Geräteeigentümer\"],\"Oc8Aqv\":[\"Vorschau und Metadaten bearbeiten\"],\"OlmKCg\":[\"Ein Flavor mit dieser ID oder diesem Namen existiert bereits. Bitte verwenden Sie andere Werte.\"],\"OvEjsP\":[\"Wird kopiert...\"],\"Ovofy+\":[\"Floating IP \",[\"floating_ip_address\"],\" freigeben\"],\"OxDN2m\":[\"Fehler beim Erstellen des Flavors. Bitte versuchen Sie es erneut.\"],\"OxaeYj\":[\"Wir entwickeln das Aurora-Dashboard, um Ihnen einen besseren Service zu bieten. Ihr Feedback ist von unschätzbarem Wert, um ein Werkzeug zu gestalten, das den einzigartigen Bedürfnissen von Unternehmen wie Ihrem gerecht wird. Bleiben Sie in Verbindung und begleiten Sie uns, während wir das Cloud-Management neu definieren.\"],\"Oxl1UN\":[\"Wenn keine Index-Datei vorhanden ist, zeigt die URL eine Liste der Objekte im Container an.\"],\"PAKSdy\":[\"Floating IP eingeben oder leer lassen für automatische Zuweisung\"],\"PEGvy+\":[\"Minimaler RAM muss 0 oder größer sein\"],\"PHsq3v\":[\"Stellen Sie vor dem Fortfahren sicher, dass die eingegebene Projekt-ID und Benutzer-ID korrekt sind. Das System kann diese Werte nicht validieren, und falsche IDs können Zugriff auf falsche Projekte und Benutzer gewähren.\"],\"PHt+EV\":[\"<0>delete zur Bestätigung eingeben:\"],\"PIbPRX\":[\"RX/TX Faktor muss eine ganze Zahl ≥ 1 sein.\"],\"PLwzWR\":[\"Alle Container\"],\"PYQUjU\":[\"Metadaten-Konfiguration konnte nicht geladen werden.\"],\"PZnUbs\":[\"Bitte melden Sie sich erneut an, um fortzufahren.\"],\"PgNNGl\":[\"Weitere Aktionen\"],\"PiH3UR\":[\"Kopiert!\"],\"PiyQJ/\":[\"Keine Flavors gefunden\"],\"PkfPsB\":[\"Geben Sie die ID des Projekts ein, mit dem Sie diese Sicherheitsgruppe teilen möchten. Sie finden Projekt-IDs im Konto-/Projekt-Umschalter oder im Identity-Service.\"],\"Pkw7J9\":[\"Dieser Ordner ist leer.\"],\"PsEGri\":[\"Ubuntu 22.04 LTS\"],\"PtjzS+\":[\"Wird dem ausgewählten Port zugeordnet. Wenn der Port mehrere IPs hat, wählen Sie die gewünschte feste IP-Adresse.\"],\"PzgYM9\":[\"Prüfsumme\"],\"Q1W//7\":[\"Keine Dienste für dieses Projekt verfügbar.\"],\"Q2xmVl\":[\"Symlinks\"],\"Q9f2QF\":[[\"numberDeleted\"],\" Objekte wurden erfolgreich gelöscht, aber einige Löschvorgänge sind fehlgeschlagen.\"],\"QAUa4B\":[\"Geben Sie einen einzelnen Port ein oder definieren Sie einen Bereich, indem Sie auch \\\"Port (bis)\\\" ausfüllen. \\\"Port (bis)\\\" ist optional.\"],\"QEtDlS\":[\"Objekt wird kopiert...\"],\"QNHur0\":[\"Fehler beim Laden der Container-ACLs: \",[\"errorMessage\"]],\"QQ8wUG\":[\"Diese Aktion kann nicht rückgängig gemacht werden. Der Flavor wird dauerhaft gelöscht.\"],\"QV1ZPO\":[\"Schlüssel ist erforderlich\"],\"QWdKwH\":[\"Verschieben\"],\"QYiqYb\":[\"Fehler beim Aktualisieren des Zugriffsstatus\"],\"Qb+14I\":[\"Diese Aktion kann nicht rückgängig gemacht werden. Die Security Group wird dauerhaft gelöscht.\"],\"QetsXP\":[\"Upload fehlgeschlagen: \",[\"uploadError\"]],\"Qg4EG6\":[\"Die bereitgestellten Flavor-Daten sind ungültig. Bitte überprüfen Sie Ihre Eingabe.\"],\"QuJSSl\":[\"Fehler beim Erstellen des Flavors. Bitte versuchen Sie es erneut.\"],\"QvqBQa\":[\"Ziel-Container\"],\"Qx7DM7\":[\"Capabilities\"],\"QxBGbh\":[\"Geschützt (wird übersprungen):\"],\"QytzQr\":[\"Geben Sie \\\"delete\\\" zur Bestätigung ein\"],\"R6kcsL\":[\"Muss eine gültige PQDN oder FQDN sein (nur alphanumerische Zeichen und Bindestriche, darf nicht mit einem Bindestrich beginnen oder enden).\"],\"R6u5CR\":[[\"failedCount\"],\" von \",[\"totalCount\"],\" Image(s) konnten nicht aktiviert werden. Einige Images sind möglicherweise bereits aktiv oder in einem ungültigen Zustand.\"],\"RByeNR\":[\"Ihre Sitzung ist abgelaufen. Bitte melden Sie sich erneut an.\"],\"RCr0yv\":[\"Flavor-Details konnten nicht geladen werden. Bitte versuchen Sie es erneut.\"],\"RFDYCD\":[\"Minimale Festplattengröße, die zum Booten dieses Images erforderlich ist\"],\"RGhYAo\":[\"RAM\"],\"RGrgxg\":[\"Container \\\"\",[\"containerName\"],\"\\\" konnte nicht gelöscht werden: \",[\"errorMessage\"]],\"RGwfoL\":[\"Maximale Metadaten-Anzahl\"],\"RNBvdl\":[\"Maximale SLO-Segmente\"],\"RS0o7b\":[\"State\"],\"RSFkXF\":[\"Image aktivieren\"],\"RSMPjT\":[\"Sie befinden sich derzeit auf der Dashboard-Route.\"],\"RSg/pq\":[\"Fehler beim Löschen des Objekts\"],\"RTQFAw\":[\"Sie sind nicht berechtigt, Metadata zu erstellen. Bitte melden Sie sich erneut an.\"],\"RWQ6BN\":[\"Enter Common name (e.g., demo-ca.test.sci)\"],\"Rih53k\":[\"Maximale Container-Namenslänge\"],\"Rlp5zj\":[\"Flavor erstellen\"],\"S0kLOH\":[\"ID\"],\"S1iTXO\":[\"Image-Instanz \\\"\",[\"imageName\"],\"\\\" wurde gelöscht\"],\"S3olSf\":[\"Keine Metadata gefunden. Klicken Sie auf \\\"Metadata hinzufügen\\\", um eine zu erstellen.\"],\"S5CUKP\":[\"Mitglieds-ID (Projekt-UUID) ist erforderlich.\"],\"S63NbU\":[\"Das Image \\\"\",[\"imageId\"],\"\\\" konnte nicht deaktiviert werden: \",[\"message\"]],\"S8/j2h\":[\"Fehler beim Leeren des Containers\"],\"SBGiGm\":[\"Lese-ACLs\"],\"SCY5an\":[\"Fehler beim Verschieben des Objekts\"],\"SFo0kK\":[\"Alle Images\"],\"SIfYq6\":[\"Metadaten bearbeiten\"],\"SLEH7X\":[\"DNS-Name eingeben\"],\"STc+7E\":[\"Maximale Container pro Extraktion\"],\"SU0uxT\":[\"Objekt hochladen nach:\"],\"SUSS9i\":[\"Container-Name\"],\"SVLToM\":[\"\\\"remove\\\" zur Bestätigung eingeben\"],\"SZw9tS\":[\"Details ansehen\"],\"Sb/VT5\":[\"Container \\\"\",[\"containerName\"],\"\\\" wurde erfolgreich geleert. \",[\"deletedCount\"],\" Objekte gelöscht.\"],\"Sf3Gvg\":[\"Failed to load PCAs\"],\"SfW/3r\":[\"Keine Gruppen vorhanden\"],\"Sgz1vJ\":[\"Mitglied \\\"\",[\"trimmedMemberId\"],\"\\\" wurde erfolgreich hinzugefügt.\"],\"Smk7M2\":[\"Fehler beim Laden der Floating-IP\"],\"SuX2Ca\":[\"Grundlegende Informationen\"],\"SysqAR\":[\"Flavor-Details\"],\"T6Gm5y\":[\"Externes Netzwerk auswählen\"],\"T7mgdd\":[[\"successCount\"],\" von \",[\"totalCount\"],\" Image(s) erfolgreich gelöscht\"],\"T8N6oi\":[\"Eigenschaftsschlüssel\"],\"T9Mtpi\":[\"Projekt ID\"],\"T9o/az\":[\"Loading Certificates issued by Certificate Authority...\"],\"TM93nK\":[\"Sicherheitsgruppenregel löschen\"],\"TPMaxo\":[\"“release” zur Bestätigung eingeben\"],\"TQn3hH\":[\"Image konnte nicht erstellt werden. Bitte versuchen Sie es erneut.\"],\"TZJiVf\":[\"Container \\\"\",[\"containerName\"],\"\\\" wurde erfolgreich geleert. \",[\"deletedCount\"],\" Objekt gelöscht.\"],\"TfC9O+\":[\"Zuletzt geändert (UTC)\"],\"TfdeUd\":[\"Fehler beim Löschen des Extra-Specs. Bitte versuchen Sie es erneut.\"],\"TpGxnq\":[\"Mitglieds-ID eingeben\"],\"Tx4Ym+\":[\"Gültige PQDN oder FQDN (max. 63 Zeichen) eingeben, um sie der Floating IP zuzuordnen. A- und PTR-Einträge werden automatisch erstellt.\"],\"TyODHt\":[\"Metadata speichern\"],\"U/oahm\":[\"URL kopiert\"],\"U2wTy/\":[\"Hinweis: Das Attribut 'stateful' kann nicht geändert werden, wenn diese Security Group derzeit von einem oder mehreren Ports verwendet wird.\"],\"U4fmHG\":[\"Der Text muss “detach” in Kleinbuchstaben entsprechen.\"],\"U6L+P/\":[\"Inaktivitäts-Timeout\"],\"U9q4M7\":[\"Zurück zu Security Groups\"],\"UB+Q8v\":[\"Loading Certificate Details...\"],\"UGhVPl\":[\"Objekttyp\"],\"UJVf0u\":[\"Image wird geladen...\"],\"UJmAAK\":[\"Subject\"],\"UK2mpr\":[\"Temporäre URL wird generiert...\"],\"UKwOYH\":[\"Image-Datei\"],\"UO3hJ2\":[\"Temporäre URLs\"],\"UQ7Wyv\":[\"Zugriff für Image verwalten - \",[\"imageName\"]],\"URmyfc\":[\"Details\"],\"USiuNX\":[\"Container-Quotas\"],\"UVFHGY\":[\"z. B. PROJECT_ID:USER_ID\"],\"UVSFVV\":[\"Geteiltes Image ablehnen\"],\"UYSopm\":[\"Minimaler RAM (MB)\"],\"UbRKMZ\":[\"Ausstehend\"],\"UbWeJA\":[\"Duration/validity\"],\"UdcGJu\":[\"Images aktivieren\"],\"UiNv/G\":[\"S3 Object Storage requires EC2 credentials (access key + secret key) to authenticate your requests. You need to create credentials before accessing S3 resources.\"],\"Uj+n/2\":[\"Fehler beim Löschen des Ordners\"],\"UkVkoq\":[\"Leer lassen für alle Codes\"],\"UmQ3/m\":[\"Ausgewählte deaktivieren\"],\"Uwo8Xw\":[\"Dieses Image wurde am \",[\"sharedAt\"],\" von <0>\",[\"ownerProject\"],\" mit Ihnen geteilt.\"],\"UztfYZ\":[\"Port zur Zuordnung auswählen\"],\"V/8B9A\":[\"Ich bestätige, dass alle vorhandenen Versionen ebenfalls gelöscht werden\"],\"V/SINY\":[\"Objekt aktualisieren\"],\"V1TzeS\":[\"Container \\\"\",[\"containerName\"],\"\\\" wurde erfolgreich gelöscht.\"],\"V66Jih\":[\"Zugriffsstatus\"],\"V7fN5X\":[\"Objekt kopieren:\"],\"V804LY\":[\"Security Group wird aktualisiert...\"],\"VCM3KS\":[\"Projektzugriff hinzufügen\"],\"VKmlZ+\":[\"Zu leerende Container (\",[\"totalCount\"],\")\"],\"VLI9eO\":[\"Floating IP-Details werden geladen...\"],\"VMh1t1\":[\"The text must match “delete” in lowercase.\"],\"VV1fdg\":[\"Jeder Benutzer hat Lesezugriff auf Objekte. Es ist kein Token in der Anfrage erforderlich.\"],\"VaA9mu\":[\"24 Stunden\"],\"VakxP/\":[\"Fehler beim Hochladen des Objekts\"],\"Vg0k6h\":[\"Zeige \",[\"filteredCount\"],\" von \",[\"totalCount\"],\" \",[\"itemName\"]],\"Vh/Uj5\":[\"Zielpfad\"],\"Vj8XFg\":[\"Fehler beim Erstellen des Containers\"],\"Vl4XTj\":[\"Ordnername darf keine Schrägstriche enthalten\"],\"Vmojta\":[\" Zugriffsstatus auf \\\"\",[\"newStatus\"],\"\\\" aktualisiert.\"],\"VoxR3s\":[\"Objekt wurde kopiert, konnte aber nicht von der Quelle gelöscht werden: \",[\"deleteErrorMessage\"]],\"Vz+7ZA\":[\"Container \\\"\",[\"containerName\"],\"\\\" konnte nicht erstellt werden: \",[\"errorMessage\"]],\"Vzlopx\":[\"Container löschen:\"],\"W0MCSG\":[\"Zugriff auf Image akzeptieren\"],\"W5FkH9\":[\"Container-Name eingeben\"],\"W9PZE0\":[\"Objekte gelöscht\"],\"W9kfjU\":[\"QoS-Policy-ID\"],\"WCKEqI\":[\"Dies ist ein <0>statisches großes Objekt (SLO)-Manifest. Metadaten-Änderungen gelten nur für das Manifest — Segment-Objekte sind nicht betroffen.\"],\"WCLyHI\":[\"Keine Floating IPs gefunden\"],\"WErCZy\":[\"Minimaler RAM, der zum Booten dieses Images erforderlich ist\"],\"WIx31g\":[\"Create Certificate\"],\"WRZ3Mt\":[\"Container-Eigenschaften werden geladen...\"],\"WYb0Td\":[\"<0>Sind Sie sicher? Alle Objekte in den ausgewählten Containern werden dauerhaft gelöscht. Dies kann nicht rückgängig gemacht werden.\"],\"WYiUDa\":[\"Container werden geladen...\"],\"Wbg1jv\":[[\"text\"],\" in die Zwischenablage kopieren\"],\"Wca9WC\":[\"Security Groups konnten nicht geladen werden\"],\"WefafP\":[\"Dieser Container scheint leer zu sein — die Objekt-Anzahl ist möglicherweise aufgrund einer kürzlichen Operation noch nicht synchronisiert.\"],\"WidMsn\":[\"Create Certificate Authority\"],\"WlpcJv\":[\"DNS-Domain\"],\"WoSkGY\":[\"Entfernte IP\"],\"WrUky8\":[\"Teilen (Temporäre URL)\"],\"WyKwnD\":[\"Alte Objekt-Versionen speichern\"],\"WzVwU0\":[\"Zielprojekt-ID\"],\"X2OnDx\":[\"Ephemeral Disk (GiB)\"],\"X70LXS\":[[\"numberDeleted\"],\" Objekte wurden dauerhaft gelöscht.\"],\"XLk16/\":[\"Gemeinsam können wir das volle Potenzial Ihrer Cloud-Infrastruktur erschließen.\"],\"XYZLy9\":[\"Schlüssel enthält ungültige Zeichen\"],\"XvjC4F\":[\"Wird gespeichert...\"],\"XwxJJB\":[\"Container \\\"\",[\"containerName\"],\"\\\" wurde erfolgreich erstellt.\"],\"XxjLdW\":[[\"emptiedCount\",\"plural\",{\"one\":[\"#\",\" Container war bereits leer.\"],\"other\":[\"#\",\" Container waren bereits leer.\"]}]],\"Y+2SDm\":[\"Security Group \\\"\",[\"securityGroupName\"],\"\\\" löschen\"],\"Y1YKad\":[\"Details bearbeiten\"],\"Y8M9Uc\":[\"Der Container wird gelöscht. Diese Aktion ist dauerhaft und kann nicht rückgängig gemacht werden.\"],\"YIix5Y\":[\"Suchen...\"],\"YNgcgc\":[\"Flavor-Details werden geladen...\"],\"YRexkb\":[\"Objekt aktualisiert\"],\"YUU0QW\":[\"Flavor-ID ist erforderlich und darf nicht leer sein\"],\"YZmsaT\":[\"Teilweise erfolgreich aktiviert\"],\"YiMCKk\":[\"ACLs-Vorschau anzeigen\"],\"Yin3uB\":[\"Floating IP wird freigegeben...\"],\"YjAOtb\":[\"Security Group erstellen\"],\"YrAy/S\":[\"Sie haben keine Berechtigung, Metadata für diesen Flavor zu löschen.\"],\"YsOJlj\":[\"Ein Serverfehler ist aufgetreten, während die Zugriffsinformationen für den Flavor abgerufen wurden. Bitte versuchen Sie es später noch einmal.\"],\"YsrbQh\":[\"Eigentümer-Projekt-ID\"],\"YuC9dj\":[\"Zuordnen\"],\"YuGQWb\":[\"Regeltyp\"],\"YzUoh9\":[\"Geben Sie zur Bestätigung <0>delete in das Feld unten ein.\"],\"Z/eWPC\":[\"Das Objekt wird zu diesem Pfad kopiert. Navigieren Sie oben durch die Ordner, um das Ziel zu ändern.\"],\"Z2fZGD\":[\"Kein Projekt ausgewählt\"],\"Z3FXyt\":[\"Lädt...\"],\"Z42tfY\":[\"Ordner im Object Storage sind virtuell — sie werden als Null-Byte-Platzhalterobjekte mit einem abschließenden Schrägstrich erstellt. Der Ordner wird nach der Erstellung angezeigt.\"],\"Z5r9vC\":[\"Teilweise erfolgreich gelöscht\"],\"Z8lGw6\":[\"Teilen\"],\"ZAx+d1\":[\"Maximale Gesamt-Metadatengröße\"],\"ZAy0zp\":[[\"successCount\"],\" von \",[\"totalCount\"],\" Image(s) erfolgreich deaktiviert\"],\"ZUmOzn\":[\"Der Server hat ein unerwartetes Datenformat für Flavor-Details zurückgegeben.\"],\"ZcWMT1\":[\"Image-Instanz \\\"\",[\"imageName\"],\"\\\" wurde deaktiviert\"],\"Zgp2Sm\":[\"Keine Projekte verfügbar.\"],\"ZhVSpK\":[\"Fehler beim Entfernen des Mandantenzugriffs vom Flavor. Bitte versuchen Sie es erneut.\"],\"Zq6Y5u\":[\"Der DNS-Name darf höchstens 63 Zeichen lang sein.\"],\"ZvIpwi\":[\"Security Group auswählen...\"],\"Zw49f9\":[\"folder-name\"],\"Zw8Q49\":[\"Security Group nicht gefunden\"],\"a/nTb8\":[\"Image erstellen\"],\"a12lSo\":[\"Port (bis)\"],\"a13wDR\":[\"Zum Suchen von Containern eingeben...\"],\"a3LDKx\":[\"Sicherheit\"],\"a4A2uB\":[\"Account-Listing-Limit\"],\"a4N/Bg\":[\"Mehr laden\"],\"a7C4YS\":[\"Container aktualisiert\"],\"a88X3d\":[\"<0>Sind Sie sicher? Objekt <1>\\\"\",[\"displayName\"],\"\\\" wird dauerhaft gelöscht. Dies kann nicht rückgängig gemacht werden.\"],\"aG9OiI\":[\"Freigabe-Details\"],\"aI8Tgp\":[\"Eigentümer-Projekt-ID\"],\"aL1w5Z\":[\"Belegt\"],\"aOeFR+\":[\"Container leeren\"],\"aSsVD3\":[\"Öffentlicher Lesezugriff ist nicht aktiviert. Bevor Sie statisches Website-Serving konfigurieren, gehen Sie zu <0>Zugriff verwalten und aktivieren Sie den öffentlichen Lesezugriff.\"],\"aTqCTq\":[\"Image-Datei ist erforderlich\"],\"aV6KPH\":[\"Versehentliches Löschen verhindern\"],\"aiqFbS\":[\"<0>Sind Sie sicher? Die ausgewählten Objekte werden dauerhaft gelöscht. Dies kann nicht rückgängig gemacht werden.\"],\"an5hVd\":[\"Images\"],\"ao/ZJi\":[\"Ordner und alle Inhalte werden gelöscht...\"],\"aqagJH\":[\"Image-Sichtbarkeit kann nicht aktualisiert werden\"],\"arel2K\":[\"Keine Objekte gefunden\"],\"azXlY+\":[\"Zugriffsstatus:\"],\"b0uU1G\":[\"Alte Objekt-Versionen in Container speichern:\"],\"b2BLBa\":[\"Sicherheitsgruppenregel hinzufügen\"],\"b5aNMO\":[\"Der Text muss \\\"delete\\\" entsprechen\"],\"bISG26\":[\"Fehler beim Abrufen der Flavor-Zugriffsinformationen. Bitte versuchen Sie es erneut.\"],\"bM1O3m\":[\"Image-Instanz\"],\"bQBMTH\":[\"Dies ist ein <0>statisches großes Objekt. Standardmäßig werden auch alle zugehörigen Segment-Objekte dauerhaft gelöscht.\"],\"bRgFkJ\":[\"Fehler beim Hochladen der Datei \\\"\",[\"fileName\"],\"\\\": \"],\"bYRFNi\":[\"Fehler beim Löschen der Objekte\"],\"bc67JN\":[\"Benutzerdefinierte Eigenschaften / Metadaten\"],\"bmQLn5\":[\"Regel hinzufügen\"],\"bnql/K\":[\"Zurück zu Images\"],\"boJ+Y1\":[\"Ordner erstellen\"],\"boJlGf\":[\"Seite nicht gefunden\"],\"bpme7e\":[\"Flavor nicht gefunden\"],\"bwRvnp\":[\"Aktion\"],\"bwhBhT\":[\"Security Group\"],\"byKna+\":[\"Ein unerwarteter Fehler ist aufgetreten. Bitte versuchen Sie es erneut.\"],\"bzMKg7\":[\"Akzeptiert\"],\"bzSI52\":[\"Verwerfen\"],\"c+fUtV\":[\"Beginnen Sie mit der Eingabe, um nach einem Container zu suchen\"],\"c+xCSz\":[\"True\"],\"c1OE1x\":[\"CA ID\"],\"c1uL4p\":[\"Das Image \\\"\",[\"imageId\"],\"\\\" konnte nicht gelöscht werden: \",[\"message\"]],\"c6b6fz\":[\"Ausgewählte löschen\"],\"cCfxH1\":[\"Wird heruntergeladen...\"],\"cJDQIO\":[\"Root Disk\"],\"cPKL6O\":[\"Sie sind nicht berechtigt, Flavors zu erstellen. Bitte melden Sie sich erneut an.\"],\"cWbW6w\":[\"Zugriff verwalten\"],\"cXuXkb\":[\"Benutzer \",[\"userId\"],\" aus Projekt \",[\"projectId\"]],\"chL5IG\":[\"Community\"],\"cj17eo\":[\"Image-Instanz \\\"\",[\"imageName\"],\"\\\" wurde aktiviert\"],\"cjEOmc\":[\"Teilen Sie diese Sicherheitsgruppe mit einem anderen Projekt. Das Zielprojekt kann diese Sicherheitsgruppe ansehen und verwenden, aber nicht ändern oder löschen.\"],\"cnGeoo\":[\"Löschen\"],\"cpw++p\":[\"Unterstützung für statische große Objekte\"],\"cqQyPB\":[\"Ordnername\"],\"ctc4XR\":[\"Delete certificate authority\"],\"d+F6q9\":[\"Erstellt\"],\"d+Ugpw\":[\"<0>Sind Sie sicher? Ordner <1>\\\"\",[\"folderDisplayName\"],\"\\\" und alle darin enthaltenen Objekte werden dauerhaft gelöscht. Dies kann nicht rückgängig gemacht werden.\"],\"d/I0J3\":[\"Ausgewählte aktivieren\"],\"d0pLfy\":[\"Security Group konnte nicht gelöscht werden\"],\"dEgA5A\":[\"Abbrechen\"],\"dFb5Nt\":[\"Id\"],\"dLFiER\":[\"Fehler beim Laden der Container: \",[\"errorMessage\"]],\"dOevLB\":[\"Läuft ab in \",[\"selectedPresetLabel\"],\" — um \",[\"expiresAtFormatted\"]],\"dPBJAJ\":[\"Alle leeren (\",[\"selectedCount\"],\")\"],\"dPj4yB\":[\"Bei Ihrem Konto anmelden\"],\"dPoCVe\":[\"“detach” zur Bestätigung eingeben\"],\"dTNzBI\":[\"Schlüssel muss mindestens ein alphanumerisches Zeichen enthalten\"],\"dVdc7N\":[\"Sie sind nicht berechtigt, Metadata zu löschen. Bitte melden Sie sich erneut an.\"],\"dd2ndz\":[\"Einträge in ACLs werden durch Kommas getrennt. Beispiele:\"],\"diFNkW\":[\"Fehler beim Laden der Komponente\"],\"dxMaZH\":[\"Manage your Private Certificate Authority infrastructure\"],\"e0NrBM\":[\"Projekt\"],\"eChIh7\":[\"Flavor \\\"\",[\"flavorName\"],\"\\\" wurde erfolgreich erstellt.\"],\"eGEHJE\":[\"DNS-Name\"],\"eKC+EC\":[\"-\"],\"ePK91l\":[\"Bearbeiten\"],\"eYlnXt\":[\"Keine Images gefunden\"],\"eh/k36\":[\"Regeltyp auswählen...\"],\"ekCRTP\":[\"Abgelehnt\"],\"eks7oA\":[\"Port-ID\"],\"eu70nA\":[\"Läuft ab um (UTC)\"],\"eyRsaH\":[\"Root\"],\"ezT9KW\":[\"Container-Synchronisierung\"],\"f+Uq1E\":[\"Neuer Objektname\"],\"f0cwjH\":[\"Objekt-Eigenschaften\"],\"fCPhho\":[\"Ein oder mehrere Objekte konnten nicht gelöscht werden:\"],\"fIvd7X\":[\"Fehler beim Löschen der Images\"],\"fJpv9x\":[\"Fehler beim Deaktivieren der Images\"],\"ffw//c\":[\"PCA\"],\"fj5byd\":[\"keine Angabe\"],\"fnCEAB\":[\"Type “delete” to confirm\"],\"fxnDd7\":[\"Fehler beim Generieren der temporären URL: \",[\"generalError\"]],\"fzfAAa\":[\"Ingress\"],\"g+Jead\":[\"IPv6\"],\"g1IxCo\":[\"RAM muss eine ganze Zahl ≥ 128 MB sein.\"],\"g3BSCe\":[\"Swap-Disk muss eine ganze Zahl ≥ 0 sein.\"],\"g3UF2V\":[\"Akzeptieren\"],\"g8Yxlg\":[\"Temporäre URL für \\\"\",[\"objectName\"],\"\\\" wurde in die Zwischenablage kopiert.\"],\"g9m7gK\":[\"ACL-Einträge steuern, wer von diesem Container lesen oder in ihn schreiben kann. Mehrere Einträge werden durch Kommas getrennt. Änderungen werden sofort nach dem Speichern wirksam.\"],\"gFKJBP\":[\"Ordnername darf keine führenden oder nachfolgenden Leerzeichen haben\"],\"gGdfWx\":[\"Der Compute-Dienst ist für dieses Projekt derzeit nicht verfügbar. Bitte versuchen Sie es später erneut.\"],\"gHTJc/\":[\"Object Storage\"],\"gMYsdZ\":[\"Auf \\\"Geteilt\\\" setzen\"],\"gU7JFm\":[\"Sicherheitsgruppenregel wird erstellt...\"],\"gYe+hC\":[\"Zum Hochladen klicken\"],\"go0J2x\":[\"Fehler beim Kopieren der temporären URL in die Zwischenablage\"],\"go9U+C\":[\"To confirm, type <0>\\\"delete\\\" in the field below.\"],\"grs4+e\":[\"Compute-Übersicht\"],\"gy6L1u\":[\"Must be a valid common name (FQDN).\"],\"gztCjq\":[\"Die angegebene Projekt-ID ist ungültig. Bitte überprüfen Sie Ihre Eingabe.\"],\"h3P8z+\":[\"Das Löschen des Flavor ist fehlgeschlagen. Bitte versuchen Sie es erneut\"],\"h47p9L\":[\"—\"],\"h8h6oz\":[\"Images deaktiviert\"],\"h99+4y\":[\"Floating IP zuweisen\"],\"hH3kDo\":[\"Image-Details werden geladen...\"],\"hHL/wm\":[\"Image-Instanz \\\"\",[\"imageName\"],\"\\\" wurde aktualisiert\"],\"hLp49h\":[\"<0>\",[\"deleteWord\"],\" zur Bestätigung eingeben:\"],\"hPz54a\":[\"Fehler beim Herunterladen\"],\"hQr1Cr\":[\"Image deaktivieren\"],\"hXUWyd\":[\"Wert ist erforderlich\"],\"hYgDIe\":[\"Erstellen\"],\"he3ygx\":[\"Kopieren\"],\"he4q+i\":[\"z. B. b90f9c4bc76140e18540b2cec1299e2a\"],\"hgpMHD\":[\"Gesamtspeicher\"],\"hkjZ7P\":[\"Fehler beim Aktualisieren der Sichtbarkeit für \\\"\",[\"imageName\"],\"\\\":\"],\"hrBow7\":[\"Netzwerk-ID\"],\"hz9da7\":[\"Failed to load Certificates issued by Certificate Authority.\"],\"i0qMbr\":[\"Startseite\"],\"i30J2U\":[\"Keine Projekte gefunden\"],\"i41Xuw\":[\"Feste IP-Adresse auswählen\"],\"i5MEDc\":[\"Fehler beim Verschieben des Objekts: \",[\"copyErrorMessage\"]],\"i6/ygf\":[\"Eine Eigenschaft mit diesem Schlüssel existiert bereits\"],\"i9TIyi\":[\"Entfernte Sicherheitsgruppe\"],\"i9qiyR\":[\"Läuft ab in\"],\"iH8pgl\":[\"Zurück\"],\"igVDFt\":[\"Anhängen\"],\"iqUvrS\":[\"Benutzer C/D/I\"],\"izMhIO\":[\"Benutzer \",[\"userId\"],\" (beliebiges Projekt)\"],\"j9hkgJ\":[\"Regeln\"],\"jBIkmi\":[\"QCOW2, Raw, VMDK, VHD, VHDX, VDI, AMI, ARI, AKI, ISO, PLOOP\"],\"jIPNJG\":[\"Grundlegende Informationen\"],\"jK6wqe\":[\"Ordner \\\"\",[\"folderName\"],\"\\\" und \",[\"deletedCount\"],\" Objekt wurden dauerhaft gelöscht.\"],\"jKopCP\":[\"Netzwerk & Routing\"],\"jMc/mo\":[\"Beim Erstellen von Metadata ist ein Serverfehler aufgetreten. Bitte versuchen Sie es später erneut.\"],\"jNm/qL\":[\"Dieser Container ist bereits leer.\"],\"jNzyQo\":[\"Objekt-Versionierung\"],\"jPxavx\":[\"Security Group konnte nicht aktualisiert werden\"],\"jS4B2+\":[\"Container-Name stimmt nicht überein\"],\"jSG7wx\":[\"Bitte geben Sie eine gültige Anzahl von Minuten größer als 0 ein\"],\"jVjr9h\":[\"Enter a valid common name in FQDN format (e.g., demo-ca.test.sci).\"],\"jhU93c\":[\"Das Image \\\"\",[\"imageName\"],\"\\\" konnte nicht erstellt werden: \"],\"js24f6\":[\"Ordner löschen:\"],\"jtnAf8\":[\"Geschützte Images (können nicht gelöscht werden)\"],\"jyqLKs\":[\"Dieses Mitglied hat bereits Zugriff auf dieses Image.\"],\"k0vAWv\":[\"Objekt-Anzahl-Quota\"],\"k5nYwm\":[\"vCPU\"],\"k7ENJG\":[[\"rowDisplayName\"],\" vorschauen\"],\"k99j0U\":[\"Upload abbrechen\"],\"kA2lMP\":[\"Externes Netzwerk\"],\"kCLnJG\":[\"Alle leeren\"],\"kGmM/p\":[\"Sie haben keine Berechtigung, Metadata für diesen Flavor zu erstellen.\"],\"kIuDMT\":[\"Konfigurieren Sie die Eingangs- und Ausgangsregeln, die steuern, welcher Datenverkehr für diese Sicherheitsgruppe erlaubt ist.\"],\"kKK8AH\":[\"Verfügbare Quota:\"],\"kNeZrV\":[\"No Certificates issued by this Certificate Authority found\"],\"kQYfgO\":[\"Ein oder mehrere Container konnten nicht geleert werden: \",[\"errorMessage\"]],\"kiRrtv\":[\"<0>Bitte beachten Sie: Für <1>dynamische und <2>statische große Objekte werden nur die Manifeste gelöscht. Die zugehörigen Segmente werden nicht gelöscht.\"],\"kqJVBO\":[\"Sie haben keine Berechtigung, diese Sicherheitsgruppe zu teilen.\"],\"kuYWaD\":[\"Reservierte Schlüssel: web-index, web-listings, quota-count, quota-bytes\"],\"kzkYE6\":[\"Checking S3 credentials...\"],\"l75CjT\":[\"Ja\"],\"lAsm87\":[\"Dieses Image vor dem Löschen schützen\"],\"lBVhQs\":[\"Etwas ist schiefgelaufen.\"],\"lN/Z9n\":[\"Security Group-Name ist erforderlich\"],\"lN3xvy\":[\"Regel löschen\"],\"lQ3EIe\":[\"Maximale Löschvorgänge pro Anfrage\"],\"lWTy+Y\":[\"Image kann nicht erstellt werden\"],\"lWxDDh\":[\"Flavor Name\"],\"lZvIXd\":[\"Die Beschreibung darf höchstens 255 Zeichen lang sein.\"],\"lhIa6x\":[\"Fehler beim Laden der Extra-Specs. Bitte versuchen Sie es erneut.\"],\"lq/mBZ\":[\"Objekt-Informationen werden geladen...\"],\"lw1412\":[\"Sie wurden aufgrund von Inaktivität abgemeldet.\"],\"lxentK\":[\"Ein unerwarteter Fehler ist aufgetreten\"],\"m16xKo\":[\"Hinzufügen\"],\"m6X3ro\":[\"Gruppenname\"],\"mQSO1Y\":[\"Port Forwarding\"],\"mSLePW\":[\"Sie haben keine Berechtigung, auf Flavors für dieses Projekt zuzugreifen.\"],\"mSfwLL\":[\"Projekt-ID\"],\"mYnJeY\":[\"Der Text muss “release” in Kleinbuchstaben entsprechen.\"],\"miy5mb\":[\"PCA (Clavis)\"],\"mqljvE\":[\"Metadaten kopieren\"],\"mvz5Eo\":[\"URL für öffentlichen Zugriff\"],\"mxPfpY\":[\"Neuen Ordner hier erstellen\"],\"mzI/c+\":[\"Herunterladen\"],\"n0ZttO\":[\"Root Disk (GiB)\"],\"n1ekoW\":[\"Einloggen\"],\"n1gB0L\":[\"Floating IP \",[\"floating_ip_address\"],\" bearbeiten\"],\"n22YIM\":[\"Beschreibung bearbeiten\"],\"n2IuBI\":[\"Eine temporäre URL gewährt zeitlich begrenzten Lesezugriff auf dieses Objekt ohne Authentifizierung. Jeder mit dem Link kann es herunterladen, bis es abläuft.\"],\"n3eQzA\":[\"Diese Eigenschaft ist reserviert und kann nicht geändert werden\"],\"n46oLW\":[\"Fehler beim Entfernen des Mitglieds\"],\"n9jJG6\":[\"Mitgliedszugriff entfernen\"],\"nETBrc\":[\"Egress\"],\"nLvo6K\":[\"Details der RBAC-Richtlinie:\"],\"nNKXt7\":[\"Deleting this Certificate Authority is permanent, and all the associated certificates will no longer apply to entities.\"],\"nUuaq8\":[\"Fehler beim Aktualisieren des Containers: \",[\"errorMessage\"]],\"nW/hX9\":[\"Allgemeine Image-Daten\"],\"nWNviN\":[\"Deleting certificate authority...\"],\"nZbdB+\":[\"Upload abgebrochen\"],\"ne/GWZ\":[\"Innerhalb eines Projekts werden Objekte in Containern gespeichert. Container sind der Ort, an dem Sie Zugriffsberechtigungen und Quotas definieren.\"],\"neiJm0\":[\"Flavors\"],\"ng+PCh\":[\"There are no PCAs available for this project.\"],\"nkpZyk\":[\"Container \\\"\",[\"containerName\"],\"\\\" war bereits leer.\"],\"nnxwBn\":[\"Es gibt keine Regeln für diese Sicherheitsgruppe\"],\"ntNlXu\":[\"Zugriff auflisten\"],\"nzFJqC\":[\"Delete CA\"],\"o/VDOG\":[\"Image kann nicht gelöscht werden\"],\"o6M6l0\":[\"Security Group konnte nicht erstellt werden\"],\"oDkgME\":[\"Sie sind nicht berechtigt, Flavors zu erstellen. Bitte melden Sie sich erneut an.\"],\"oEGiW3\":[\"Wird hochgeladen... \",[\"progressPct\"],\"%\"],\"ocUvR+\":[\"False\"],\"odVI9Y\":[\"Container gelöscht\"],\"og1m+J\":[\"Loading Certificate Authority Details...\"],\"okXQSt\":[\"Subject information\"],\"olfSYj\":[\"Zugriffskontrolle aktualisiert\"],\"onHi/J\":[\"Es wird aus Ihrer Image-Liste entfernt.\"],\"p4nMut\":[\"Swap (MiB)\"],\"p6CSHM\":[\"Objekte löschen\"],\"p7DzCB\":[\"Fehler beim Aktualisieren der Zugriffskontrolle\"],\"pFg+7w\":[\"Aktualisiert:\"],\"pOPvlj\":[\"Bereits aktiv (wird übersprungen)\"],\"pU25+T\":[\"Upload von \\\"\",[\"objectName\"],\"\\\" wurde abgebrochen.\"],\"pbzA+s\":[\"Optionale Beschreibung\"],\"pebLmQ\":[\"Zugriff für \",[\"memberIdDisplay\"],\" entfernen\"],\"plnnns\":[[\"successCount\"],\" Image(s) gelöscht, aber \",[\"failedCount\"],\" Image(s) konnten nicht gelöscht werden.\"],\"poCbZw\":[\"ACLs werden geladen...\"],\"podzPY\":[\"Projekt-ID\"],\"psPHye\":[\"Geteiltes Image akzeptieren\"],\"pubQie\":[\"Value eingeben\"],\"q0Rla3\":[\"Projektzugriff hinzufügen\"],\"q44uUq\":[\"Container teilweise geleert\"],\"q5sTNZ\":[\"<0>Kein Temp-URL-Schlüssel konfiguriert. Ein temporärer URL-Schlüssel muss auf Account- oder Container-Ebene gesetzt werden, bevor temporäre URLs generiert werden können. Wenden Sie sich an Ihren Administrator, um <1>X-Account-Meta-Temp-URL-Key oder <2>X-Container-Meta-Temp-URL-Key zu konfigurieren.\"],\"q6K46F\":[\"Schlüssel existiert bereits\"],\"q88/6A\":[\"Fehler beim Erstellen des Ordners\"],\"qAkkjP\":[\"Maximale Objekt-Namenslänge\"],\"qEDO1j\":[\"Dies ist ein <0>dynamisches großes Objekt. Nur das Manifest wird gelöscht — die zugehörigen Segment-Objekte (gespeichert unter dem Manifest-Präfix) werden <1>nicht automatisch entfernt und müssen separat gelöscht werden.\"],\"qFDA8L\":[\"Zugriff auf Image ablehnen\"],\"qJb6G2\":[\"Erneut versuchen\"],\"qQ1QBh\":[\"Hardware-Spezifikationen\"],\"qST5TS\":[\"Fehler – Image-Details\"],\"qUlxA+\":[\"Ordner \\\"\",[\"folderName\"],\"\\\" wurde dauerhaft gelöscht.\"],\"qaAo9Y\":[\"Es ist ein Serverfehler beim Erstellen des Flavors aufgetreten. Bitte versuchen Sie es später erneut.\"],\"qh5W8q\":[\"Richtlinie entfernen\"],\"qhDo93\":[\"Common name is required.\"],\"qs+BrU\":[\"Sie haben keine Berechtigung, Projektzugriffe von diesem Flavor zu entfernen.\"],\"qtoOYG\":[\"Kein Limit\"],\"quU9wK\":[[\"failedCount\"],\" von \",[\"totalCount\"],\" Image(s) konnten nicht deaktiviert werden. Einige Images sind möglicherweise bereits deaktiviert oder in einem ungültigen Zustand.\"],\"qvF2D8\":[\"Keine Images verfügbar. Filtern Sie neu oder erstellen Sie ein neues Image.\"],\"qxxo7y\":[\"Keine Richtlinien entsprechen Ihrer Suche\"],\"qyNaF7\":[\"Geben Sie einen Zeitstempel wie \\\"YYYY-MM-DD HH:mm:ss\\\" ein, um die automatische Löschung zu planen\"],\"qzIZOL\":[\"Ungültiges Dateiformat. Unterstützte Formate: \",[\"supportedFileFormats\"]],\"qzhUb9\":[\"Erste \",[\"maxOptions\"],\" von \",[\"totalCount\"],\" werden angezeigt — verfeinern Sie Ihre Suche, um die Ergebnisse einzugrenzen\"],\"r5SQFW\":[\"Container-Name muss \",[\"maxContainerNameLength\"],\" Zeichen oder weniger haben\"],\"r9Aac8\":[\"Ephemeral Disk\"],\"rAtQcX\":[\"Sie können das Objekt umbenennen, indem Sie den Namen hier ändern.\"],\"rD9yV1\":[\"Zu deaktivierende Images:\"],\"rIe0oV\":[\"Fehler beim Hinzufügen des Zugriffs für das Project zum Flavor. Bitte versuchen Sie es erneut.\"],\"rIi6x4\":[\"Der Flavor konnte nicht gefunden werden. Möglicherweise wurde er bereits gelöscht.\"],\"rJe6vw\":[\"7 Tage\"],\"rbuO5A\":[\"Diese Sicherheitsgruppe ist bereits mit dem angegebenen Projekt geteilt.\"],\"rcBt6T\":[\"Failed to create credential: \",[\"errorMessage\"]],\"rdUucN\":[\"Vorschau\"],\"rhaNn7\":[\"Container werden geladen...\"],\"riR9oD\":[\"Hinweis: Für <0>statische und dynamische große Objekte werden nur die Manifeste gelöscht — ihre Segmente außerhalb dieses Ordnerpräfixes sind nicht betroffen.\"],\"rlgAtt\":[\"Das Objekt wird zu diesem Pfad verschoben. Navigieren Sie oben durch die Ordner, um das Ziel zu ändern.\"],\"rp0Bd0\":[\"Compute\"],\"rrjuul\":[\"Weitere Details finden Sie in der <0>Dokumentation.\"],\"rvT6l1\":[\"Services Overview\"],\"rvXsSb\":[\"Der Projektzugriff für \\\"\",[\"tenantIdToRemove\"],\"\\\" wurde erfolgreich entfernt.\"],\"rwBVXS\":[\"Zu löschende Images (\",[\"deletableCount\"],\")\"],\"ryf/ee\":[\"Images aktiviert\"],\"ryxYVo\":[\"Zu deaktivierende Images (\",[\"activeCount\"],\")\"],\"s/s1lz\":[\"Jeder Benutzer kann eine HEAD- oder GET-Operation auf dem Container ausführen, sofern der Benutzer auch Lesezugriff auf Objekte hat. Es ist kein Token erforderlich.\"],\"s2ubkU\":[\"Flavor ID\"],\"s4Vnq2\":[\"Wird geleert...\"],\"sNVNmf\":[\"MAC-Adresse\"],\"sPFHpI\":[\"Disk\"],\"sSNyf3\":[\"Willkommen beim <0>Aurora-Dashboard, Ihrer Cloud-Management-Lösung der nächsten Generation. Wir sind bestrebt, die Art und Weise, wie Sie mit Ihrer Cloud-Infrastruktur interagieren und diese verwalten, zu vereinfachen. Mit Effizienz, Skalierbarkeit und Benutzerfreundlichkeit im Kern konzipiert, ermöglicht Aurora Ihnen, Prozesse zu optimieren und das volle Potenzial Ihrer Cloud-Ressourcen auszuschöpfen.\"],\"sWBLli\":[\"Eigenschaft hinzufügen\"],\"sXd+qS\":[\"Eigenschaften von \\\"\",[\"objectName\"],\"\\\" wurden erfolgreich aktualisiert.\"],\"sa4CV6\":[\"Alle Benutzer aus Projekt \",[\"projectId\"]],\"shKIZu\":[\"Zu aktivierende Images (\",[\"deactivatedCount\"],\")\"],\"sheDTJ\":[\"Bitte beachten Sie: Für <0>dynamische und <1>statische große Objekte werden nur die Manifeste gelöscht. Die zugehörigen Segmente werden nicht gelöscht.\"],\"sihD20\":[\"Images werden geladen...\"],\"sjMCOP\":[\"Zuletzt geändert\"],\"slWh5C\":[\"Floating IP \",[\"floating_ip_address\"],\" einem Port zuordnen\"],\"sxbP3b\":[\"Objekt-Anzahl\"],\"t/YqKh\":[\"Entfernen\"],\"t0X9+8\":[\"Container-Name\"],\"t1POAD\":[\"Keine benutzerdefinierten Metadaten-Eigenschaften gefunden. Klicken Sie auf \\\"Eigenschaft hinzufügen\\\", um eine zu erstellen.\"],\"t1fq6V\":[\"Der Server hat ein unerwartetes Datenformat zurückgegeben.\"],\"t7ff15\":[\"gültiges Token erforderlich: false\"],\"t95VRV\":[\"Über das Aurora-Dashboard\"],\"tASa/P\":[\"Beim Löschen des Flavors ist ein Serverfehler aufgetreten. Bitte versuchen Sie es später erneut.\"],\"tIrNgH\":[\"Beim Abrufen der Metadata ist ein Serverfehler aufgetreten. Bitte versuchen Sie es später erneut.\"],\"tLerHy\":[\"Ephemeral Disk muss eine ganze Zahl ≥ 0 sein.\"],\"tM5SEI\":[\"ACLs für Container \\\"\",[\"containerName\"],\"\\\" wurden erfolgreich aktualisiert.\"],\"tOkmLM\":[\"Fehler beim Kopieren des Objekts\"],\"tV/Ozb\":[\"Port-Bereich\"],\"tVSmFT\":[\"Weitere werden geladen...\"],\"tX5yOZ\":[\"Neuer Ordner\"],\"tasfos\":[\"entfernen\"],\"tbwGSx\":[\"Minimale Festplatte (GB)\"],\"tejJLY\":[\"Floating IP wird zugeordnet...\"],\"tfAKBU\":[\"\\\"\",[\"objectName\"],\"\\\" konnte nicht hochgeladen werden: \",[\"errorMessage\"]],\"tfDRzk\":[\"Speichern\"],\"tfxu04\":[\"Zugriff entfernen für \",[\"tenantId\"]],\"thHAVL\":[\"Akzeptierte Images\"],\"tiflqy\":[\"Image kann nicht reaktiviert werden\"],\"tlfxPP\":[\"\\\"\",[\"objectName\"],\"\\\" konnte nicht kopiert werden: \",[\"errorMessage\"]],\"tmpGvt\":[\"production, linux\"],\"u+VWhB\":[\"In die Zwischenablage kopiert!\"],\"u2xIeO\":[\"Fehler beim Aktualisieren der ACLs: \",[\"errorMessage\"]],\"u5HztT\":[\"RX/TX Factor\"],\"u77/s4\":[\"Floating IPs\"],\"u7En0V\":[\"Metadata hinzufügen\"],\"uAI0yI\":[\"Objekt löschen:\"],\"uAQUqI\":[\"Status\"],\"uLtFAr\":[\"Container \\\"\",[\"containerName\"],\"\\\" konnte nicht aktualisiert werden: \",[\"errorMessage\"]],\"uSdnuQ\":[\"VCPUs müssen eine ganze Zahl ≥ 1 sein.\"],\"ujK/QN\":[\"Objekte werden geladen...\"],\"uly9ET\":[\"Regeldetails:\"],\"up0ZSW\":[\"Fingerprint\"],\"uuKb0T\":[\"Die Beschreibung muss weniger als 65535 Zeichen haben.\"],\"v0hPHE\":[\"Details anzeigen\"],\"v3djpU\":[\"Verschieben/Umbenennen\"],\"v9Dn8m\":[\"Das Aurora-Dashboard ist mehr als nur ein Werkzeug – es ist Ihr Partner bei der Navigation in der Cloud. Egal, ob Sie ein kleines Startup oder ein globales Unternehmen sind, Aurora bietet Ihnen die Flexibilität, Leistung und Einfachheit, die Sie benötigen, um Ihre Ziele zu erreichen.\"],\"vBUQNE\":[\"Das Metadata konnte nicht gefunden werden. Möglicherweise wurde es bereits gelöscht.\"],\"vEkTR9\":[\"Quota\"],\"vH2C/2\":[\"Swap\"],\"vR4HmN\":[\"Lädt Instanzen...\"],\"vTh35P\":[\"Container erstellen\"],\"vXmL4D\":[\"Legen Sie Ihre Image-Datei hier ab\"],\"vZUKSz\":[\"Floating IP \",[\"floating_ip_address\"],\" trennen\"],\"vbajgL\":[\"Öffentlicher Flavor\"],\"vcQSZh\":[\"Dieser Ordner ist leer — verwenden Sie \\\"Neuer Ordner\\\", um einen zu erstellen.\"],\"vcXmqy\":[\"Netzwerk-Übersicht\"],\"vcvCXq\":[\"Fehler – Flavor-Details\"],\"vg84cD\":[[\"allCount\"],\" Elemente\"],\"vmRPFm\":[\"Sicherheitsgruppe teilen\"],\"vmYyLY\":[\"Entferntes IP-Präfix\"],\"vp5vfW\":[\"1 Stunde\"],\"vpt8cE\":[\"URL generieren\"],\"vrPCbw\":[\"Image-ID\"],\"w3bAcf\":[\"Diese Aktion ist dauerhaft. Die Adresse wird aus Ihrem Projekt entfernt und dem öffentlichen Pool zurückgegeben. Dies kann nicht rückgängig gemacht werden.\"],\"w9+8d7\":[\"Projektzugriff entfernen\"],\"wEfZld\":[\"Neuen Flavor erstellen\"],\"wFaT8w\":[\"Fehler beim Leeren der Container\"],\"wMHvYH\":[\"Value\"],\"wPrtGF\":[\"Key eingeben\"],\"wTg+FY\":[\"Maximale Dateigröße\"],\"wXxPjv\":[\"S3 Object Storage — Setup Required\"],\"wa1Bcq\":[\"Project ID eingeben\"],\"wbqM4L\":[[\"customMinutes\"],\" Minuten\"],\"wcUecy\":[\"Sie haben keine Berechtigung, Metadata für diesen Flavor anzusehen.\"],\"wdUvGT\":[\"Creating Certificate Authority...\"],\"we28Pq\":[\"ACLs-Vorschau ausblenden\"],\"wlQNTg\":[\"Members\"],\"wlUDbB\":[\"Zuletzt aktualisiert: \",[\"formattedDate\"]],\"wrXcuy\":[\"Objektname\"],\"wrk/xj\":[\"Image-Details\"],\"wyIOMP\":[\"Image-Name ist erforderlich\"],\"wzqqS+\":[\"Hauptmerkmale\"],\"x/XQrD\":[\"Beliebiger Dateityp\"],\"x1bK0h\":[\"Mit den aktuellen Suchkriterien sind keine Container verfügbar. Versuchen Sie, Ihren Suchbegriff anzupassen.\"],\"x3T4pq\":[\"Die Container-Metadaten melden Objekte, aber keine wurden aufgelistet. Dies kann eine vorübergehende Synchronisierungsverzögerung sein — bitte warten Sie einen Moment und versuchen Sie es erneut.\"],\"x5l/TK\":[\"Bereits aktiv (wird übersprungen):\"],\"x9AdZ8\":[\"property_key\"],\"xNG/3n\":[\"Floating IP-Adresse\"],\"xNZKYy\":[[\"failedCount\"],\" von \",[\"totalCount\"],\" Image(s) konnten nicht gelöscht werden. Einige Images sind möglicherweise geschützt oder werden verwendet.\"],\"xqhyRT\":[\"Objekt hochgeladen\"],\"xw2UtT\":[\"Neues Image erstellen\"],\"y+KBOY\":[\"z. B. production, linux, ubuntu\"],\"y02Bu1\":[\"Container:\"],\"y0u86k\":[\"Der angeforderte Flavor konnte nicht gefunden werden. Er wurde möglicherweise gelöscht oder Sie haben keinen Zugriff darauf.\"],\"y1GYnY\":[\"\\\"\",[\"objectName\"],\"\\\" konnte nicht verschoben werden: \",[\"errorMessage\"]],\"yPWFWy\":[\"ICMP-Typ\"],\"yTtJTy\":[\"Image-Metadaten bearbeiten\"],\"yYxB17\":[\"Alle Filter löschen\"],\"ylfbpz\":[\"Der Key für die Extra-Spec ist erforderlich und darf nicht leer sein.\"],\"yp0UjB\":[\"Ethertype\"],\"yqPflB\":[\"... und \",[\"hiddenCount\"],\" weitere\"],\"yu9G3x\":[\"Security Group bearbeiten\"],\"ywe1H/\":[[\"totalCount\",\"plural\",{\"one\":[[\"totalCount\"],\" Container\"],\"other\":[[\"totalCount\"],\" Container\"]}]],\"yz7wBu\":[\"Schließen\"],\"z+zpLP\":[\"gültiges Token erforderlich: true\"],\"z1JceR\":[\"Zurück zu Floating IPs\"],\"z45o5B\":[\"Objekt-Anzahl\"],\"z9NAjZ\":[\"Objekt gelöscht\"],\"zCD96i\":[\"Sie sind nicht berechtigt, Flavor-Details anzuzeigen. Bitte melden Sie sich erneut an.\"],\"zDS0JC\":[\"Der Name muss 2-50 Zeichen lang sein.\"],\"zWb/Nn\":[\"Maximale Header-Größe\"],\"zc5dcw\":[\"Anmeldung fehlgeschlagen. Bitte überprüfen Sie Ihre Anmeldeinformationen und versuchen Sie es erneut.\"],\"zga9sT\":[\"OK\"],\"zhM8FP\":[\"Zugriff für einen Benutzer aus einem anderen Projekt gewähren.\"],\"zm7+/D\":[\"Sie sind dabei, <0>\",[\"activeCount\"],\" Image(s) zu deaktivieren. Deaktivierte Images können nicht zum Starten neuer Instances verwendet werden.\"],\"zwBp5t\":[\"Privat\"]}")as Messages; \ No newline at end of file +/*eslint-disable*/import type{Messages}from"@lingui/core";export const messages=JSON.parse("{\"+0B+ue\":[\"Projekte\"],\"+9CXS9\":[\"Images deaktivieren\"],\"+Jcye3\":[\"Keyname\"],\"+Lt5cp\":[\"Sie sind nicht berechtigt, Projektzugriffe hinzuzufügen. Bitte melden Sie sich erneut an.\"],\"+Nhol2\":[\"Certificate not found\"],\"+NwLgN\":[\"Durch die Aktivierung dieses Images kann es wieder zum Starten neuer Instances verwendet werden.\"],\"+Nx1wc\":[\"Floating IPs konnten nicht geladen werden\"],\"+OEi73\":[\"Object Storage (Swift)\"],\"+nQTmZ\":[\"Dieses Projekt hat keinen Zugriff auf den Flavor.\"],\"+p6nHr\":[\"Fehler beim Laden der Objekt-Metadaten: \",[\"metadataErrorMessage\"]],\"+zy2Nq\":[\"Typ\"],\"/1MfrG\":[\"\\\"\",[\"objectName\"],\"\\\" konnte nicht heruntergeladen werden: \",[\"errorMessage\"]],\"/2a/eI\":[\"Flavor wird geladen...\"],\"/9Squ9\":[\"Sie haben keine Berechtigung, die Details dieses Flavors anzuzeigen.\"],\"/BZLRP\":[\"Um diese Aktion zu bestätigen, geben Sie das Wort <0>“detach” in das Feld unten ein.\"],\"/EcdUM\":[\"Ihre Aktion ist erforderlich\"],\"/HgF9q\":[\"Sortieren nach\"],\"/InK0O\":[\"Gesamtspeicher\"],\"/LqWNN\":[\"\\\"\",[\"objectName\"],\"\\\" konnte nicht gelöscht werden: \",[\"errorMessage\"]],\"/NeNjH\":[\"Eigenschaften des Containers \\\"\",[\"containerName\"],\"\\\" wurden erfolgreich aktualisiert.\"],\"/Nmxy/\":[\"Keine Keypaare verfügbar\"],\"/QIkBY\":[\"<0>Sicher & Zuverlässig: Ihre Daten und Operationen sind mit Sicherheitsstandards auf Unternehmensebene und robuster Zuverlässigkeit geschützt.\"],\"/Qox3b\":[\"Ein Ordner mit diesem Namen existiert bereits\"],\"/Z2leb\":[\"No containers found.\"],\"/Z5n1b\":[\"Ordner erstellen unter:\"],\"/bUiYk\":[\"Router-ID\"],\"/eFtWI\":[\"RBAC-Richtlinien\"],\"/pOQrn\":[\"This action is irreversible. Deleting a bucket permanently removes it and cannot be undone. The bucket must be empty before deletion.\"],\"/xnbdQ\":[\"Der angegebene Benutzer hat Zugriff. Ein Token für den Benutzer (auf ein beliebiges Projekt bezogen) muss in der Anfrage enthalten sein.\"],\"01/uUD\":[\"Segmente beibehalten (nur Manifest löschen)\"],\"07WXfc\":[\"Der Server hat ein unerwartetes Datenformat für die Metadata zurückgegeben.\"],\"0BSSYj\":[\"Ein Serverfehler ist aufgetreten, während der Projektzugriff entfernt wurde. Bitte versuchen Sie es später noch einmal.\"],\"0Gd0NU\":[\"Geteilt\"],\"0P2gFy\":[\"Die gesuchte Seite existiert nicht.\"],\"0WsqO0\":[\"Container geleert\"],\"0cVgUw\":[\"Filtern nach\"],\"0eY8Mz\":[\"Für dieses Projekt sind keine Floating IPs verfügbar. Floating IPs ermöglichen es, öffentliche IP-Adressen Instances zuzuordnen.\"],\"0kCt7e\":[\"Die angegebenen Flavor-Daten sind ungültig. Bitte überprüfen Sie Ihre Eingabe.\"],\"0kc0zi\":[\"Beim Löschen des Metadata ist ein Serverfehler aufgetreten. Bitte versuchen Sie es später erneut.\"],\"0o0OhW\":[\"No objects found.\"],\"0p+s6m\":[\"Typ: \",[\"typeValue\"],\", Code: \",[\"codeValue\"]],\"0u9jhd\":[\"Das Trennen dieser Floating IP hebt die Zuordnung zum aktuellen Port auf. Die Instance ist über diese Adresse nicht mehr erreichbar.\"],\"16085O\":[\"IP-Version\"],\"1H2g6v\":[\"Objekt wird verschoben...\"],\"1NFtQz\":[\"Failed to Delete Bucket\"],\"1NS3nd\":[[\"emptiedCount\",\"plural\",{\"one\":[\"#\",\" Container\"],\"other\":[\"#\",\" Container\"]}],\" erfolgreich geleert. \",[\"totalDeleted\",\"plural\",{\"one\":[\"#\",\" Objekt\"],\"other\":[\"#\",\" Objekte\"]}],\" insgesamt gelöscht.\"],\"1RwosK\":[\"Zielprojekt-ID ist erforderlich\"],\"1UzENP\":[\"Nein\"],\"1VDqZj\":[\"<0>Zukunftssicher: Aurora ist darauf ausgelegt, sich mit den neuesten Trends in der Cloud-Technologie weiterzuentwickeln und stellt so sicher, dass Ihre Lösung immer auf dem neuesten Stand ist.\"],\"1iQtS2\":[\"Erste \",[\"actualObjectCount\"],\" von \",[\"total\"],\" Objekten werden angezeigt\"],\"1iUuTT\":[\"Ihre Sitzung ist abgelaufen. Bitte melden Sie sich erneut an.\"],\"1ojTVo\":[\"DNS-Domain auswählen.\"],\"1pGUZa\":[\"Sitzung läuft ab in\"],\"1pdLQw\":[\"Image nicht gefunden\"],\"1rLu3+\":[\"Container \\\"\",[\"containerName\"],\"\\\" konnte nicht geleert werden: \",[\"errorMessage\"]],\"1rPB1p\":[\"Der Flavor oder das Projekt konnte nicht gefunden werden. Bitte überprüfen Sie, ob sie existieren.\"],\"1t/NnN\":[\"Ablehnen\"],\"1zZ1IK\":[\"Hallo\"],\"20E+79\":[\"Sie müssen sich anmelden, um auf diese Seite zuzugreifen.\"],\"20Kpaw\":[\"Flavor \\\"\",[\"flavorName\"],\"\\\" wurde erfolgreich gelöscht.\"],\"20axE5\":[\"Vom Projekt geteilt\"],\"23wBCX\":[\"Öffentlicher Lesezugriff\"],\"2G6hLq\":[\"Lösche \",[\"specKey\"]],\"2Inn83\":[\"Bulk-Upload von Archivdateien\"],\"2TtIL2\":[\"Gespeichert als X-Object-Meta-*-Header. Schlüssel sind nicht zwischen Groß- und Kleinschreibung unterscheidend.\"],\"2cJIlz\":[\"Floating Network-ID\"],\"2d/OiW\":[\"Geben Sie Ihren Benutzernamen ein\"],\"2dnZwV\":[\"Ordner \\\"\",[\"folderName\"],\"\\\" konnte nicht gelöscht werden: \",[\"errorMessage\"]],\"2gH+i8\":[\"Sie sind nicht berechtigt, Flavors zu löschen. Bitte melden Sie sich erneut an.\"],\"2lq0gq\":[\"<0>Eigenschaften von <1>\",[\"displayName\"],\"\"],\"2mbisJ\":[\"Metadaten \\\"\",[\"trimmedKey\"],\"\\\" wurden erfolgreich hinzugefügt.\"],\"2pnrGl\":[\"Erwartetes Format: JJJJ-MM-TT HH:MM:SS\"],\"2q/Q7x\":[\"Sichtbarkeit\"],\"2ysnjX\":[\"<0>Erhöhte Produktivität: Durch die Reduzierung der betrieblichen Komplexität hilft Aurora Ihrem Team, sich auf das Wesentliche zu konzentrieren – Innovationen voranzutreiben und Geschäftserfolg zu sichern.\"],\"2zceEg\":[\"Diese Aktion kann nicht rückgängig gemacht werden. Das Image wird dauerhaft gelöscht.\"],\"33F2A+\":[\"Geben Sie den Container-Namen zur Bestätigung ein\"],\"3AUpb4\":[\"Alle löschen (\",[\"selectedCount\"],\")\"],\"3Qn0me\":[\"Mitglied hinzufügen\"],\"3dBmvU\":[\"Der Container kann nicht gelöscht werden, da er Objekte enthält. Leeren Sie den Container zuerst.\"],\"3n+vCm\":[\"Benutzerdefinierte Dauer (Minuten)\"],\"3nWqQW\":[\"Sie sind nicht berechtigt, Metadata anzusehen. Bitte melden Sie sich erneut an.\"],\"3nh/7E\":[\"Wenn diese Option aktiviert ist, wird dieser Flavor für alle Projekte verfügbar sein. Wenn sie deaktiviert ist, muss der Zugriff explizit für bestimmte Projekt gewährt werden.\"],\"3oChIh\":[\"<0>Vereinheitlichtes Cloud-Management: Konsolidiert alle Ihre Cloud-Ressourcen in einer intuitiven Oberfläche.\"],\"3oc18/\":[\"Private Flavors konnten nicht geladen werden. Die angezeigte Liste ist möglicherweise unvollständig.\"],\"3q1GLx\":[\"Datei-Upload ausstehend...\"],\"3x7Sws\":[\"Security Group-Details werden geladen...\"],\"4+2wZO\":[\"Back to Certificate Authorities Details page\"],\"47eI0x\":[\"Die Beschreibung muss mindestens 1 Zeichen lang sein.\"],\"48bMai\":[\"Bucket name is required\"],\"4EZrJN\":[\"Regeln\"],\"4O2AH3\":[\"Mitglied \\\"\",[\"memberIdToRemove\"],\"\\\" wurde erfolgreich entfernt.\"],\"4fh0Wj\":[\"Boot-Größe\"],\"4fvDRe\":[\"Zu aktivierende Images:\"],\"4fvcmm\":[\"Objekt wird hochgeladen als: <0>\",[\"selectedObjectName\"],\"\"],\"4h3Eyf\":[\"\\\"\",[\"objectName\"],\"\\\" wurde erfolgreich hochgeladen.\"],\"4kjaAc\":[\"Keine Servergruppen verfügbar\"],\"4mbrAq\":[\"1 Minute\"],\"4opp4r\":[\"Security Groups\"],\"4pOfUd\":[\"Unsere Mission\"],\"4t33sh\":[\"Fehler beim Aktualisieren des Objekts\"],\"4uXhtt\":[\"CIDR\"],\"4utWB4\":[\"Serverrolle:\"],\"5/wyf8\":[\"Floating IP eingeben\"],\"50Piuj\":[\"This bucket contains \",[\"actualObjectCount\"],\" \",[\"actualObjectCount\",\"plural\",{\"one\":[\"object\"],\"other\":[\"objects\"]}],\" and cannot be deleted. Delete all objects first.\"],\"56IxdF\":[\"Fehler beim Laden der Container-Objekte: \",[\"errorMessage\"]],\"5BLR6Q\":[\"IPv4\"],\"5JDSvn\":[\"Maximale Metadaten-Wertlänge\"],\"5M4Te3\":[\"DNS\"],\"5MF8U2\":[\"Fehler beim Aktualisieren des Containers\"],\"5Ml1iQ\":[[\"allContainersCount\"],\" buckets\"],\"5Okch2\":[\"Leeren:\"],\"5Yrl6N\":[\"Lädt Servergruppen\"],\"5aNQ3F\":[\"\\\"\",[\"objectName\"],\"\\\" wurde erfolgreich nach \",[\"destination\"],\" kopiert.\"],\"5g7owI\":[\"Floating IP wird aktualisiert...\"],\"5y3O+A\":[\"Deaktivieren\"],\"6+7EwD\":[\"Objekte als Index bereitstellen, wenn Dateiname ist:\"],\"6+OdGi\":[\"Protokoll\"],\"6/xipy\":[\"Container-Format\"],\"644xgx\":[\"Geschützt\"],\"66YUKF\":[\"Failed to Empty Bucket\"],\"6BDqha\":[\"Limits\"],\"6CDYXS\":[\"Statisches Website-Serving\"],\"6GBt0m\":[\"Metadata\"],\"6H/Lg1\":[\"Dies ist ein öffentliches Image. Alle Benutzer haben Zugriff darauf. Eine explizite Freigabe ist nicht erforderlich.\"],\"6KRclz\":[\"Ordner erstellt\"],\"6Kjltl\":[\"Zugriffskontrolle für Container:\"],\"6OopEX\":[\"Container geleert\"],\"6Rnrsz\":[\"Zugriff verwalten - \",[\"flavorName\"]],\"6V3Ea3\":[\"Copied\"],\"6X/9Di\":[\"\\\"\",[\"objectName\"],\"\\\" wurde erfolgreich nach \",[\"destination\"],\" verschoben.\"],\"6YtxFj\":[\"Name\"],\"6fuDFZ\":[\"Empty All Completed with Errors\"],\"6jAi8c\":[\"Bereich\"],\"6luZQA\":[\"Objekt verschoben\"],\"6oolxV\":[\"Dieser Metadata-Key existiert bereits. Bitte verwenden Sie einen anderen Key.\"],\"6qzsuS\":[\"Schreib-ACLs\"],\"6sxz+g\":[\"Port-Name\"],\"6w+VnM\":[\"Container erstellt\"],\"6z9W13\":[\"Neustart\"],\"76RKuS\":[\"ICMP-Code\"],\"78+riR\":[\"Sie sind nicht berechtigt, Projektzugriffe zu entfernen. Bitte melden Sie sich erneut an.\"],\"7AfIPZ\":[\"Floating Network\"],\"7BpykL\":[\"Fehler beim Erstellen des Extra-Specs. Bitte versuchen Sie es erneut.\"],\"7L01XJ\":[\"Aktionen\"],\"7NC3vm\":[\"Subnet\"],\"7NSdfG\":[\"Container \",[\"progressCurrent\"],\" von \",[\"progressTotal\"],\" wird geleert, bitte warten...\"],\"7Q24LN\":[\"Richtlinie\"],\"7RBR/D\":[\"Empty Buckets\"],\"7T1fHv\":[\"Fehler beim Entfernen des Mitglieds \\\"\",[\"memberIdToRemove\"],\"\\\"\"],\"7UlHhT\":[\"Metadaten \\\"\",[\"keyToDelete\"],\"\\\" wurden erfolgreich gelöscht.\"],\"7XQ3QJ\":[\"Abgelehnter Referrer: \",[\"host\"]],\"7ZnTL8\":[\"Fehler beim Aktualisieren des Objekts: \",[\"mutationErrorMessage\"]],\"7a4DvD\":[\"Keine Server verfügbar\"],\"7d1a0d\":[\"Öffentlich\"],\"7flw0l\":[\"Der Projektzugriff für \\\"\",[\"trimmedTenantId\"],\"\\\" wurde erfolgreich hinzugefügt.\"],\"7huC4O\":[\"There are no Certificates available for this Certificate Authority.\"],\"7sMeHQ\":[\"Key\"],\"88kg0+\":[\"Erstellt am\"],\"8AriEH\":[\"Image-Instanz \\\"\",[\"imageName\"],\"\\\" wurde erstellt\"],\"8HrqL8\":[\"<0>Are you sure? All \",[\"bucketCount\"],\" \",[\"bucketCount\",\"plural\",{\"one\":[\"object\"],\"other\":[\"objects\"]}],\" in bucket \\\"\",[\"bucketName\"],\"\\\" will be permanently deleted. This action cannot be undone.\"],\"8S2nDL\":[\"No PCAs found\"],\"8TSI9h\":[\"Durch die Deaktivierung dieses Images kann es nicht mehr zum Starten neuer Instances verwendet werden. Bestehende Instances sind nicht betroffen.\"],\"8Tg/JR\":[\"Benutzerdefiniert\"],\"8ZOb7O\":[[\"numberDeleted\"],\" Objekt wurde dauerhaft gelöscht.\"],\"8ZsakT\":[\"Passwort\"],\"8c3/77\":[\"Maximale Metadaten-Namenslänge\"],\"8erw15\":[\"Bucket \\\"\",[\"bucketName\"],\"\\\" was already empty.\"],\"8jLXs3\":[\"Versionierte Schreibvorgänge\"],\"8s0tOH\":[\"Sie haben keine Berechtigung, diesem Flavor Projektzugriffe hinzuzufügen.\"],\"8t1+HU\":[[\"successCount\"],\" Image(s) deaktiviert, aber \",[\"failedCount\"],\" Image(s) konnten nicht deaktiviert werden.\"],\"8uPTwT\":[[\"filteredCount\",\"plural\",{\"one\":[[\"filteredCount\"],\" von \",[\"totalCount\"],\" Container\"],\"other\":[[\"filteredCount\"],\" von \",[\"totalCount\"],\" Containern\"]}]],\"8wdCNd\":[\"tcp, udp, icmp oder Protokollnummer\"],\"8zAn1f\":[\"Fehler beim Löschen des Flavors. Bitte versuchen Sie es erneut.\"],\"98Fs4G\":[\"Image wird erstellt...\"],\"9J93Xr\":[\"Container-Name darf keine Schrägstriche enthalten\"],\"9SX0bO\":[\"Das Image \\\"\",[\"imageName\"],\"\\\" konnte nicht aktualisiert werden: \"],\"9X8lAk\":[\"Zuweisen\"],\"9doWrf\":[\"Fehler beim Hinzufügen des Mitglieds\"],\"9dsDHD\":[\"Das Image \\\"\",[\"imageId\"],\"\\\" konnte nicht reaktiviert werden: \",[\"message\"]],\"9iz2XW\":[\"Image kann nicht aktualisiert werden\"],\"9njIiV\":[\"Fehler beim Aktivieren der Images\"],\"9rz81C\":[\"Geräte-ID\"],\"9v5VLp\":[\"Keine benutzerdefinierten Eigenschaften definiert\"],\"9vSW3U\":[\"Rekursiv löschen\"],\"9x6EkK\":[\"Dies ist ein öffentlicher Flavor. Alle Projekte haben Zugriff darauf.\"],\"A7CVME\":[\"Wählen Sie zuerst das Festplattenformat\"],\"AB4Tnl\":[\"Bitte wählen Sie eine Datei zum Hochladen aus\"],\"AGXLLY\":[\"Image-Datei kann nicht hochgeladen werden\"],\"AJRhSM\":[\"Root Disk muss eine ganze Zahl ≥ 0 sein.\"],\"AN0DBJ\":[\"Enter drücken zum Hinzufügen\"],\"ASJMIw\":[\"S3 bucket names must be 3-63 characters long and contain only lowercase letters, numbers, periods, and hyphens. They must start and end with a letter or number, and be globally unique within the cluster.\"],\"AX9Juz\":[\"Die ID darf nur alphanumerische Zeichen, Bindestriche, Unterstriche und Punkte enthalten.\"],\"AZyHwC\":[\"Muss eine gültige IPv4- oder IPv6-Adresse sein (z. B.: 172.24.4.228 oder 2001:db8::1).\"],\"Ac6dy9\":[\"Name eingeben\"],\"AdtLNV\":[\"Stellen Sie sicher, dass ACL-Einträge gültig sind — korrekte Projekt-IDs, Benutzer-IDs und Formate liegen in Ihrer Verantwortung. Ungültige Einträge können stillschweigend unbeabsichtigten Zugriff gewähren oder verweigern.\"],\"AeXO77\":[\"Account\"],\"Afh/Lb\":[\"Zielordner auswählen\"],\"AlbUVn\":[\"<0>Optimierte Skalierbarkeit: Aurora ist für Unternehmen jeder Größe konzipiert und wächst mit Ihnen mit, unterstützt einfache Umgebungen und komplexe Multi-Cloud-Setups gleichermaßen.\"],\"Alx2/L\":[\"In neuem Tab öffnen\"],\"AuQtzx\":[\"Muss eine nicht-negative Ganzzahl sein\"],\"AxZkIr\":[\"Disk (GiB)\"],\"B2Czeb\":[\"Min. RAM\"],\"B2SpR8\":[\"Bucket name must not start with reserved prefix \\\"\",[\"prefix\"],\"\\\"\"],\"B2i9cQ\":[\"Zu löschende Objekte (\",[\"totalCount\"],\")\"],\"B3toQF\":[\"Objekte\"],\"B4Jzm7\":[\"Ceph\"],\"BCJPTn\":[\"Zugriff für alle Benutzer aus diesem Projekt gewähren.\"],\"BCXapL\":[\"Fehler beim Laden der Container-Eigenschaften: \",[\"errorMessage\"]],\"BJt+PJ\":[\"Fehler beim Löschen des Containers\"],\"BMTd81\":[\"Diese Aktion kann nicht rückgängig gemacht werden. Das Zielprojekt verliert sofort den Zugriff auf diese Sicherheitsgruppe.\"],\"BMogtG\":[\"Issue End Entity Certificate\"],\"BOQYRn\":[\"Lädt Key Pairs...\"],\"BOoOLQ\":[\"All Buckets Emptied\"],\"BP4Fwj\":[\"Fehler beim Laden der Objekte: \",[\"errorMessage\"]],\"BSaBkZ\":[\"Objekte — \",[\"containerName\"]],\"BTsbBe\":[\"Bucket Name\"],\"BYH/2L\":[\"Image kann nicht deaktiviert werden\"],\"BZpsYm\":[\"Failed to load containers: \",[\"errorMessage\"]],\"BgMp/T\":[\"Ungültige Formatkombination für das ausgewählte Festplattenformat\"],\"Blsc/x\":[\"Delete Certificate Authority\"],\"BoIAP6\":[\"Die ID des Netzwerks, das der Floating IP zugeordnet ist.\"],\"BoPocW\":[\"MD5-Prüfsumme\"],\"BrrIs8\":[\"Storage\"],\"CA8ZeT\":[\"Sichtbarkeit des Images \\\"\",[\"imageName\"],\"\\\" auf \",[\"visibility\"],\" aktualisiert\"],\"CBFSfX\":[\"Bitte korrigieren Sie die Validierungsfehler unten.\"],\"CFMxC8\":[\"Images gelöscht\"],\"CMVP7y\":[\"Diese Aktion kann nicht rückgängig gemacht werden. Die Regel wird dauerhaft gelöscht.\"],\"CfKRC1\":[\"Empty Bucket\"],\"CgZxr7\":[\"Min. RAM (MB)\"],\"ChOuUj\":[\"Floating IP nicht gefunden\"],\"Cj2Gtd\":[\"Größe\"],\"ClGcRq\":[\"Container\"],\"CongmL\":[\"Could not delete bucket \\\"\",[\"bucketName\"],\"\\\": \",[\"errorMessage\"]],\"CtiFDz\":[\"This will permanently delete all objects from \",[\"totalCount\"],\" selected \",[\"totalCount\",\"plural\",{\"one\":[\"bucket\"],\"other\":[\"buckets\"]}],\". This action cannot be undone.\"],\"Cu6xuZ\":[\"Dies ist ein <0>dynamisches großes Objekt (DLO)-Manifest. Metadaten-Änderungen gelten nur für das Manifest — Segment-Objekte sind nicht betroffen.\"],\"CunRry\":[\"Ungültiges Projekt-ID-Format. Muss 32 hexadezimale Zeichen sein (z. B. b90f9c4bc76140e18540b2cec1299e2a) oder UUID-Format (z. B. 12345678-1234-1234-1234-123456789abc)\"],\"Cxgv2U\":[\"Min. Disk\"],\"D/8vkD\":[\"Es wird in Ihrer Image-Liste angezeigt.\"],\"D3IRXw\":[\"Floating IP wird getrennt...\"],\"D7qT9F\":[\"Warum Aurora wählen?\"],\"DDRhQm\":[\"Ihre Sitzung ist abgelaufen.\"],\"DHrCY6\":[\"Common name\"],\"DJT9tB\":[\"Account-Quotas\"],\"DKkOPx\":[\"Zusätzliche Spezifikationen\"],\"DNVql8\":[\"Vollständiges Lifecycle-Management von Floating IPs, einschließlich Zuweisung, Port-Zuordnung/-Aufhebung, DNS-Einstellungen und Löschung\"],\"DcMIiu\":[\"ACLs für Container \\\"\",[\"containerName\"],\"\\\" konnten nicht aktualisiert werden: \",[\"errorMessage\"]],\"Df0YHr\":[\"Security Group aktualisieren\"],\"Dh1qvV\":[\"Sie sind dabei, \",[\"deletableCount\"],\" Image(s) zu löschen. Diese Aktion kann nicht rückgängig gemacht werden.\"],\"Dia2Ue\":[\"Für diese Security Group sind keine RBAC-Richtlinien vorhanden\"],\"Do5/uH\":[\"Der Flavor oder das Projekt konnte nicht gefunden werden. Möglicherweise wurde es bereits entfernt.\"],\"Dqnh7K\":[\"Spezifischer Referrer: \",[\"host\"]],\"Dt5W9T\":[\"RBAC-Richtlinie entfernen\"],\"DvB4XF\":[\"Legen Sie Ihre Datei hier ab\"],\"E/QGRL\":[\"Deaktiviert\"],\"E4QYe7\":[\"Empfohlene Images\"],\"E6nRW7\":[\"URL kopieren\"],\"EF2EU9\":[\"Wird gelöscht...\"],\"EPMHs9\":[\"Sie haben keine Berechtigung, Flavors in diesem Projekt zu löschen.\"],\"EQnVgi\":[\"Der Flavor-Service ist für dieses Projekt nicht verfügbar.\"],\"EdQY6l\":[\"Keine\"],\"Ef7StM\":[\"Unknown\"],\"Enpdmy\":[\"Geben Sie <0>entfernen zur Bestätigung ein:\"],\"EoKe5U\":[\"Domain\"],\"Eq5PsT\":[\"\\\"detach\\\" zur Bestätigung eingeben\"],\"EqSPkP\":[\"Lädt Flavors...\"],\"Erlvqg\":[\"Objektname darf keine führenden oder nachfolgenden Leerzeichen haben\"],\"ExLULX\":[\"Image-Name\"],\"EztMB8\":[\"Fehler beim Abrufen der Flavors vom Server.\"],\"F02e8I\":[\"Keine benutzerdefinierten Metadaten. Klicken Sie auf \\\"Eigenschaft hinzufügen\\\", um eine zu erstellen.\"],\"F6YIQe\":[\"Effiziente Massenlöschung\"],\"FKL6Jv\":[\"z. B. .r:*,.rlistings\"],\"FNcMGM\":[\"Creation Date\"],\"FOcBn3\":[\"Trennen\"],\"FPsvA8\":[\"Got it!\"],\"FQBaXG\":[\"Aktivieren\"],\"FRtmJJ\":[\"Storage-Container nicht gefunden\"],\"FSbpS7\":[\"CPU\"],\"FjONW3\":[\"Fehler beim Laden des Flavors\"],\"FjPnAE\":[\"Fehler beim Laden der Security Group\"],\"Flugry\":[[\"progressPct\"],\"%\"],\"FrLdVI\":[\"Bucket to empty:\"],\"FwSyEp\":[\"Das angegebene Projekt existiert nicht oder Sie haben keine Berechtigung, es zu teilen.\"],\"Fzrzfe\":[\"Ordnername ist erforderlich\"],\"G6AP+o\":[\"Geteilt:\"],\"GDx4dP\":[\"Manage your Certificate\"],\"GEgjm+\":[\"Objekte werden geladen...\"],\"GPuCEo\":[\"Leer lassen für alle Typen\"],\"GSIPwA\":[\"Temporäre URL\"],\"GbKqnI\":[[\"successCount\"],\" Image(s) aktiviert, aber \",[\"failedCount\"],\" Image(s) konnten nicht aktiviert werden.\"],\"Gfx1qQ\":[\"Inhalt kann nicht geladen werden\"],\"GxkJXS\":[\"Wird hochgeladen...\"],\"Gyd3No\":[\"Kein spezifischer Projektzugriff für diesen privaten Flavor konfiguriert. Klicken Sie auf \\\"Projektzugriff hinzufügen\\\", um Zugriff zu gewähren.\"],\"H+a5j6\":[\"Freigeben\"],\"H4Qwmp\":[\"Keine Objekte entsprechen Ihrer Suche. Versuchen Sie, Ihren Suchbegriff anzupassen.\"],\"H7u085\":[\"Noch kein Projekt hat Zugriff auf dieses Image. Klicken Sie auf \\\"Projektzugriff hinzufügen\\\", um Zugriff zu gewähren.\"],\"HAkrpK\":[\"Bei Aurora ist es unsere Mission, eine zentrale Plattform bereitzustellen, die das Cloud-Management vereinheitlicht. Wir streben danach, die Komplexitäten der Bereitstellung, Konfiguration und Skalierung von Ressourcen über verschiedene Cloud-Umgebungen hinweg zu vereinfachen und gleichzeitig ein nahtloses Wachstum für Ihr Unternehmen zu ermöglichen.\"],\"HBpi4q\":[\"Lädt Images...\"],\"HG0uMz\":[\"Back to Certificate Authorities\"],\"HM56Bx\":[\"Creating...\"],\"HNlEFZ\":[\"delete\"],\"HQH8HM\":[\"\\\"\",[\"objectName\"],\"\\\" konnte nicht aktualisiert werden: \",[\"errorMessage\"]],\"HVdrr1\":[\"Beliebiger Referrer\"],\"HiDAFk\":[\"Create Bucket\"],\"HivZR9\":[\"Create Credential\"],\"Hivb/4\":[\"Der Server hat Probleme. Bitte versuchen Sie es später erneut.\"],\"Hiw1Ha\":[\"Keine Container gefunden\"],\"HlwgQN\":[\"Objekt \\\"\",[\"objectName\"],\"\\\" wurde dauerhaft gelöscht.\"],\"HuA8iQ\":[\"Floating IP wird zugewiesen...\"],\"HxTYrE\":[\"Der Flavor konnte nicht gefunden werden. Möglicherweise wurde er gelöscht.\"],\"I5kZVK\":[\"Entfernte Quelle\"],\"INUP6f\":[\"<0>Mühelose Ressourcenbereitstellung: Stellen Sie Ressourcen wie Server, Netzwerke und Volumes schnell bereit, konfigurieren Sie diese und setzen Sie sie mit nur wenigen Klicks ein.\"],\"IOkHLC\":[\"Fehler beim Kopieren des Objekts: \",[\"errorMessage\"]],\"IQSLN+\":[\"Error loading Certificate Authority\"],\"IQldU4\":[\"Bucket name must contain only lowercase letters, numbers, periods, and hyphens\"],\"IUwGEM\":[\"Änderungen speichern\"],\"IWF68U\":[\"Speicherübersicht\"],\"IZ6Mh2\":[\"Geben Sie den Domain ein\"],\"IbYr/u\":[\"Content type\"],\"Io2Dvq\":[\"Certificate Authority not found\"],\"Ioblgz\":[\"Diese Aktion ist dauerhaft. Alle Objekte im Container werden gelöscht und dies kann nicht rückgängig gemacht werden.\"],\"IwlPLb\":[\"Delete Bucket\"],\"J4DKSM\":[\"Container-Format ist erforderlich\"],\"J6EOll\":[\"Objekt verschieben/umbenennen:\"],\"J7+bZb\":[\"Ordner gelöscht\"],\"J9QcnV\":[[\"successCount\"],\" von \",[\"totalCount\"],\" Image(s) erfolgreich aktiviert\"],\"J9cmxx\":[\"Fehler beim Aktualisieren der Sichtbarkeit auf \",[\"newVisibility\"]],\"JB0bhm\":[\"Mitmachen\"],\"JNGYAW\":[\"Container-Name ist erforderlich\"],\"JPWqr2\":[\"Bucket \\\"\",[\"bucketName\"],\"\\\" was successfully emptied. \",[\"deletedCount\"],\" objects deleted.\"],\"JT3I1g\":[\"Flavor löschen\"],\"JeRXll\":[\"Dieser Schlüssel ist reserviert und wird separat verwaltet\"],\"JfWCsP\":[\"Teilweise erfolgreich deaktiviert\"],\"Jh4rAZ\":[\"Fehler beim Laden des Images\"],\"Jim5X9\":[\"Stateful\"],\"JoECY1\":[\"Die bereitgestellten Metadata-Daten sind ungültig. Bitte überprüfen Sie Ihre Eingabe.\"],\"JofLr3\":[\"Error Loading Buckets: \",[\"errorMessage\"]],\"JpZn1L\":[\"Bereits deaktiviert (wird übersprungen)\"],\"JrmKyf\":[\"Fehlgeschlagen: \",[\"errorDetails\"]],\"JtHgVz\":[\"Images löschen\"],\"K+e/0e\":[\"RAM (MiB)\"],\"K3bUTE\":[\"Minimale Festplatte muss 0 oder größer sein\"],\"K8Qnlj\":[\"Wird verschoben...\"],\"K9eC8x\":[\"Dies kann auf unzureichende Berechtigungen oder ein vorübergehendes Serviceproblem zurückzuführen sein. Bitte überprüfen Sie Ihre Zugriffsrechte oder aktualisieren Sie die Seite.\"],\"KDw4GX\":[\"Erneut versuchen\"],\"KJC+M7\":[\"Serverfehler beim Abrufen der Flavor-Details. Bitte versuchen Sie es später erneut.\"],\"KOpPMt\":[\"Gesamtspeicher-Quota\"],\"KSW/GC\":[\"Keine Flavors verfügbar. Filtern Sie neu oder erstellen Sie einen neuen Flavor.\"],\"KZN4Lc\":[\"Alle löschen\"],\"Km4AGG\":[\"Sicherheitsgruppe wird erstellt...\"],\"KoQP4F\":[\"Ein Serverfehler ist aufgetreten, während der Projektzugriff hinzugefügt wurde. Bitte versuchen Sie es später noch einmal.\"],\"KsIM0b\":[\"Boot-RAM\"],\"KsnZ3m\":[\"Ordner \\\"\",[\"folderName\"],\"\\\" wurde erfolgreich erstellt.\"],\"KzUd7m\":[\"new-folder-name\"],\"LI70tz\":[\"Error loading Certificate\"],\"LI8Z2I\":[[\"rowDisplayName\"],\" herunterladen\"],\"LK0pQN\":[\"Festplattenformat ist erforderlich\"],\"LMdsuJ\":[\"Port (von)\"],\"LQQCas\":[\"Ordner \\\"\",[\"folderName\"],\"\\\" und \",[\"deletedCount\"],\" Objekt wurden dauerhaft gelöscht.\"],\"Llcakz\":[\"Aktualisiert am\"],\"LqMb+g\":[\"Um diese Aktion zu bestätigen, geben Sie das Wort <0>\\\"release\\\" in das Feld unten ein.\"],\"LtI9AS\":[\"Eigentümer\"],\"Lylr9Z\":[\"Objekt kopiert\"],\"M3bysB\":[\"Bucket Created\"],\"M470oJ\":[\"Der Flavor konnte nicht gefunden werden oder hat keine Metadata.\"],\"M5Epeo\":[\"Image-Details bearbeiten\"],\"M5RhXF\":[\"Wird entfernt...\"],\"M5rEN5\":[\"Sitzung abgelaufen\"],\"M9H+/G\":[\"Projekte\"],\"MEIAzV\":[\"Unbenannt\"],\"MILoeL\":[\"Services\"],\"MJLqeY\":[\"Failed to check bucket contents: \",[\"errorMessage\"]],\"MJtNLd\":[\"Zu löschende Images:\"],\"MOug+V\":[\"Geben Sie ein Tag ein und drücken Sie Enter oder klicken Sie auf Hinzufügen\"],\"MRB7nI\":[\"Richtung\"],\"MXoA/6\":[\"Objekt hochladen\"],\"MXw7Fr\":[\"Servername\"],\"MZGbkp\":[\"VCPUs\"],\"MbKJNP\":[\"Sie haben keine Berechtigung, auf die Zugriffsinformationen für diesen Flavor zuzugreifen.\"],\"MgZyuJ\":[\"Sie sind dabei, <0>\",[\"deactivatedCount\"],\" Image(s) zu aktivieren. Aktivierte Images stehen zum Starten neuer Instances zur Verfügung.\"],\"MmtQVF\":[\"Ungültiger Wert für die Einstellung des öffentlichen Flavors.\"],\"Mt6sRo\":[\"Sie sind nicht berechtigt, auf die Zugriffsinformationen des Flavors zuzugreifen. Bitte melden Sie sich erneut an.\"],\"MtzSbv\":[\" Objektname ist erforderlich\"],\"MuKU9V\":[\"Failed to load objects: \",[\"errorMessage\"]],\"N2S1rs\":[\"Leeren\"],\"N5I2RJ\":[\"\\\"release\\\" zur Bestätigung eingeben\"],\"N5vGcw\":[\"Geben Sie Ihre Anmeldedaten ein, um auf Ihr Konto zuzugreifen.\"],\"NH2fsP\":[\"Bereits deaktiviert (wird übersprungen):\"],\"NNpgo3\":[\"Bucket name\"],\"NOdFZR\":[\"Wird generiert...\"],\"NQU1Nn\":[\"Container-Name kopieren\"],\"NRMm0E\":[\"Dieses Projekt hat bereits Zugriff auf den Flavor.\"],\"NRP2uq\":[\"Objekt teilen:\"],\"NRVSdy\":[\"Mitglieds-ID\"],\"NW1XEL\":[\"Emptying bucket \",[\"progressCurrent\"],\" of \",[\"progressTotal\"],\", please wait...\"],\"NW4PIb\":[\"Ordner \\\"\",[\"folderName\"],\"\\\" konnte nicht erstellt werden: \",[\"errorMessage\"]],\"NZJhro\":[\"Objektname darf keine Schrägstriche enthalten\"],\"Nc7QKU\":[\"Feste IP-Adresse\"],\"NeUjqc\":[\"Dateilisting aktivieren\"],\"NixRmA\":[\"Min. Disk (GB)\"],\"NlcF/v\":[\"Kein Flavor für die Löschung ausgewählt.\"],\"No++00\":[\"Bucket to delete:\"],\"NopYGU\":[\"Festplattenformat\"],\"Np28ib\":[\"oder per Drag & Drop\"],\"Nu4oKW\":[\"Beschreibung\"],\"Nvfd2b\":[\"Versionierung ist aktiviert\"],\"O80bQY\":[\"Objekt-Eigenschaften werden geladen...\"],\"O8tK4v\":[\"Regel hinzufügen\"],\"ONWvwQ\":[\"Hochladen\"],\"OR475H\":[\"Netzwerk\"],\"OSlLnz\":[\"Image-Sichtbarkeit\"],\"OYHzN1\":[\"Tags\"],\"OZImTR\":[\"Container-Listing-Limit\"],\"OaSktR\":[\"Geräteeigentümer\"],\"Oc8Aqv\":[\"Vorschau und Metadaten bearbeiten\"],\"OlmKCg\":[\"Ein Flavor mit dieser ID oder diesem Namen existiert bereits. Bitte verwenden Sie andere Werte.\"],\"OvEjsP\":[\"Wird kopiert...\"],\"Ovofy+\":[\"Floating IP \",[\"floating_ip_address\"],\" freigeben\"],\"OxDN2m\":[\"Fehler beim Erstellen des Flavors. Bitte versuchen Sie es erneut.\"],\"OxaeYj\":[\"Wir entwickeln das Aurora-Dashboard, um Ihnen einen besseren Service zu bieten. Ihr Feedback ist von unschätzbarem Wert, um ein Werkzeug zu gestalten, das den einzigartigen Bedürfnissen von Unternehmen wie Ihrem gerecht wird. Bleiben Sie in Verbindung und begleiten Sie uns, während wir das Cloud-Management neu definieren.\"],\"Oxl1UN\":[\"Wenn keine Index-Datei vorhanden ist, zeigt die URL eine Liste der Objekte im Container an.\"],\"PAKSdy\":[\"Floating IP eingeben oder leer lassen für automatische Zuweisung\"],\"PEGvy+\":[\"Minimaler RAM muss 0 oder größer sein\"],\"PHsq3v\":[\"Stellen Sie vor dem Fortfahren sicher, dass die eingegebene Projekt-ID und Benutzer-ID korrekt sind. Das System kann diese Werte nicht validieren, und falsche IDs können Zugriff auf falsche Projekte und Benutzer gewähren.\"],\"PHt+EV\":[\"<0>delete zur Bestätigung eingeben:\"],\"PIbPRX\":[\"RX/TX Faktor muss eine ganze Zahl ≥ 1 sein.\"],\"PLwzWR\":[\"Alle Container\"],\"PYQUjU\":[\"Metadaten-Konfiguration konnte nicht geladen werden.\"],\"PZnUbs\":[\"Bitte melden Sie sich erneut an, um fortzufahren.\"],\"PgNNGl\":[\"Weitere Aktionen\"],\"PiH3UR\":[\"Kopiert!\"],\"PiyQJ/\":[\"Keine Flavors gefunden\"],\"PkfPsB\":[\"Geben Sie die ID des Projekts ein, mit dem Sie diese Sicherheitsgruppe teilen möchten. Sie finden Projekt-IDs im Konto-/Projekt-Umschalter oder im Identity-Service.\"],\"Pkw7J9\":[\"Dieser Ordner ist leer.\"],\"PsEGri\":[\"Ubuntu 22.04 LTS\"],\"PtjzS+\":[\"Wird dem ausgewählten Port zugeordnet. Wenn der Port mehrere IPs hat, wählen Sie die gewünschte feste IP-Adresse.\"],\"PzgYM9\":[\"Prüfsumme\"],\"Q1W//7\":[\"Keine Dienste für dieses Projekt verfügbar.\"],\"Q2xmVl\":[\"Symlinks\"],\"Q9f2QF\":[[\"numberDeleted\"],\" Objekte wurden erfolgreich gelöscht, aber einige Löschvorgänge sind fehlgeschlagen.\"],\"QAUa4B\":[\"Geben Sie einen einzelnen Port ein oder definieren Sie einen Bereich, indem Sie auch \\\"Port (bis)\\\" ausfüllen. \\\"Port (bis)\\\" ist optional.\"],\"QEtDlS\":[\"Objekt wird kopiert...\"],\"QNHur0\":[\"Fehler beim Laden der Container-ACLs: \",[\"errorMessage\"]],\"QQ8wUG\":[\"Diese Aktion kann nicht rückgängig gemacht werden. Der Flavor wird dauerhaft gelöscht.\"],\"QRlXaL\":[\"Bucket name must not end with reserved suffix \\\"\",[\"suffix\"],\"\\\"\"],\"QV1ZPO\":[\"Schlüssel ist erforderlich\"],\"QWdKwH\":[\"Verschieben\"],\"QYiqYb\":[\"Fehler beim Aktualisieren des Zugriffsstatus\"],\"Qb+14I\":[\"Diese Aktion kann nicht rückgängig gemacht werden. Die Security Group wird dauerhaft gelöscht.\"],\"QetsXP\":[\"Upload fehlgeschlagen: \",[\"uploadError\"]],\"Qg4EG6\":[\"Die bereitgestellten Flavor-Daten sind ungültig. Bitte überprüfen Sie Ihre Eingabe.\"],\"QpbWpf\":[\"Bucket \\\"\",[\"bucketName\"],\"\\\" was successfully emptied. \",[\"deletedCount\"],\" object deleted.\"],\"QuJSSl\":[\"Fehler beim Erstellen des Flavors. Bitte versuchen Sie es erneut.\"],\"QvqBQa\":[\"Ziel-Container\"],\"Qx7DM7\":[\"Capabilities\"],\"QxBGbh\":[\"Geschützt (wird übersprungen):\"],\"QytzQr\":[\"Geben Sie \\\"delete\\\" zur Bestätigung ein\"],\"R6kcsL\":[\"Muss eine gültige PQDN oder FQDN sein (nur alphanumerische Zeichen und Bindestriche, darf nicht mit einem Bindestrich beginnen oder enden).\"],\"R6u5CR\":[[\"failedCount\"],\" von \",[\"totalCount\"],\" Image(s) konnten nicht aktiviert werden. Einige Images sind möglicherweise bereits aktiv oder in einem ungültigen Zustand.\"],\"RByeNR\":[\"Ihre Sitzung ist abgelaufen. Bitte melden Sie sich erneut an.\"],\"RCr0yv\":[\"Flavor-Details konnten nicht geladen werden. Bitte versuchen Sie es erneut.\"],\"RFDYCD\":[\"Minimale Festplattengröße, die zum Booten dieses Images erforderlich ist\"],\"RGhYAo\":[\"RAM\"],\"RGrgxg\":[\"Container \\\"\",[\"containerName\"],\"\\\" konnte nicht gelöscht werden: \",[\"errorMessage\"]],\"RGwfoL\":[\"Maximale Metadaten-Anzahl\"],\"RNBvdl\":[\"Maximale SLO-Segmente\"],\"RS0o7b\":[\"State\"],\"RSFkXF\":[\"Image aktivieren\"],\"RSMPjT\":[\"Sie befinden sich derzeit auf der Dashboard-Route.\"],\"RSg/pq\":[\"Fehler beim Löschen des Objekts\"],\"RTQFAw\":[\"Sie sind nicht berechtigt, Metadata zu erstellen. Bitte melden Sie sich erneut an.\"],\"RWQ6BN\":[\"Enter Common name (e.g., demo-ca.test.sci)\"],\"Rih53k\":[\"Maximale Container-Namenslänge\"],\"Rlp5zj\":[\"Flavor erstellen\"],\"S0kLOH\":[\"ID\"],\"S1iTXO\":[\"Image-Instanz \\\"\",[\"imageName\"],\"\\\" wurde gelöscht\"],\"S3olSf\":[\"Keine Metadata gefunden. Klicken Sie auf \\\"Metadata hinzufügen\\\", um eine zu erstellen.\"],\"S5CUKP\":[\"Mitglieds-ID (Projekt-UUID) ist erforderlich.\"],\"S63NbU\":[\"Das Image \\\"\",[\"imageId\"],\"\\\" konnte nicht deaktiviert werden: \",[\"message\"]],\"S8/j2h\":[\"Fehler beim Leeren des Containers\"],\"SBGiGm\":[\"Lese-ACLs\"],\"SCY5an\":[\"Fehler beim Verschieben des Objekts\"],\"SFo0kK\":[\"Alle Images\"],\"SIfYq6\":[\"Metadaten bearbeiten\"],\"SLEH7X\":[\"DNS-Name eingeben\"],\"STc+7E\":[\"Maximale Container pro Extraktion\"],\"SU0uxT\":[\"Objekt hochladen nach:\"],\"SUSS9i\":[\"Container-Name\"],\"SVLToM\":[\"\\\"remove\\\" zur Bestätigung eingeben\"],\"SZw9tS\":[\"Details ansehen\"],\"Sb/VT5\":[\"Container \\\"\",[\"containerName\"],\"\\\" wurde erfolgreich geleert. \",[\"deletedCount\"],\" Objekte gelöscht.\"],\"Sf3Gvg\":[\"Failed to load PCAs\"],\"SfW/3r\":[\"Keine Gruppen vorhanden\"],\"Sgz1vJ\":[\"Mitglied \\\"\",[\"trimmedMemberId\"],\"\\\" wurde erfolgreich hinzugefügt.\"],\"Smk7M2\":[\"Fehler beim Laden der Floating-IP\"],\"SuX2Ca\":[\"Grundlegende Informationen\"],\"SysqAR\":[\"Flavor-Details\"],\"T6Gm5y\":[\"Externes Netzwerk auswählen\"],\"T7mgdd\":[[\"successCount\"],\" von \",[\"totalCount\"],\" Image(s) erfolgreich gelöscht\"],\"T8N6oi\":[\"Eigenschaftsschlüssel\"],\"T9Mtpi\":[\"Projekt ID\"],\"T9o/az\":[\"Loading Certificates issued by Certificate Authority...\"],\"TM93nK\":[\"Sicherheitsgruppenregel löschen\"],\"TPMaxo\":[\"“release” zur Bestätigung eingeben\"],\"TQn3hH\":[\"Image konnte nicht erstellt werden. Bitte versuchen Sie es erneut.\"],\"TZJiVf\":[\"Container \\\"\",[\"containerName\"],\"\\\" wurde erfolgreich geleert. \",[\"deletedCount\"],\" Objekt gelöscht.\"],\"TfC9O+\":[\"Zuletzt geändert (UTC)\"],\"TfdeUd\":[\"Fehler beim Löschen des Extra-Specs. Bitte versuchen Sie es erneut.\"],\"TpGxnq\":[\"Mitglieds-ID eingeben\"],\"Tx4Ym+\":[\"Gültige PQDN oder FQDN (max. 63 Zeichen) eingeben, um sie der Floating IP zuzuordnen. A- und PTR-Einträge werden automatisch erstellt.\"],\"TyODHt\":[\"Metadata speichern\"],\"U/oahm\":[\"URL kopiert\"],\"U2wTy/\":[\"Hinweis: Das Attribut 'stateful' kann nicht geändert werden, wenn diese Security Group derzeit von einem oder mehreren Ports verwendet wird.\"],\"U4fmHG\":[\"Der Text muss “detach” in Kleinbuchstaben entsprechen.\"],\"U6L+P/\":[\"Inaktivitäts-Timeout\"],\"U9q4M7\":[\"Zurück zu Security Groups\"],\"UB+Q8v\":[\"Loading Certificate Details...\"],\"UGhVPl\":[\"Objekttyp\"],\"UJVf0u\":[\"Image wird geladen...\"],\"UJmAAK\":[\"Subject\"],\"UK2mpr\":[\"Temporäre URL wird generiert...\"],\"UKwOYH\":[\"Image-Datei\"],\"UO3hJ2\":[\"Temporäre URLs\"],\"UQ7Wyv\":[\"Zugriff für Image verwalten - \",[\"imageName\"]],\"URmyfc\":[\"Details\"],\"USiuNX\":[\"Container-Quotas\"],\"UVFHGY\":[\"z. B. PROJECT_ID:USER_ID\"],\"UVSFVV\":[\"Geteiltes Image ablehnen\"],\"UYSopm\":[\"Minimaler RAM (MB)\"],\"UaP7Th\":[\"Failed to Create Bucket\"],\"UbRKMZ\":[\"Ausstehend\"],\"UbWeJA\":[\"Duration/validity\"],\"UdcGJu\":[\"Images aktivieren\"],\"UiNv/G\":[\"S3 Object Storage requires EC2 credentials (access key + secret key) to authenticate your requests. You need to create credentials before accessing S3 resources.\"],\"Uj+n/2\":[\"Fehler beim Löschen des Ordners\"],\"UkVkoq\":[\"Leer lassen für alle Codes\"],\"UmQ3/m\":[\"Ausgewählte deaktivieren\"],\"Uwo8Xw\":[\"Dieses Image wurde am \",[\"sharedAt\"],\" von <0>\",[\"ownerProject\"],\" mit Ihnen geteilt.\"],\"UztfYZ\":[\"Port zur Zuordnung auswählen\"],\"V/8B9A\":[\"Ich bestätige, dass alle vorhandenen Versionen ebenfalls gelöscht werden\"],\"V/SINY\":[\"Objekt aktualisieren\"],\"V1TzeS\":[\"Container \\\"\",[\"containerName\"],\"\\\" wurde erfolgreich gelöscht.\"],\"V66Jih\":[\"Zugriffsstatus\"],\"V7fN5X\":[\"Objekt kopieren:\"],\"V8/eES\":[\"Bucket name must not contain consecutive periods\"],\"V804LY\":[\"Security Group wird aktualisiert...\"],\"VCM3KS\":[\"Projektzugriff hinzufügen\"],\"VG2a+x\":[\"Bucket name must start and end with a letter or number\"],\"VKCkZH\":[\"Bucket \\\"\",[\"bucketName\"],\"\\\" was successfully created.\"],\"VKmlZ+\":[\"Zu leerende Container (\",[\"totalCount\"],\")\"],\"VLI9eO\":[\"Floating IP-Details werden geladen...\"],\"VMh1t1\":[\"The text must match “delete” in lowercase.\"],\"VV1fdg\":[\"Jeder Benutzer hat Lesezugriff auf Objekte. Es ist kein Token in der Anfrage erforderlich.\"],\"VaA9mu\":[\"24 Stunden\"],\"VakxP/\":[\"Fehler beim Hochladen des Objekts\"],\"Vg0k6h\":[\"Zeige \",[\"filteredCount\"],\" von \",[\"totalCount\"],\" \",[\"itemName\"]],\"Vh/Uj5\":[\"Zielpfad\"],\"Vj8XFg\":[\"Fehler beim Erstellen des Containers\"],\"Vl4XTj\":[\"Ordnername darf keine Schrägstriche enthalten\"],\"Vmojta\":[\" Zugriffsstatus auf \\\"\",[\"newStatus\"],\"\\\" aktualisiert.\"],\"VoxR3s\":[\"Objekt wurde kopiert, konnte aber nicht von der Quelle gelöscht werden: \",[\"deleteErrorMessage\"]],\"Vz+7ZA\":[\"Container \\\"\",[\"containerName\"],\"\\\" konnte nicht erstellt werden: \",[\"errorMessage\"]],\"Vzlopx\":[\"Container löschen:\"],\"W0MCSG\":[\"Zugriff auf Image akzeptieren\"],\"W5FkH9\":[\"Container-Name eingeben\"],\"W8Rb/w\":[\"Bucket name must not be formatted as an IP address\"],\"W9PZE0\":[\"Objekte gelöscht\"],\"W9kfjU\":[\"QoS-Policy-ID\"],\"WCKEqI\":[\"Dies ist ein <0>statisches großes Objekt (SLO)-Manifest. Metadaten-Änderungen gelten nur für das Manifest — Segment-Objekte sind nicht betroffen.\"],\"WCLyHI\":[\"Keine Floating IPs gefunden\"],\"WErCZy\":[\"Minimaler RAM, der zum Booten dieses Images erforderlich ist\"],\"WIx31g\":[\"Create Certificate\"],\"WRZ3Mt\":[\"Container-Eigenschaften werden geladen...\"],\"WYb0Td\":[\"<0>Sind Sie sicher? Alle Objekte in den ausgewählten Containern werden dauerhaft gelöscht. Dies kann nicht rückgängig gemacht werden.\"],\"WYiUDa\":[\"Container werden geladen...\"],\"Wbg1jv\":[[\"text\"],\" in die Zwischenablage kopieren\"],\"Wca9WC\":[\"Security Groups konnten nicht geladen werden\"],\"WefafP\":[\"Dieser Container scheint leer zu sein — die Objekt-Anzahl ist möglicherweise aufgrund einer kürzlichen Operation noch nicht synchronisiert.\"],\"WidMsn\":[\"Create Certificate Authority\"],\"WlpcJv\":[\"DNS-Domain\"],\"WoSkGY\":[\"Entfernte IP\"],\"WrUky8\":[\"Teilen (Temporäre URL)\"],\"WyKwnD\":[\"Alte Objekt-Versionen speichern\"],\"WzVwU0\":[\"Zielprojekt-ID\"],\"X2OnDx\":[\"Ephemeral Disk (GiB)\"],\"X70LXS\":[[\"numberDeleted\"],\" Objekte wurden dauerhaft gelöscht.\"],\"XLk16/\":[\"Gemeinsam können wir das volle Potenzial Ihrer Cloud-Infrastruktur erschließen.\"],\"XYZLy9\":[\"Schlüssel enthält ungültige Zeichen\"],\"XvjC4F\":[\"Wird gespeichert...\"],\"XwxJJB\":[\"Container \\\"\",[\"containerName\"],\"\\\" wurde erfolgreich erstellt.\"],\"XxjLdW\":[[\"emptiedCount\",\"plural\",{\"one\":[\"#\",\" Container war bereits leer.\"],\"other\":[\"#\",\" Container waren bereits leer.\"]}]],\"Y+2SDm\":[\"Security Group \\\"\",[\"securityGroupName\"],\"\\\" löschen\"],\"Y1YKad\":[\"Details bearbeiten\"],\"Y8M9Uc\":[\"Der Container wird gelöscht. Diese Aktion ist dauerhaft und kann nicht rückgängig gemacht werden.\"],\"YIix5Y\":[\"Suchen...\"],\"YNgcgc\":[\"Flavor-Details werden geladen...\"],\"YRexkb\":[\"Objekt aktualisiert\"],\"YUU0QW\":[\"Flavor-ID ist erforderlich und darf nicht leer sein\"],\"YVLcyI\":[\"Successfully emptied \",[\"emptiedCount\"],\" \",[\"emptiedCount\",\"plural\",{\"one\":[\"bucket\"],\"other\":[\"buckets\"]}],\", deleting \",[\"totalDeleted\"],\" \",[\"totalDeleted\",\"plural\",{\"one\":[\"object\"],\"other\":[\"objects\"]}],\".\"],\"YZmsaT\":[\"Teilweise erfolgreich aktiviert\"],\"YiMCKk\":[\"ACLs-Vorschau anzeigen\"],\"Yin3uB\":[\"Floating IP wird freigegeben...\"],\"YjAOtb\":[\"Security Group erstellen\"],\"YrAy/S\":[\"Sie haben keine Berechtigung, Metadata für diesen Flavor zu löschen.\"],\"YsOJlj\":[\"Ein Serverfehler ist aufgetreten, während die Zugriffsinformationen für den Flavor abgerufen wurden. Bitte versuchen Sie es später noch einmal.\"],\"YsrbQh\":[\"Eigentümer-Projekt-ID\"],\"YuC9dj\":[\"Zuordnen\"],\"YuGQWb\":[\"Regeltyp\"],\"YzUoh9\":[\"Geben Sie zur Bestätigung <0>delete in das Feld unten ein.\"],\"Z/eWPC\":[\"Das Objekt wird zu diesem Pfad kopiert. Navigieren Sie oben durch die Ordner, um das Ziel zu ändern.\"],\"Z2fZGD\":[\"Kein Projekt ausgewählt\"],\"Z3FXyt\":[\"Lädt...\"],\"Z42tfY\":[\"Ordner im Object Storage sind virtuell — sie werden als Null-Byte-Platzhalterobjekte mit einem abschließenden Schrägstrich erstellt. Der Ordner wird nach der Erstellung angezeigt.\"],\"Z5r9vC\":[\"Teilweise erfolgreich gelöscht\"],\"Z8lGw6\":[\"Teilen\"],\"ZAx+d1\":[\"Maximale Gesamt-Metadatengröße\"],\"ZAy0zp\":[[\"successCount\"],\" von \",[\"totalCount\"],\" Image(s) erfolgreich deaktiviert\"],\"ZUmOzn\":[\"Der Server hat ein unerwartetes Datenformat für Flavor-Details zurückgegeben.\"],\"ZcWMT1\":[\"Image-Instanz \\\"\",[\"imageName\"],\"\\\" wurde deaktiviert\"],\"Zgp2Sm\":[\"Keine Projekte verfügbar.\"],\"ZhVSpK\":[\"Fehler beim Entfernen des Mandantenzugriffs vom Flavor. Bitte versuchen Sie es erneut.\"],\"Zq6Y5u\":[\"Der DNS-Name darf höchstens 63 Zeichen lang sein.\"],\"Zshv0H\":[\"Buckets to empty:\"],\"ZvIpwi\":[\"Security Group auswählen...\"],\"Zw49f9\":[\"folder-name\"],\"Zw8Q49\":[\"Security Group nicht gefunden\"],\"a/nTb8\":[\"Image erstellen\"],\"a12lSo\":[\"Port (bis)\"],\"a13wDR\":[\"Zum Suchen von Containern eingeben...\"],\"a3LDKx\":[\"Sicherheit\"],\"a4A2uB\":[\"Account-Listing-Limit\"],\"a4N/Bg\":[\"Mehr laden\"],\"a7C4YS\":[\"Container aktualisiert\"],\"a88X3d\":[\"<0>Sind Sie sicher? Objekt <1>\\\"\",[\"displayName\"],\"\\\" wird dauerhaft gelöscht. Dies kann nicht rückgängig gemacht werden.\"],\"aG9OiI\":[\"Freigabe-Details\"],\"aI8Tgp\":[\"Eigentümer-Projekt-ID\"],\"aL1w5Z\":[\"Belegt\"],\"aOeFR+\":[\"Container leeren\"],\"aSsVD3\":[\"Öffentlicher Lesezugriff ist nicht aktiviert. Bevor Sie statisches Website-Serving konfigurieren, gehen Sie zu <0>Zugriff verwalten und aktivieren Sie den öffentlichen Lesezugriff.\"],\"aTqCTq\":[\"Image-Datei ist erforderlich\"],\"aV6KPH\":[\"Versehentliches Löschen verhindern\"],\"aiqFbS\":[\"<0>Sind Sie sicher? Die ausgewählten Objekte werden dauerhaft gelöscht. Dies kann nicht rückgängig gemacht werden.\"],\"an5hVd\":[\"Images\"],\"ao/ZJi\":[\"Ordner und alle Inhalte werden gelöscht...\"],\"aqagJH\":[\"Image-Sichtbarkeit kann nicht aktualisiert werden\"],\"arel2K\":[\"Keine Objekte gefunden\"],\"azXlY+\":[\"Zugriffsstatus:\"],\"b0uU1G\":[\"Alte Objekt-Versionen in Container speichern:\"],\"b2BLBa\":[\"Sicherheitsgruppenregel hinzufügen\"],\"b5aNMO\":[\"Der Text muss \\\"delete\\\" entsprechen\"],\"b95YH9\":[\"Bucket Emptied\"],\"bHUarC\":[\"Bucket Deleted\"],\"bISG26\":[\"Fehler beim Abrufen der Flavor-Zugriffsinformationen. Bitte versuchen Sie es erneut.\"],\"bM1O3m\":[\"Image-Instanz\"],\"bQBMTH\":[\"Dies ist ein <0>statisches großes Objekt. Standardmäßig werden auch alle zugehörigen Segment-Objekte dauerhaft gelöscht.\"],\"bRgFkJ\":[\"Fehler beim Hochladen der Datei \\\"\",[\"fileName\"],\"\\\": \"],\"bYRFNi\":[\"Fehler beim Löschen der Objekte\"],\"bc67JN\":[\"Benutzerdefinierte Eigenschaften / Metadaten\"],\"bmQLn5\":[\"Regel hinzufügen\"],\"bnql/K\":[\"Zurück zu Images\"],\"boJ+Y1\":[\"Ordner erstellen\"],\"boJlGf\":[\"Seite nicht gefunden\"],\"boefwq\":[\"Could not create bucket \\\"\",[\"bucketName\"],\"\\\": \",[\"errorMessage\"]],\"bpme7e\":[\"Flavor nicht gefunden\"],\"bwRvnp\":[\"Aktion\"],\"bwhBhT\":[\"Security Group\"],\"byKna+\":[\"Ein unerwarteter Fehler ist aufgetreten. Bitte versuchen Sie es erneut.\"],\"bzMKg7\":[\"Akzeptiert\"],\"bzSI52\":[\"Verwerfen\"],\"c+fUtV\":[\"Beginnen Sie mit der Eingabe, um nach einem Container zu suchen\"],\"c+xCSz\":[\"True\"],\"c1OE1x\":[\"CA ID\"],\"c1uL4p\":[\"Das Image \\\"\",[\"imageId\"],\"\\\" konnte nicht gelöscht werden: \",[\"message\"]],\"c5i+X0\":[\"Loading Buckets...\"],\"c6b6fz\":[\"Ausgewählte löschen\"],\"cCfxH1\":[\"Wird heruntergeladen...\"],\"cJDQIO\":[\"Root Disk\"],\"cPKL6O\":[\"Sie sind nicht berechtigt, Flavors zu erstellen. Bitte melden Sie sich erneut an.\"],\"cWbW6w\":[\"Zugriff verwalten\"],\"cXuXkb\":[\"Benutzer \",[\"userId\"],\" aus Projekt \",[\"projectId\"]],\"chL5IG\":[\"Community\"],\"cj17eo\":[\"Image-Instanz \\\"\",[\"imageName\"],\"\\\" wurde aktiviert\"],\"cjEOmc\":[\"Teilen Sie diese Sicherheitsgruppe mit einem anderen Projekt. Das Zielprojekt kann diese Sicherheitsgruppe ansehen und verwenden, aber nicht ändern oder löschen.\"],\"cnGeoo\":[\"Löschen\"],\"cpw++p\":[\"Unterstützung für statische große Objekte\"],\"cqQyPB\":[\"Ordnername\"],\"ctc4XR\":[\"Delete certificate authority\"],\"d+F6q9\":[\"Erstellt\"],\"d+Ugpw\":[\"<0>Sind Sie sicher? Ordner <1>\\\"\",[\"folderDisplayName\"],\"\\\" und alle darin enthaltenen Objekte werden dauerhaft gelöscht. Dies kann nicht rückgängig gemacht werden.\"],\"d/I0J3\":[\"Ausgewählte aktivieren\"],\"d0pLfy\":[\"Security Group konnte nicht gelöscht werden\"],\"dEHa3L\":[\"Type the bucket name to confirm\"],\"dEgA5A\":[\"Abbrechen\"],\"dFb5Nt\":[\"Id\"],\"dLFiER\":[\"Fehler beim Laden der Container: \",[\"errorMessage\"]],\"dOevLB\":[\"Läuft ab in \",[\"selectedPresetLabel\"],\" — um \",[\"expiresAtFormatted\"]],\"dPBJAJ\":[\"Alle leeren (\",[\"selectedCount\"],\")\"],\"dPj4yB\":[\"Bei Ihrem Konto anmelden\"],\"dPoCVe\":[\"“detach” zur Bestätigung eingeben\"],\"dTNzBI\":[\"Schlüssel muss mindestens ein alphanumerisches Zeichen enthalten\"],\"dVdc7N\":[\"Sie sind nicht berechtigt, Metadata zu löschen. Bitte melden Sie sich erneut an.\"],\"dd2ndz\":[\"Einträge in ACLs werden durch Kommas getrennt. Beispiele:\"],\"diFNkW\":[\"Fehler beim Laden der Komponente\"],\"dxMaZH\":[\"Manage your Private Certificate Authority infrastructure\"],\"e0NrBM\":[\"Projekt\"],\"eChIh7\":[\"Flavor \\\"\",[\"flavorName\"],\"\\\" wurde erfolgreich erstellt.\"],\"eGEHJE\":[\"DNS-Name\"],\"eKC+EC\":[\"-\"],\"ePK91l\":[\"Bearbeiten\"],\"eYlnXt\":[\"Keine Images gefunden\"],\"eh/k36\":[\"Regeltyp auswählen...\"],\"ekCRTP\":[\"Abgelehnt\"],\"eks7oA\":[\"Port-ID\"],\"emkxJa\":[\"There are no buckets available with the current search criteria. Try adjusting your search term.\"],\"eu70nA\":[\"Läuft ab um (UTC)\"],\"eyRsaH\":[\"Root\"],\"ezT9KW\":[\"Container-Synchronisierung\"],\"f+Uq1E\":[\"Neuer Objektname\"],\"f0cwjH\":[\"Objekt-Eigenschaften\"],\"fCPhho\":[\"Ein oder mehrere Objekte konnten nicht gelöscht werden:\"],\"fIvd7X\":[\"Fehler beim Löschen der Images\"],\"fJpv9x\":[\"Fehler beim Deaktivieren der Images\"],\"ffw//c\":[\"PCA\"],\"fj5byd\":[\"keine Angabe\"],\"fnCEAB\":[\"Type “delete” to confirm\"],\"fxnDd7\":[\"Fehler beim Generieren der temporären URL: \",[\"generalError\"]],\"fzfAAa\":[\"Ingress\"],\"g+Jead\":[\"IPv6\"],\"g1IxCo\":[\"RAM muss eine ganze Zahl ≥ 128 MB sein.\"],\"g3BSCe\":[\"Swap-Disk muss eine ganze Zahl ≥ 0 sein.\"],\"g3UF2V\":[\"Akzeptieren\"],\"g8Yxlg\":[\"Temporäre URL für \\\"\",[\"objectName\"],\"\\\" wurde in die Zwischenablage kopiert.\"],\"g9m7gK\":[\"ACL-Einträge steuern, wer von diesem Container lesen oder in ihn schreiben kann. Mehrere Einträge werden durch Kommas getrennt. Änderungen werden sofort nach dem Speichern wirksam.\"],\"gFKJBP\":[\"Ordnername darf keine führenden oder nachfolgenden Leerzeichen haben\"],\"gFsjJK\":[[\"bucketCount\",\"plural\",{\"one\":[\"object\"],\"other\":[\"objects\"]}]],\"gGdfWx\":[\"Der Compute-Dienst ist für dieses Projekt derzeit nicht verfügbar. Bitte versuchen Sie es später erneut.\"],\"gHTJc/\":[\"Object Storage\"],\"gMYsdZ\":[\"Auf \\\"Geteilt\\\" setzen\"],\"gU7JFm\":[\"Sicherheitsgruppenregel wird erstellt...\"],\"gYe+hC\":[\"Zum Hochladen klicken\"],\"gjop1H\":[\"Bucket name must be at least 3 characters\"],\"go0J2x\":[\"Fehler beim Kopieren der temporären URL in die Zwischenablage\"],\"go9U+C\":[\"To confirm, type <0>\\\"delete\\\" in the field below.\"],\"grs4+e\":[\"Compute-Übersicht\"],\"gy6L1u\":[\"Must be a valid common name (FQDN).\"],\"gztCjq\":[\"Die angegebene Projekt-ID ist ungültig. Bitte überprüfen Sie Ihre Eingabe.\"],\"h3P8z+\":[\"Das Löschen des Flavor ist fehlgeschlagen. Bitte versuchen Sie es erneut\"],\"h47p9L\":[\"—\"],\"h8h6oz\":[\"Images deaktiviert\"],\"h99+4y\":[\"Floating IP zuweisen\"],\"hH3kDo\":[\"Image-Details werden geladen...\"],\"hHL/wm\":[\"Image-Instanz \\\"\",[\"imageName\"],\"\\\" wurde aktualisiert\"],\"hLp49h\":[\"<0>\",[\"deleteWord\"],\" zur Bestätigung eingeben:\"],\"hPz54a\":[\"Fehler beim Herunterladen\"],\"hQdfmR\":[\"Bucket \\\"\",[\"bucketName\"],\"\\\" was successfully deleted.\"],\"hQr1Cr\":[\"Image deaktivieren\"],\"hXUWyd\":[\"Wert ist erforderlich\"],\"hYgDIe\":[\"Erstellen\"],\"he3ygx\":[\"Kopieren\"],\"he4q+i\":[\"z. B. b90f9c4bc76140e18540b2cec1299e2a\"],\"hgpMHD\":[\"Gesamtspeicher\"],\"hkjZ7P\":[\"Fehler beim Aktualisieren der Sichtbarkeit für \\\"\",[\"imageName\"],\"\\\":\"],\"hrBow7\":[\"Netzwerk-ID\"],\"hz9da7\":[\"Failed to load Certificates issued by Certificate Authority.\"],\"i0qMbr\":[\"Startseite\"],\"i30J2U\":[\"Keine Projekte gefunden\"],\"i41Xuw\":[\"Feste IP-Adresse auswählen\"],\"i5MEDc\":[\"Fehler beim Verschieben des Objekts: \",[\"copyErrorMessage\"]],\"i6/ygf\":[\"Eine Eigenschaft mit diesem Schlüssel existiert bereits\"],\"i9TIyi\":[\"Entfernte Sicherheitsgruppe\"],\"i9qiyR\":[\"Läuft ab in\"],\"iH8pgl\":[\"Zurück\"],\"igVDFt\":[\"Anhängen\"],\"ih2lfP\":[\"No buckets found\"],\"iqUvrS\":[\"Benutzer C/D/I\"],\"is0Bhk\":[\"Bucket name must be 63 characters or fewer\"],\"izMhIO\":[\"Benutzer \",[\"userId\"],\" (beliebiges Projekt)\"],\"j9hkgJ\":[\"Regeln\"],\"jBIkmi\":[\"QCOW2, Raw, VMDK, VHD, VHDX, VDI, AMI, ARI, AKI, ISO, PLOOP\"],\"jIPNJG\":[\"Grundlegende Informationen\"],\"jK6wqe\":[\"Ordner \\\"\",[\"folderName\"],\"\\\" und \",[\"deletedCount\"],\" Objekt wurden dauerhaft gelöscht.\"],\"jKopCP\":[\"Netzwerk & Routing\"],\"jMc/mo\":[\"Beim Erstellen von Metadata ist ein Serverfehler aufgetreten. Bitte versuchen Sie es später erneut.\"],\"jNm/qL\":[\"Dieser Container ist bereits leer.\"],\"jNzyQo\":[\"Objekt-Versionierung\"],\"jPxavx\":[\"Security Group konnte nicht aktualisiert werden\"],\"jS4B2+\":[\"Container-Name stimmt nicht überein\"],\"jSG7wx\":[\"Bitte geben Sie eine gültige Anzahl von Minuten größer als 0 ein\"],\"jVjr9h\":[\"Enter a valid common name in FQDN format (e.g., demo-ca.test.sci).\"],\"jhU93c\":[\"Das Image \\\"\",[\"imageName\"],\"\\\" konnte nicht erstellt werden: \"],\"js24f6\":[\"Ordner löschen:\"],\"jtnAf8\":[\"Geschützte Images (können nicht gelöscht werden)\"],\"jyqLKs\":[\"Dieses Mitglied hat bereits Zugriff auf dieses Image.\"],\"k0vAWv\":[\"Objekt-Anzahl-Quota\"],\"k5nYwm\":[\"vCPU\"],\"k7ENJG\":[[\"rowDisplayName\"],\" vorschauen\"],\"k99j0U\":[\"Upload abbrechen\"],\"kA2lMP\":[\"Externes Netzwerk\"],\"kCLnJG\":[\"Alle leeren\"],\"kGmM/p\":[\"Sie haben keine Berechtigung, Metadata für diesen Flavor zu erstellen.\"],\"kIuDMT\":[\"Konfigurieren Sie die Eingangs- und Ausgangsregeln, die steuern, welcher Datenverkehr für diese Sicherheitsgruppe erlaubt ist.\"],\"kKK8AH\":[\"Verfügbare Quota:\"],\"kNeZrV\":[\"No Certificates issued by this Certificate Authority found\"],\"kQYfgO\":[\"Ein oder mehrere Container konnten nicht geleert werden: \",[\"errorMessage\"]],\"kiRrtv\":[\"<0>Bitte beachten Sie: Für <1>dynamische und <2>statische große Objekte werden nur die Manifeste gelöscht. Die zugehörigen Segmente werden nicht gelöscht.\"],\"kqJVBO\":[\"Sie haben keine Berechtigung, diese Sicherheitsgruppe zu teilen.\"],\"kuYWaD\":[\"Reservierte Schlüssel: web-index, web-listings, quota-count, quota-bytes\"],\"l75CjT\":[\"Ja\"],\"lAsm87\":[\"Dieses Image vor dem Löschen schützen\"],\"lBVhQs\":[\"Etwas ist schiefgelaufen.\"],\"lN/Z9n\":[\"Security Group-Name ist erforderlich\"],\"lN3xvy\":[\"Regel löschen\"],\"lQ3EIe\":[\"Maximale Löschvorgänge pro Anfrage\"],\"lWTy+Y\":[\"Image kann nicht erstellt werden\"],\"lWxDDh\":[\"Flavor Name\"],\"lZvIXd\":[\"Die Beschreibung darf höchstens 255 Zeichen lang sein.\"],\"lhIa6x\":[\"Fehler beim Laden der Extra-Specs. Bitte versuchen Sie es erneut.\"],\"lq/mBZ\":[\"Objekt-Informationen werden geladen...\"],\"lw1412\":[\"Sie wurden aufgrund von Inaktivität abgemeldet.\"],\"lxentK\":[\"Ein unerwarteter Fehler ist aufgetreten\"],\"m16xKo\":[\"Hinzufügen\"],\"m6X3ro\":[\"Gruppenname\"],\"mQSO1Y\":[\"Port Forwarding\"],\"mSLePW\":[\"Sie haben keine Berechtigung, auf Flavors für dieses Projekt zuzugreifen.\"],\"mSfwLL\":[\"Projekt-ID\"],\"mYnJeY\":[\"Der Text muss “release” in Kleinbuchstaben entsprechen.\"],\"miy5mb\":[\"PCA (Clavis)\"],\"mqljvE\":[\"Metadaten kopieren\"],\"mvz5Eo\":[\"URL für öffentlichen Zugriff\"],\"mxPfpY\":[\"Neuen Ordner hier erstellen\"],\"mzI/c+\":[\"Herunterladen\"],\"n0ZttO\":[\"Root Disk (GiB)\"],\"n1ekoW\":[\"Einloggen\"],\"n1gB0L\":[\"Floating IP \",[\"floating_ip_address\"],\" bearbeiten\"],\"n22YIM\":[\"Beschreibung bearbeiten\"],\"n2IuBI\":[\"Eine temporäre URL gewährt zeitlich begrenzten Lesezugriff auf dieses Objekt ohne Authentifizierung. Jeder mit dem Link kann es herunterladen, bis es abläuft.\"],\"n3eQzA\":[\"Diese Eigenschaft ist reserviert und kann nicht geändert werden\"],\"n46oLW\":[\"Fehler beim Entfernen des Mitglieds\"],\"n9jJG6\":[\"Mitgliedszugriff entfernen\"],\"nETBrc\":[\"Egress\"],\"nLvo6K\":[\"Details der RBAC-Richtlinie:\"],\"nNKXt7\":[\"Deleting this Certificate Authority is permanent, and all the associated certificates will no longer apply to entities.\"],\"nUuaq8\":[\"Fehler beim Aktualisieren des Containers: \",[\"errorMessage\"]],\"nW/hX9\":[\"Allgemeine Image-Daten\"],\"nWNviN\":[\"Deleting certificate authority...\"],\"nZbdB+\":[\"Upload abgebrochen\"],\"ne/GWZ\":[\"Innerhalb eines Projekts werden Objekte in Containern gespeichert. Container sind der Ort, an dem Sie Zugriffsberechtigungen und Quotas definieren.\"],\"neiJm0\":[\"Flavors\"],\"ng+PCh\":[\"There are no PCAs available for this project.\"],\"nkpZyk\":[\"Container \\\"\",[\"containerName\"],\"\\\" war bereits leer.\"],\"nnxwBn\":[\"Es gibt keine Regeln für diese Sicherheitsgruppe\"],\"ntNlXu\":[\"Zugriff auflisten\"],\"nzFJqC\":[\"Delete CA\"],\"o/VDOG\":[\"Image kann nicht gelöscht werden\"],\"o6M6l0\":[\"Security Group konnte nicht erstellt werden\"],\"oDkgME\":[\"Sie sind nicht berechtigt, Flavors zu erstellen. Bitte melden Sie sich erneut an.\"],\"oEGiW3\":[\"Wird hochgeladen... \",[\"progressPct\"],\"%\"],\"ocUvR+\":[\"False\"],\"odVI9Y\":[\"Container gelöscht\"],\"og1m+J\":[\"Loading Certificate Authority Details...\"],\"okXQSt\":[\"Subject information\"],\"olfSYj\":[\"Zugriffskontrolle aktualisiert\"],\"onHi/J\":[\"Es wird aus Ihrer Image-Liste entfernt.\"],\"p4nMut\":[\"Swap (MiB)\"],\"p6CSHM\":[\"Objekte löschen\"],\"p7DzCB\":[\"Fehler beim Aktualisieren der Zugriffskontrolle\"],\"pD/2BQ\":[\"Successfully emptied \",[\"emptiedCount\"],\" of \",[\"totalBuckets\"],\" \",[\"totalBuckets\",\"plural\",{\"one\":[\"bucket\"],\"other\":[\"buckets\"]}],\", deleting \",[\"totalDeleted\"],\" \",[\"totalDeleted\",\"plural\",{\"one\":[\"object\"],\"other\":[\"objects\"]}],\". \",[\"errorsLength\"],\" \",[\"errorsLength\",\"plural\",{\"one\":[\"bucket\"],\"other\":[\"buckets\"]}],\" failed.\"],\"pFg+7w\":[\"Aktualisiert:\"],\"pOPvlj\":[\"Bereits aktiv (wird übersprungen)\"],\"pU25+T\":[\"Upload von \\\"\",[\"objectName\"],\"\\\" wurde abgebrochen.\"],\"pbzA+s\":[\"Optionale Beschreibung\"],\"pebLmQ\":[\"Zugriff für \",[\"memberIdDisplay\"],\" entfernen\"],\"plnnns\":[[\"successCount\"],\" Image(s) gelöscht, aber \",[\"failedCount\"],\" Image(s) konnten nicht gelöscht werden.\"],\"poCbZw\":[\"ACLs werden geladen...\"],\"podzPY\":[\"Projekt-ID\"],\"psPHye\":[\"Geteiltes Image akzeptieren\"],\"pubQie\":[\"Value eingeben\"],\"q0Rla3\":[\"Projektzugriff hinzufügen\"],\"q44uUq\":[\"Container teilweise geleert\"],\"q5sTNZ\":[\"<0>Kein Temp-URL-Schlüssel konfiguriert. Ein temporärer URL-Schlüssel muss auf Account- oder Container-Ebene gesetzt werden, bevor temporäre URLs generiert werden können. Wenden Sie sich an Ihren Administrator, um <1>X-Account-Meta-Temp-URL-Key oder <2>X-Container-Meta-Temp-URL-Key zu konfigurieren.\"],\"q6K46F\":[\"Schlüssel existiert bereits\"],\"q88/6A\":[\"Fehler beim Erstellen des Ordners\"],\"qAkkjP\":[\"Maximale Objekt-Namenslänge\"],\"qEDO1j\":[\"Dies ist ein <0>dynamisches großes Objekt. Nur das Manifest wird gelöscht — die zugehörigen Segment-Objekte (gespeichert unter dem Manifest-Präfix) werden <1>nicht automatisch entfernt und müssen separat gelöscht werden.\"],\"qFDA8L\":[\"Zugriff auf Image ablehnen\"],\"qJb6G2\":[\"Erneut versuchen\"],\"qQ1QBh\":[\"Hardware-Spezifikationen\"],\"qST5TS\":[\"Fehler – Image-Details\"],\"qUlxA+\":[\"Ordner \\\"\",[\"folderName\"],\"\\\" wurde dauerhaft gelöscht.\"],\"qaAo9Y\":[\"Es ist ein Serverfehler beim Erstellen des Flavors aufgetreten. Bitte versuchen Sie es später erneut.\"],\"qh5W8q\":[\"Richtlinie entfernen\"],\"qhDo93\":[\"Common name is required.\"],\"qs+BrU\":[\"Sie haben keine Berechtigung, Projektzugriffe von diesem Flavor zu entfernen.\"],\"qtoOYG\":[\"Kein Limit\"],\"quU9wK\":[[\"failedCount\"],\" von \",[\"totalCount\"],\" Image(s) konnten nicht deaktiviert werden. Einige Images sind möglicherweise bereits deaktiviert oder in einem ungültigen Zustand.\"],\"qvF2D8\":[\"Keine Images verfügbar. Filtern Sie neu oder erstellen Sie ein neues Image.\"],\"qxxo7y\":[\"Keine Richtlinien entsprechen Ihrer Suche\"],\"qyNaF7\":[\"Geben Sie einen Zeitstempel wie \\\"YYYY-MM-DD HH:mm:ss\\\" ein, um die automatische Löschung zu planen\"],\"qzIZOL\":[\"Ungültiges Dateiformat. Unterstützte Formate: \",[\"supportedFileFormats\"]],\"qzhUb9\":[\"Erste \",[\"maxOptions\"],\" von \",[\"totalCount\"],\" werden angezeigt — verfeinern Sie Ihre Suche, um die Ergebnisse einzugrenzen\"],\"r5SQFW\":[\"Container-Name muss \",[\"maxContainerNameLength\"],\" Zeichen oder weniger haben\"],\"r9Aac8\":[\"Ephemeral Disk\"],\"rAtQcX\":[\"Sie können das Objekt umbenennen, indem Sie den Namen hier ändern.\"],\"rD9yV1\":[\"Zu deaktivierende Images:\"],\"rIe0oV\":[\"Fehler beim Hinzufügen des Zugriffs für das Project zum Flavor. Bitte versuchen Sie es erneut.\"],\"rIi6x4\":[\"Der Flavor konnte nicht gefunden werden. Möglicherweise wurde er bereits gelöscht.\"],\"rJe6vw\":[\"7 Tage\"],\"rPuPb+\":[\"Checking bucket contents...\"],\"rayTRr\":[[\"allContainersCount\"],\" bucket\"],\"rbuO5A\":[\"Diese Sicherheitsgruppe ist bereits mit dem angegebenen Projekt geteilt.\"],\"rcBt6T\":[\"Failed to create credential: \",[\"errorMessage\"]],\"rdUucN\":[\"Vorschau\"],\"rhaNn7\":[\"Container werden geladen...\"],\"riR9oD\":[\"Hinweis: Für <0>statische und dynamische große Objekte werden nur die Manifeste gelöscht — ihre Segmente außerhalb dieses Ordnerpräfixes sind nicht betroffen.\"],\"rlgAtt\":[\"Das Objekt wird zu diesem Pfad verschoben. Navigieren Sie oben durch die Ordner, um das Ziel zu ändern.\"],\"rp0Bd0\":[\"Compute\"],\"rrjuul\":[\"Weitere Details finden Sie in der <0>Dokumentation.\"],\"rvT6l1\":[\"Services Overview\"],\"rvXsSb\":[\"Der Projektzugriff für \\\"\",[\"tenantIdToRemove\"],\"\\\" wurde erfolgreich entfernt.\"],\"rwBVXS\":[\"Zu löschende Images (\",[\"deletableCount\"],\")\"],\"ryf/ee\":[\"Images aktiviert\"],\"ryxYVo\":[\"Zu deaktivierende Images (\",[\"activeCount\"],\")\"],\"s/s1lz\":[\"Jeder Benutzer kann eine HEAD- oder GET-Operation auf dem Container ausführen, sofern der Benutzer auch Lesezugriff auf Objekte hat. Es ist kein Token erforderlich.\"],\"s2ubkU\":[\"Flavor ID\"],\"s4Vnq2\":[\"Wird geleert...\"],\"sNVNmf\":[\"MAC-Adresse\"],\"sPFHpI\":[\"Disk\"],\"sSNyf3\":[\"Willkommen beim <0>Aurora-Dashboard, Ihrer Cloud-Management-Lösung der nächsten Generation. Wir sind bestrebt, die Art und Weise, wie Sie mit Ihrer Cloud-Infrastruktur interagieren und diese verwalten, zu vereinfachen. Mit Effizienz, Skalierbarkeit und Benutzerfreundlichkeit im Kern konzipiert, ermöglicht Aurora Ihnen, Prozesse zu optimieren und das volle Potenzial Ihrer Cloud-Ressourcen auszuschöpfen.\"],\"sWBLli\":[\"Eigenschaft hinzufügen\"],\"sXd+qS\":[\"Eigenschaften von \\\"\",[\"objectName\"],\"\\\" wurden erfolgreich aktualisiert.\"],\"sa4CV6\":[\"Alle Benutzer aus Projekt \",[\"projectId\"]],\"shKIZu\":[\"Zu aktivierende Images (\",[\"deactivatedCount\"],\")\"],\"sheDTJ\":[\"Bitte beachten Sie: Für <0>dynamische und <1>statische große Objekte werden nur die Manifeste gelöscht. Die zugehörigen Segmente werden nicht gelöscht.\"],\"sihD20\":[\"Images werden geladen...\"],\"sjMCOP\":[\"Zuletzt geändert\"],\"slWh5C\":[\"Floating IP \",[\"floating_ip_address\"],\" einem Port zuordnen\"],\"sxbP3b\":[\"Objekt-Anzahl\"],\"t/YqKh\":[\"Entfernen\"],\"t0X9+8\":[\"Container-Name\"],\"t1POAD\":[\"Keine benutzerdefinierten Metadaten-Eigenschaften gefunden. Klicken Sie auf \\\"Eigenschaft hinzufügen\\\", um eine zu erstellen.\"],\"t1fq6V\":[\"Der Server hat ein unerwartetes Datenformat zurückgegeben.\"],\"t7ewhH\":[\"Could not empty bucket \\\"\",[\"bucketName\"],\"\\\": \",[\"errorMessage\"]],\"t7ff15\":[\"gültiges Token erforderlich: false\"],\"t95VRV\":[\"Über das Aurora-Dashboard\"],\"tASa/P\":[\"Beim Löschen des Flavors ist ein Serverfehler aufgetreten. Bitte versuchen Sie es später erneut.\"],\"tIrNgH\":[\"Beim Abrufen der Metadata ist ein Serverfehler aufgetreten. Bitte versuchen Sie es später erneut.\"],\"tL9it8\":[\"Nothing to do. Bucket is already empty.\"],\"tLerHy\":[\"Ephemeral Disk muss eine ganze Zahl ≥ 0 sein.\"],\"tM5SEI\":[\"ACLs für Container \\\"\",[\"containerName\"],\"\\\" wurden erfolgreich aktualisiert.\"],\"tOkmLM\":[\"Fehler beim Kopieren des Objekts\"],\"tV/Ozb\":[\"Port-Bereich\"],\"tVSmFT\":[\"Weitere werden geladen...\"],\"tX5yOZ\":[\"Neuer Ordner\"],\"tasfos\":[\"entfernen\"],\"tbwGSx\":[\"Minimale Festplatte (GB)\"],\"tejJLY\":[\"Floating IP wird zugeordnet...\"],\"tfAKBU\":[\"\\\"\",[\"objectName\"],\"\\\" konnte nicht hochgeladen werden: \",[\"errorMessage\"]],\"tfDRzk\":[\"Speichern\"],\"tfxu04\":[\"Zugriff entfernen für \",[\"tenantId\"]],\"thHAVL\":[\"Akzeptierte Images\"],\"tiflqy\":[\"Image kann nicht reaktiviert werden\"],\"tlfxPP\":[\"\\\"\",[\"objectName\"],\"\\\" konnte nicht kopiert werden: \",[\"errorMessage\"]],\"tmpGvt\":[\"production, linux\"],\"u+VWhB\":[\"In die Zwischenablage kopiert!\"],\"u2xIeO\":[\"Fehler beim Aktualisieren der ACLs: \",[\"errorMessage\"]],\"u5HztT\":[\"RX/TX Factor\"],\"u77/s4\":[\"Floating IPs\"],\"u7En0V\":[\"Metadata hinzufügen\"],\"uAI0yI\":[\"Objekt löschen:\"],\"uAQUqI\":[\"Status\"],\"uAZE7K\":[\"Delete bucket\"],\"uLtFAr\":[\"Container \\\"\",[\"containerName\"],\"\\\" konnte nicht aktualisiert werden: \",[\"errorMessage\"]],\"uSdnuQ\":[\"VCPUs müssen eine ganze Zahl ≥ 1 sein.\"],\"ujK/QN\":[\"Objekte werden geladen...\"],\"uly9ET\":[\"Regeldetails:\"],\"up0ZSW\":[\"Fingerprint\"],\"uuKb0T\":[\"Die Beschreibung muss weniger als 65535 Zeichen haben.\"],\"v0hPHE\":[\"Details anzeigen\"],\"v3djpU\":[\"Verschieben/Umbenennen\"],\"v9Dn8m\":[\"Das Aurora-Dashboard ist mehr als nur ein Werkzeug – es ist Ihr Partner bei der Navigation in der Cloud. Egal, ob Sie ein kleines Startup oder ein globales Unternehmen sind, Aurora bietet Ihnen die Flexibilität, Leistung und Einfachheit, die Sie benötigen, um Ihre Ziele zu erreichen.\"],\"vBUQNE\":[\"Das Metadata konnte nicht gefunden werden. Möglicherweise wurde es bereits gelöscht.\"],\"vEkTR9\":[\"Quota\"],\"vH2C/2\":[\"Swap\"],\"vR4HmN\":[\"Lädt Instanzen...\"],\"vTh35P\":[\"Container erstellen\"],\"vXmL4D\":[\"Legen Sie Ihre Image-Datei hier ab\"],\"vZUKSz\":[\"Floating IP \",[\"floating_ip_address\"],\" trennen\"],\"vbajgL\":[\"Öffentlicher Flavor\"],\"vcQSZh\":[\"Dieser Ordner ist leer — verwenden Sie \\\"Neuer Ordner\\\", um einen zu erstellen.\"],\"vcXmqy\":[\"Netzwerk-Übersicht\"],\"vcvCXq\":[\"Fehler – Flavor-Details\"],\"vg84cD\":[[\"allCount\"],\" Elemente\"],\"vmRPFm\":[\"Sicherheitsgruppe teilen\"],\"vmYyLY\":[\"Entferntes IP-Präfix\"],\"vp5vfW\":[\"1 Stunde\"],\"vpt8cE\":[\"URL generieren\"],\"vrPCbw\":[\"Image-ID\"],\"w3bAcf\":[\"Diese Aktion ist dauerhaft. Die Adresse wird aus Ihrem Projekt entfernt und dem öffentlichen Pool zurückgegeben. Dies kann nicht rückgängig gemacht werden.\"],\"w9+8d7\":[\"Projektzugriff entfernen\"],\"wEfZld\":[\"Neuen Flavor erstellen\"],\"wFaT8w\":[\"Fehler beim Leeren der Container\"],\"wMHvYH\":[\"Value\"],\"wPrtGF\":[\"Key eingeben\"],\"wTg+FY\":[\"Maximale Dateigröße\"],\"wXxPjv\":[\"S3 Object Storage — Setup Required\"],\"wa1Bcq\":[\"Project ID eingeben\"],\"wbqM4L\":[[\"customMinutes\"],\" Minuten\"],\"wcUecy\":[\"Sie haben keine Berechtigung, Metadata für diesen Flavor anzusehen.\"],\"wdUvGT\":[\"Creating Certificate Authority...\"],\"we28Pq\":[\"ACLs-Vorschau ausblenden\"],\"wlQNTg\":[\"Members\"],\"wlUDbB\":[\"Zuletzt aktualisiert: \",[\"formattedDate\"]],\"wrXcuy\":[\"Objektname\"],\"wrk/xj\":[\"Image-Details\"],\"wxVsr5\":[\"Bucket name does not match\"],\"wyIOMP\":[\"Image-Name ist erforderlich\"],\"wzqqS+\":[\"Hauptmerkmale\"],\"x/XQrD\":[\"Beliebiger Dateityp\"],\"x1bK0h\":[\"Mit den aktuellen Suchkriterien sind keine Container verfügbar. Versuchen Sie, Ihren Suchbegriff anzupassen.\"],\"x3T4pq\":[\"Die Container-Metadaten melden Objekte, aber keine wurden aufgelistet. Dies kann eine vorübergehende Synchronisierungsverzögerung sein — bitte warten Sie einen Moment und versuchen Sie es erneut.\"],\"x5l/TK\":[\"Bereits aktiv (wird übersprungen):\"],\"x9AdZ8\":[\"property_key\"],\"xNG/3n\":[\"Floating IP-Adresse\"],\"xNZKYy\":[[\"failedCount\"],\" von \",[\"totalCount\"],\" Image(s) konnten nicht gelöscht werden. Einige Images sind möglicherweise geschützt oder werden verwendet.\"],\"xqhyRT\":[\"Objekt hochgeladen\"],\"xw2UtT\":[\"Neues Image erstellen\"],\"y+KBOY\":[\"z. B. production, linux, ubuntu\"],\"y02Bu1\":[\"Container:\"],\"y0u86k\":[\"Der angeforderte Flavor konnte nicht gefunden werden. Er wurde möglicherweise gelöscht oder Sie haben keinen Zugriff darauf.\"],\"y1GYnY\":[\"\\\"\",[\"objectName\"],\"\\\" konnte nicht verschoben werden: \",[\"errorMessage\"]],\"yDHGP+\":[\"my-bucket-name\"],\"yPWFWy\":[\"ICMP-Typ\"],\"yTtJTy\":[\"Image-Metadaten bearbeiten\"],\"yYxB17\":[\"Alle Filter löschen\"],\"ylfbpz\":[\"Der Key für die Extra-Spec ist erforderlich und darf nicht leer sein.\"],\"yp0UjB\":[\"Ethertype\"],\"yqPflB\":[\"... und \",[\"hiddenCount\"],\" weitere\"],\"yu9G3x\":[\"Security Group bearbeiten\"],\"ywe1H/\":[[\"totalCount\",\"plural\",{\"one\":[[\"totalCount\"],\" Container\"],\"other\":[[\"totalCount\"],\" Container\"]}]],\"yz7wBu\":[\"Schließen\"],\"z+zpLP\":[\"gültiges Token erforderlich: true\"],\"z1JceR\":[\"Zurück zu Floating IPs\"],\"z45o5B\":[\"Objekt-Anzahl\"],\"z9NAjZ\":[\"Objekt gelöscht\"],\"zCD96i\":[\"Sie sind nicht berechtigt, Flavor-Details anzuzeigen. Bitte melden Sie sich erneut an.\"],\"zDS0JC\":[\"Der Name muss 2-50 Zeichen lang sein.\"],\"zWb/Nn\":[\"Maximale Header-Größe\"],\"zc5dcw\":[\"Anmeldung fehlgeschlagen. Bitte überprüfen Sie Ihre Anmeldeinformationen und versuchen Sie es erneut.\"],\"zga9sT\":[\"OK\"],\"zhM8FP\":[\"Zugriff für einen Benutzer aus einem anderen Projekt gewähren.\"],\"zm7+/D\":[\"Sie sind dabei, <0>\",[\"activeCount\"],\" Image(s) zu deaktivieren. Deaktivierte Images können nicht zum Starten neuer Instances verwendet werden.\"],\"zwBp5t\":[\"Privat\"]}")as Messages; \ No newline at end of file diff --git a/apps/aurora-portal/src/locales/en/messages.po b/apps/aurora-portal/src/locales/en/messages.po index 53cb3b509..45d30d770 100644 --- a/apps/aurora-portal/src/locales/en/messages.po +++ b/apps/aurora-portal/src/locales/en/messages.po @@ -31,9 +31,18 @@ msgstr "\"{objectName}\" was successfully moved to {destination}." msgid "\"{objectName}\" was successfully uploaded." msgstr "\"{objectName}\" was successfully uploaded." +msgid "{allContainersCount} bucket" +msgstr "{allContainersCount} bucket" + +msgid "{allContainersCount} buckets" +msgstr "{allContainersCount} buckets" + msgid "{allCount} items" msgstr "{allCount} items" +msgid "{bucketCount, plural, one {object} other {objects}}" +msgstr "{bucketCount, plural, one {object} other {objects}}" + msgid "{customMinutes} minutes" msgstr "{customMinutes} minutes" @@ -61,6 +70,9 @@ msgstr "{progressPct}%" msgid "{totalCount, plural, one {{totalCount} container} other {{totalCount} containers}}" msgstr "{totalCount, plural, one {{totalCount} container} other {{totalCount} containers}}" +msgid "<0>Are you sure? All {bucketCount} {bucketCount, plural, one {object} other {objects}} in bucket \"{bucketName}\" will be permanently deleted. This action cannot be undone." +msgstr "<0>Are you sure? All {bucketCount} {bucketCount, plural, one {object} other {objects}} in bucket \"{bucketName}\" will be permanently deleted. This action cannot be undone." + msgid "<0>Are you sure? All objects in the selected containers will be permanently deleted. This cannot be undone." msgstr "<0>Are you sure? All objects in the selected containers will be permanently deleted. This cannot be undone." @@ -223,6 +235,9 @@ msgstr "Add Security Group Rule" msgid "Add Tenant Access" msgstr "Add Tenant Access" +msgid "All Buckets Emptied" +msgstr "All Buckets Emptied" + msgid "All containers" msgstr "All containers" @@ -325,6 +340,75 @@ msgstr "Boot RAM" msgid "Boot size" msgstr "Boot size" +msgid "Bucket \"{bucketName}\" was already empty." +msgstr "Bucket \"{bucketName}\" was already empty." + +msgid "Bucket \"{bucketName}\" was successfully created." +msgstr "Bucket \"{bucketName}\" was successfully created." + +msgid "Bucket \"{bucketName}\" was successfully deleted." +msgstr "Bucket \"{bucketName}\" was successfully deleted." + +msgid "Bucket \"{bucketName}\" was successfully emptied. {deletedCount} object deleted." +msgstr "Bucket \"{bucketName}\" was successfully emptied. {deletedCount} object deleted." + +msgid "Bucket \"{bucketName}\" was successfully emptied. {deletedCount} objects deleted." +msgstr "Bucket \"{bucketName}\" was successfully emptied. {deletedCount} objects deleted." + +msgid "Bucket Created" +msgstr "Bucket Created" + +msgid "Bucket Deleted" +msgstr "Bucket Deleted" + +msgid "Bucket Emptied" +msgstr "Bucket Emptied" + +msgid "Bucket name" +msgstr "Bucket name" + +msgid "Bucket Name" +msgstr "Bucket Name" + +msgid "Bucket name does not match" +msgstr "Bucket name does not match" + +msgid "Bucket name is required" +msgstr "Bucket name is required" + +msgid "Bucket name must be 63 characters or fewer" +msgstr "Bucket name must be 63 characters or fewer" + +msgid "Bucket name must be at least 3 characters" +msgstr "Bucket name must be at least 3 characters" + +msgid "Bucket name must contain only lowercase letters, numbers, periods, and hyphens" +msgstr "Bucket name must contain only lowercase letters, numbers, periods, and hyphens" + +msgid "Bucket name must not be formatted as an IP address" +msgstr "Bucket name must not be formatted as an IP address" + +msgid "Bucket name must not contain consecutive periods" +msgstr "Bucket name must not contain consecutive periods" + +msgid "Bucket name must not end with reserved suffix \"{suffix}\"" +msgstr "Bucket name must not end with reserved suffix \"{suffix}\"" + +msgid "Bucket name must not start with reserved prefix \"{prefix}\"" +msgstr "Bucket name must not start with reserved prefix \"{prefix}\"" + +msgid "Bucket name must start and end with a letter or number" +msgstr "Bucket name must start and end with a letter or number" + +msgid "Bucket to delete:" +msgstr "Bucket to delete:" + +msgid "Bucket to empty:" +msgstr "Bucket to empty:" + +msgid "Buckets to empty:" +msgstr "Buckets to empty:" + msgid "Bulk upload of archive files" msgstr "Bulk upload of archive files" @@ -349,8 +433,8 @@ msgstr "Certificate Authority not found" msgid "Certificate not found" msgstr "Certificate not found" -msgid "Checking S3 credentials..." -msgstr "Checking S3 credentials..." +msgid "Checking bucket contents..." +msgstr "Checking bucket contents..." msgid "Checksum" msgstr "Checksum" @@ -451,9 +535,6 @@ msgstr "Container Updated" msgid "Container:" msgstr "Container:" -msgid "Container: {containerName}" -msgstr "Container: {containerName}" - msgid "Containers" msgstr "Containers" @@ -469,6 +550,9 @@ msgstr "Containers to be emptied ({totalCount})" msgid "Content type" msgstr "Content type" +msgid "Copied" +msgstr "Copied" + msgid "Copied to clipboard!" msgstr "Copied to clipboard!" @@ -502,6 +586,9 @@ msgstr "Copying..." msgid "Could not copy \"{objectName}\": {errorMessage}" msgstr "Could not copy \"{objectName}\": {errorMessage}" +msgid "Could not create bucket \"{bucketName}\": {errorMessage}" +msgstr "Could not create bucket \"{bucketName}\": {errorMessage}" + msgid "Could not create container \"{containerName}\": {errorMessage}" msgstr "Could not create container \"{containerName}\": {errorMessage}" @@ -511,6 +598,9 @@ msgstr "Could not create folder \"{folderName}\": {errorMessage}" msgid "Could not delete \"{objectName}\": {errorMessage}" msgstr "Could not delete \"{objectName}\": {errorMessage}" +msgid "Could not delete bucket \"{bucketName}\": {errorMessage}" +msgstr "Could not delete bucket \"{bucketName}\": {errorMessage}" + msgid "Could not delete container \"{containerName}\": {errorMessage}" msgstr "Could not delete container \"{containerName}\": {errorMessage}" @@ -520,6 +610,9 @@ msgstr "Could not delete folder \"{folderName}\": {errorMessage}" msgid "Could not download \"{objectName}\": {errorMessage}" msgstr "Could not download \"{objectName}\": {errorMessage}" +msgid "Could not empty bucket \"{bucketName}\": {errorMessage}" +msgstr "Could not empty bucket \"{bucketName}\": {errorMessage}" + msgid "Could not empty container \"{containerName}\": {errorMessage}" msgstr "Could not empty container \"{containerName}\": {errorMessage}" @@ -544,6 +637,9 @@ msgstr "CPU" msgid "Create" msgstr "Create" +msgid "Create Bucket" +msgstr "Create Bucket" + msgid "Create Certificate" msgstr "Create Certificate" @@ -646,6 +742,12 @@ msgstr "Delete All" msgid "Delete All ({selectedCount})" msgstr "Delete All ({selectedCount})" +msgid "Delete bucket" +msgstr "Delete bucket" + +msgid "Delete Bucket" +msgstr "Delete Bucket" + msgid "Delete CA" msgstr "Delete CA" @@ -844,12 +946,24 @@ msgstr "Empty All" msgid "Empty All ({selectedCount})" msgstr "Empty All ({selectedCount})" +msgid "Empty All Completed with Errors" +msgstr "Empty All Completed with Errors" + +msgid "Empty Bucket" +msgstr "Empty Bucket" + +msgid "Empty Buckets" +msgstr "Empty Buckets" + msgid "Empty Containers" msgstr "Empty Containers" msgid "Empty:" msgstr "Empty:" +msgid "Emptying bucket {progressCurrent} of {progressTotal}, please wait..." +msgstr "Emptying bucket {progressCurrent} of {progressTotal}, please wait..." + msgid "Emptying container {progressCurrent} of {progressTotal}, please wait..." msgstr "Emptying container {progressCurrent} of {progressTotal}, please wait..." @@ -934,6 +1048,9 @@ msgstr "Error - Flavor Details" msgid "Error - Image Details" msgstr "Error - Image Details" +msgid "Error Loading Buckets: {errorMessage}" +msgstr "Error Loading Buckets: {errorMessage}" + msgid "Error loading Certificate" msgstr "Error loading Certificate" @@ -997,6 +1114,9 @@ msgstr "Failed to add member" msgid "Failed to add tenant access to flavor. Please try again." msgstr "Failed to add tenant access to flavor. Please try again." +msgid "Failed to check bucket contents: {errorMessage}" +msgstr "Failed to check bucket contents: {errorMessage}" + msgid "Failed to Copy Object" msgstr "Failed to Copy Object" @@ -1006,6 +1126,9 @@ msgstr "Failed to copy object: {errorMessage}" msgid "Failed to copy the temporary URL to the clipboard" msgstr "Failed to copy the temporary URL to the clipboard" +msgid "Failed to Create Bucket" +msgstr "Failed to Create Bucket" + msgid "Failed to Create Container" msgstr "Failed to Create Container" @@ -1039,6 +1162,9 @@ msgstr "Failed to Deactivate Images" msgid "Failed to delete {failedCount} of {totalCount} image(s). Some images may be protected or in use." msgstr "Failed to delete {failedCount} of {totalCount} image(s). Some images may be protected or in use." +msgid "Failed to Delete Bucket" +msgstr "Failed to Delete Bucket" + msgid "Failed to Delete Container" msgstr "Failed to Delete Container" @@ -1069,6 +1195,9 @@ msgstr "Failed to delete the flavor. Please try again." msgid "Failed to Download" msgstr "Failed to Download" +msgid "Failed to Empty Bucket" +msgstr "Failed to Empty Bucket" + msgid "Failed to Empty Container" msgstr "Failed to Empty Container" @@ -1288,6 +1417,9 @@ msgstr "Generating..." msgid "Get Involved" msgstr "Get Involved" +msgid "Got it!" +msgstr "Got it!" + msgid "Grant access to a user from a different project." msgstr "Grant access to a user from a different project." @@ -1498,6 +1630,9 @@ msgstr "Load More" msgid "Loading ACLs..." msgstr "Loading ACLs..." +msgid "Loading Buckets..." +msgstr "Loading Buckets..." + msgid "Loading Certificate Authority Details..." msgstr "Loading Certificate Authority Details..." @@ -1714,6 +1849,9 @@ msgstr "Must be a valid IPv4 or IPv6 address (for example: 172.24.4.228 or 2001: msgid "Must be a valid PQDN or FQDN (alphanumeric and hyphens only, cannot start or end with hyphen)." msgstr "Must be a valid PQDN or FQDN (alphanumeric and hyphens only, cannot start or end with hyphen)." +msgid "my-bucket-name" +msgstr "my-bucket-name" + msgid "N/A" msgstr "N/A" @@ -1747,6 +1885,9 @@ msgstr "new-folder-name" msgid "No" msgstr "No" +msgid "No buckets found" +msgstr "No buckets found" + msgid "No Certificates issued by this Certificate Authority found" msgstr "No Certificates issued by this Certificate Authority found" @@ -1834,6 +1975,9 @@ msgstr "Note: for <0>static and dynamic large objects only the manifests are msgid "Note: The 'stateful' attribute cannot be changed if this security group is currently in use by one or more ports." msgstr "Note: The 'stateful' attribute cannot be changed if this security group is currently in use by one or more ports." +msgid "Nothing to do. Bucket is already empty." +msgstr "Nothing to do. Bucket is already empty." + msgid "Object \"{objectName}\" was permanently deleted." msgstr "Object \"{objectName}\" was permanently deleted." @@ -2206,6 +2350,9 @@ msgstr "RX/TX Factor" msgid "RX/TX Factor must be an integer ≥ 1." msgstr "RX/TX Factor must be an integer ≥ 1." +msgid "S3 bucket names must be 3-63 characters long and contain only lowercase letters, numbers, periods, and hyphens. They must start and end with a letter or number, and be globally unique within the cluster." +msgstr "S3 bucket names must be 3-63 characters long and contain only lowercase letters, numbers, periods, and hyphens. They must start and end with a letter or number, and be globally unique within the cluster." + msgid "S3 Object Storage — Setup Required" msgstr "S3 Object Storage — Setup Required" @@ -2440,6 +2587,12 @@ msgstr "Successfully deactivated {successCount} of {totalCount} image(s)" msgid "Successfully deleted {successCount} of {totalCount} image(s)" msgstr "Successfully deleted {successCount} of {totalCount} image(s)" +msgid "Successfully emptied {emptiedCount} {emptiedCount, plural, one {bucket} other {buckets}}, deleting {totalDeleted} {totalDeleted, plural, one {object} other {objects}}." +msgstr "Successfully emptied {emptiedCount} {emptiedCount, plural, one {bucket} other {buckets}}, deleting {totalDeleted} {totalDeleted, plural, one {object} other {objects}}." + +msgid "Successfully emptied {emptiedCount} of {totalBuckets} {totalBuckets, plural, one {bucket} other {buckets}}, deleting {totalDeleted} {totalDeleted, plural, one {object} other {objects}}. {errorsLength} {errorsLength, plural, one {bucket} other {buckets}} failed." +msgstr "Successfully emptied {emptiedCount} of {totalBuckets} {totalBuckets, plural, one {bucket} other {buckets}}, deleting {totalDeleted} {totalDeleted, plural, one {object} other {objects}}. {errorsLength} {errorsLength, plural, one {bucket} other {buckets}} failed." + msgid "Suggested Images" msgstr "Suggested Images" @@ -2578,6 +2731,9 @@ msgstr "The text must match “detach” in lowercase." msgid "The text must match “release” in lowercase." msgstr "The text must match “release” in lowercase." +msgid "There are no buckets available with the current search criteria. Try adjusting your search term." +msgstr "There are no buckets available with the current search criteria. Try adjusting your search term." + msgid "There are no Certificates available for this Certificate Authority." msgstr "There are no Certificates available for this Certificate Authority." @@ -2620,12 +2776,18 @@ msgstr "This action cannot be undone. The security group will be permanently del msgid "This action cannot be undone. The target project will lose access to this security group immediately." msgstr "This action cannot be undone. The target project will lose access to this security group immediately." +msgid "This action is irreversible. Deleting a bucket permanently removes it and cannot be undone. The bucket must be empty before deletion." +msgstr "This action is irreversible. Deleting a bucket permanently removes it and cannot be undone. The bucket must be empty before deletion." + msgid "This action is permanent. All objects in the container will be deleted and this cannot be undone." msgstr "This action is permanent. All objects in the container will be deleted and this cannot be undone." msgid "This action is permanent. The address will be removed from your project and returned to the public pool. This action cannot be undone." msgstr "This action is permanent. The address will be removed from your project and returned to the public pool. This action cannot be undone." +msgid "This bucket contains {actualObjectCount} {actualObjectCount, plural, one {object} other {objects}} and cannot be deleted. Delete all objects first." +msgstr "This bucket contains {actualObjectCount} {actualObjectCount, plural, one {object} other {objects}} and cannot be deleted. Delete all objects first." + msgid "This container appears empty — the object count may not have synced yet due to a recent operation." msgstr "This container appears empty — the object count may not have synced yet due to a recent operation." @@ -2683,6 +2845,9 @@ msgstr "This tenant already has access to the flavor." msgid "This tenant does not have access to the flavor." msgstr "This tenant does not have access to the flavor." +msgid "This will permanently delete all objects from {totalCount} selected {totalCount, plural, one {bucket} other {buckets}}. This action cannot be undone." +msgstr "This will permanently delete all objects from {totalCount} selected {totalCount, plural, one {bucket} other {buckets}}. This action cannot be undone." + msgid "To confirm this action, type the word <0>“detach” in the field below." msgstr "To confirm this action, type the word <0>“detach” in the field below." @@ -2755,6 +2920,9 @@ msgstr "Type container name to confirm" msgid "Type name" msgstr "Type name" +msgid "Type the bucket name to confirm" +msgstr "Type the bucket name to confirm" + msgid "Type to search containers..." msgstr "Type to search containers..." diff --git a/apps/aurora-portal/src/locales/en/messages.ts b/apps/aurora-portal/src/locales/en/messages.ts index 69d789f20..1013282e5 100644 --- a/apps/aurora-portal/src/locales/en/messages.ts +++ b/apps/aurora-portal/src/locales/en/messages.ts @@ -1 +1 @@ -/*eslint-disable*/import type{Messages}from"@lingui/core";export const messages=JSON.parse("{\"+0B+ue\":[\"Projects\"],\"+9CXS9\":[\"Deactivate Images\"],\"+Jcye3\":[\"Key Name\"],\"+Lt5cp\":[\"You are not authorized to add tenant access. Please log in again.\"],\"+Nhol2\":[\"Certificate not found\"],\"+NwLgN\":[\"Activating this image will allow it to be used to launch new instances again.\"],\"+Nx1wc\":[\"Failed to load Floating IPs\"],\"+OEi73\":[\"Object Storage (Swift)\"],\"+YQ9qu\":[\"Container: \",[\"containerName\"]],\"+nQTmZ\":[\"This tenant does not have access to the flavor.\"],\"+p6nHr\":[\"Failed to load object metadata: \",[\"metadataErrorMessage\"]],\"+zy2Nq\":[\"Type\"],\"/1MfrG\":[\"Could not download \\\"\",[\"objectName\"],\"\\\": \",[\"errorMessage\"]],\"/2a/eI\":[\"Loading Flavor...\"],\"/9Squ9\":[\"You don't have permission to view this flavor's details.\"],\"/BZLRP\":[\"To confirm this action, type the word <0>“detach” in the field below.\"],\"/EcdUM\":[\"Your action is required\"],\"/HgF9q\":[\"Sort by\"],\"/InK0O\":[\"Total size\"],\"/LqWNN\":[\"Could not delete \\\"\",[\"objectName\"],\"\\\": \",[\"errorMessage\"]],\"/NeNjH\":[\"Container \\\"\",[\"containerName\"],\"\\\" properties were successfully updated.\"],\"/Nmxy/\":[\"No key pairs available.\"],\"/QIkBY\":[\"<0>Secure & Reliable: Your data and operations are safeguarded with enterprise-grade security and robust reliability.\"],\"/Qox3b\":[\"A folder with this name already exists\"],\"/Z2leb\":[\"No containers found.\"],\"/Z5n1b\":[\"Create folder below:\"],\"/bUiYk\":[\"Router ID\"],\"/eFtWI\":[\"RBAC Policies\"],\"/xnbdQ\":[\"The specified user has access. A token for the user (scoped to any project) must be included in the request.\"],\"01/uUD\":[\"Keep segments (delete manifest only)\"],\"07WXfc\":[\"Server returned unexpected data format for extra specs.\"],\"0BSSYj\":[\"Server error occurred while removing tenant access. Please try again later.\"],\"0Gd0NU\":[\"Shared\"],\"0P2gFy\":[\"The page you are looking for does not exist.\"],\"0WsqO0\":[\"Containers Emptied\"],\"0cVgUw\":[\"Filter by\"],\"0eY8Mz\":[\"There are no Floating IPs available for this project. Floating IPs allow you to map public IP addresses to instances.\"],\"0kCt7e\":[\"The flavor data provided is invalid. Please check your input.\"],\"0kc0zi\":[\"Server error occurred while deleting the extra spec. Please try again later.\"],\"0o0OhW\":[\"No objects found.\"],\"0p+s6m\":[\"Type: \",[\"typeValue\"],\", Code: \",[\"codeValue\"]],\"0u9jhd\":[\"Detaching this Floating IP will remove its association with the current port. The instance will no longer be reachable through this address.\"],\"16085O\":[\"IP Version\"],\"1H2g6v\":[\"Moving object...\"],\"1NS3nd\":[[\"emptiedCount\",\"plural\",{\"one\":[\"#\",\" container\"],\"other\":[\"#\",\" containers\"]}],\" successfully emptied. \",[\"totalDeleted\",\"plural\",{\"one\":[\"#\",\" object\"],\"other\":[\"#\",\" objects\"]}],\" deleted in total.\"],\"1RwosK\":[\"Target project ID is required\"],\"1UzENP\":[\"No\"],\"1VDqZj\":[\"<0>Future-Ready: Aurora is designed to evolve with the latest trends in cloud technology, ensuring your solution is always cutting-edge.\"],\"1iQtS2\":[\"Showing first \",[\"actualObjectCount\"],\" of \",[\"total\"],\" objects\"],\"1iUuTT\":[\"Your session has expired. Please log in again.\"],\"1ojTVo\":[\"Select a DNS domain.\"],\"1pGUZa\":[\"Session expires in\"],\"1pdLQw\":[\"Image not found\"],\"1rLu3+\":[\"Could not empty container \\\"\",[\"containerName\"],\"\\\": \",[\"errorMessage\"]],\"1rPB1p\":[\"The flavor or tenant could not be found. Please verify they exist.\"],\"1t/NnN\":[\"Reject\"],\"1zZ1IK\":[\"Hi\"],\"20E+79\":[\"You need to login to access this page.\"],\"20Kpaw\":[\"Flavor \\\"\",[\"flavorName\"],\"\\\" has been successfully deleted.\"],\"20axE5\":[\"Shared by Project\"],\"23wBCX\":[\"Public Read Access\"],\"2G6hLq\":[\"Delete \",[\"specKey\"]],\"2Inn83\":[\"Bulk upload of archive files\"],\"2TtIL2\":[\"Stored as X-Object-Meta-* headers. Keys are case-insensitive.\"],\"2cJIlz\":[\"Floating Network ID\"],\"2d/OiW\":[\"Enter your username\"],\"2dnZwV\":[\"Could not delete folder \\\"\",[\"folderName\"],\"\\\": \",[\"errorMessage\"]],\"2gH+i8\":[\"You are not authorized to delete flavors. Please log in again.\"],\"2lq0gq\":[\"<0>Properties of <1>\",[\"displayName\"],\"\"],\"2mbisJ\":[\"Metadata \\\"\",[\"trimmedKey\"],\"\\\" has been added successfully.\"],\"2pnrGl\":[\"Expected format: YYYY-MM-DD HH:MM:SS\"],\"2q/Q7x\":[\"Visibility\"],\"2ysnjX\":[\"<0>Enhanced Productivity: By reducing operational complexity, Aurora helps your team focus on what truly matters—innovating and driving business success.\"],\"2zceEg\":[\"This action cannot be undone. The image will be permanently deleted.\"],\"33F2A+\":[\"Type container name to confirm\"],\"3AUpb4\":[\"Delete All (\",[\"selectedCount\"],\")\"],\"3Qn0me\":[\"Add Member\"],\"3dBmvU\":[\"The container cannot be deleted as it contains objects. Empty the container first.\"],\"3n+vCm\":[\"Custom duration (minutes)\"],\"3nWqQW\":[\"You are not authorized to view extra specs. Please log in again.\"],\"3nh/7E\":[\"If checked, this flavor will be available to all tenants. If unchecked, access must be explicitly granted to specific tenants.\"],\"3oChIh\":[\"<0>Unified Cloud Management: Consolidates all your cloud assets into one intuitive interface.\"],\"3oc18/\":[\"Private flavors could not be loaded. You may be seeing an incomplete list.\"],\"3q1GLx\":[\"Pending file upload...\"],\"3x7Sws\":[\"Loading Security Group Details...\"],\"4+2wZO\":[\"Back to Certificate Authorities Details page\"],\"47eI0x\":[\"Description must be at least 1 character.\"],\"4EZrJN\":[\"Rules\"],\"4O2AH3\":[\"Member \\\"\",[\"memberIdToRemove\"],\"\\\" has been removed successfully.\"],\"4fh0Wj\":[\"Boot size\"],\"4fvDRe\":[\"Images to activate:\"],\"4fvcmm\":[\"Object will be uploaded as: <0>\",[\"selectedObjectName\"],\"\"],\"4h3Eyf\":[\"\\\"\",[\"objectName\"],\"\\\" was successfully uploaded.\"],\"4kjaAc\":[\"No server groups available.\"],\"4mbrAq\":[\"1 minute\"],\"4opp4r\":[\"Security Groups\"],\"4pOfUd\":[\"Our Mission\"],\"4t33sh\":[\"Failed to Update Object\"],\"4uXhtt\":[\"CIDR\"],\"4utWB4\":[\"Server Role:\"],\"5/wyf8\":[\"Enter a floating IP\"],\"56IxdF\":[\"Failed to load container objects: \",[\"errorMessage\"]],\"5BLR6Q\":[\"IPv4\"],\"5JDSvn\":[\"Max meta value length\"],\"5M4Te3\":[\"DNS\"],\"5MF8U2\":[\"Failed to Update Container\"],\"5Okch2\":[\"Empty:\"],\"5Yrl6N\":[\"Loading Server Groups...\"],\"5aNQ3F\":[\"\\\"\",[\"objectName\"],\"\\\" was successfully copied to \",[\"destination\"],\".\"],\"5g7owI\":[\"Updating Floating IP...\"],\"5y3O+A\":[\"Deactivate\"],\"6+7EwD\":[\"Serve objects as index when file name is:\"],\"6+OdGi\":[\"Protocol\"],\"6/xipy\":[\"Container Format\"],\"644xgx\":[\"Protected\"],\"6BDqha\":[\"Limits\"],\"6CDYXS\":[\"Static website serving\"],\"6GBt0m\":[\"Metadata\"],\"6H/Lg1\":[\"This is a public image. All users have access to it. Explicit sharing is not needed.\"],\"6KRclz\":[\"Folder Created\"],\"6Kjltl\":[\"Access Control for container:\"],\"6OopEX\":[\"Container Emptied\"],\"6Rnrsz\":[\"Manage Access - \",[\"flavorName\"]],\"6X/9Di\":[\"\\\"\",[\"objectName\"],\"\\\" was successfully moved to \",[\"destination\"],\".\"],\"6YtxFj\":[\"Name\"],\"6jAi8c\":[\"Range\"],\"6luZQA\":[\"Object Moved\"],\"6oolxV\":[\"This extra spec keys already exist. Please use different keys.\"],\"6qzsuS\":[\"Write ACLs\"],\"6sxz+g\":[\"Port Name\"],\"6w+VnM\":[\"Container Created\"],\"6z9W13\":[\"Restart\"],\"76RKuS\":[\"ICMP Code\"],\"78+riR\":[\"You are not authorized to remove tenant access. Please log in again.\"],\"7AfIPZ\":[\"Floating Network\"],\"7BpykL\":[\"Failed to create extra specs. Please try again.\"],\"7L01XJ\":[\"Actions\"],\"7NC3vm\":[\"Subnet\"],\"7NSdfG\":[\"Emptying container \",[\"progressCurrent\"],\" of \",[\"progressTotal\"],\", please wait...\"],\"7Q24LN\":[\"Policy\"],\"7T1fHv\":[\"Failed to remove member \\\"\",[\"memberIdToRemove\"],\"\\\"\"],\"7UlHhT\":[\"Metadata \\\"\",[\"keyToDelete\"],\"\\\" has been deleted successfully.\"],\"7XQ3QJ\":[\"Denied referrer: \",[\"host\"]],\"7ZnTL8\":[\"Failed to update object: \",[\"mutationErrorMessage\"]],\"7a4DvD\":[\"No servers available.\"],\"7d1a0d\":[\"Public\"],\"7flw0l\":[\"Tenant access for \\\"\",[\"trimmedTenantId\"],\"\\\" has been added successfully.\"],\"7huC4O\":[\"There are no Certificates available for this Certificate Authority.\"],\"7sMeHQ\":[\"Key\"],\"88kg0+\":[\"Created At\"],\"8AriEH\":[\"Image instance \\\"\",[\"imageName\"],\"\\\" has been created\"],\"8S2nDL\":[\"No PCAs found\"],\"8TSI9h\":[\"Deactivating this image will prevent it from being used to launch new instances. Existing instances will not be affected.\"],\"8Tg/JR\":[\"Custom\"],\"8ZOb7O\":[[\"numberDeleted\"],\" object was permanently deleted.\"],\"8ZsakT\":[\"Password\"],\"8c3/77\":[\"Max meta name length\"],\"8jLXs3\":[\"Versioned writes\"],\"8s0tOH\":[\"You don't have permission to add tenant access to this flavor.\"],\"8t1+HU\":[\"Deactivated \",[\"successCount\"],\" image(s), but \",[\"failedCount\"],\" image(s) could not be deactivated.\"],\"8uPTwT\":[[\"filteredCount\",\"plural\",{\"one\":[[\"filteredCount\"],\" of \",[\"totalCount\"],\" container\"],\"other\":[[\"filteredCount\"],\" of \",[\"totalCount\"],\" containers\"]}]],\"8wdCNd\":[\"tcp, udp, icmp, or protocol number\"],\"8zAn1f\":[\"Failed to delete flavor. Please try again.\"],\"98Fs4G\":[\"Creating image...\"],\"9J93Xr\":[\"Container name cannot contain slashes\"],\"9SX0bO\":[\"The image \\\"\",[\"imageName\"],\"\\\" could not be updated: \"],\"9X8lAk\":[\"Allocate\"],\"9doWrf\":[\"Failed to add member\"],\"9dsDHD\":[\"The image \\\"\",[\"imageId\"],\"\\\" could not be re-activated: \",[\"message\"]],\"9iz2XW\":[\"Unable to Update Image\"],\"9njIiV\":[\"Failed to Activate Images\"],\"9rz81C\":[\"Device ID\"],\"9v5VLp\":[\"No custom properties defined\"],\"9vSW3U\":[\"Delete Recursively\"],\"9x6EkK\":[\"This is a public flavor. All tenants have access to it.\"],\"A7CVME\":[\"Select disk format first\"],\"AB4Tnl\":[\"Please select a file to upload\"],\"AGXLLY\":[\"Unable to Upload Image File\"],\"AJRhSM\":[\"Root Disk must be an integer ≥ 0.\"],\"AN0DBJ\":[\"Press Enter to add\"],\"AX9Juz\":[\"ID must only contain alphanumeric characters, hyphens, underscores, and dots.\"],\"AZyHwC\":[\"Must be a valid IPv4 or IPv6 address (for example: 172.24.4.228 or 2001:db8::1).\"],\"Ac6dy9\":[\"Type name\"],\"AdtLNV\":[\"Ensure ACL entries are valid — correct project IDs, user IDs, and formats are your responsibility. Invalid entries may silently grant or deny unintended access.\"],\"AeXO77\":[\"Account\"],\"Afh/Lb\":[\"Select destination folder\"],\"AlbUVn\":[\"<0>Optimized Scalability: Built for businesses of all sizes, Aurora grows with you, supporting simple environments and intricate multi-cloud setups alike.\"],\"Alx2/L\":[\"Open in new tab\"],\"AuQtzx\":[\"Must be a non-negative integer\"],\"AxZkIr\":[\"Disk (GiB)\"],\"B2Czeb\":[\"Min. RAM\"],\"B2i9cQ\":[\"Objects to be deleted (\",[\"totalCount\"],\")\"],\"B3toQF\":[\"Objects\"],\"B4Jzm7\":[\"Ceph\"],\"BCJPTn\":[\"Grant access to all users from that project.\"],\"BCXapL\":[\"Failed to load container properties: \",[\"errorMessage\"]],\"BJt+PJ\":[\"Failed to Delete Container\"],\"BMTd81\":[\"This action cannot be undone. The target project will lose access to this security group immediately.\"],\"BMogtG\":[\"Issue End Entity Certificate\"],\"BOQYRn\":[\"Loading Key Pairs...\"],\"BP4Fwj\":[\"Error Loading Objects: \",[\"errorMessage\"]],\"BSaBkZ\":[\"Objects — \",[\"containerName\"]],\"BYH/2L\":[\"Unable to Deactivate Image\"],\"BZpsYm\":[\"Failed to load containers: \",[\"errorMessage\"]],\"BgMp/T\":[\"Invalid format combination for selected disk format\"],\"Blsc/x\":[\"Delete Certificate Authority\"],\"BoIAP6\":[\"The ID of the network associated with the floating IP.\"],\"BoPocW\":[\"MD5 checksum\"],\"BrrIs8\":[\"Storage\"],\"CA8ZeT\":[\"Image \\\"\",[\"imageName\"],\"\\\" visibility updated to \",[\"visibility\"]],\"CBFSfX\":[\"Please fix the validation errors below.\"],\"CFMxC8\":[\"Images Deleted\"],\"CMVP7y\":[\"This action cannot be undone. The rule will be permanently deleted.\"],\"CgZxr7\":[\"Min RAM (MB)\"],\"ChOuUj\":[\"Floating IP not found\"],\"Cj2Gtd\":[\"Size\"],\"ClGcRq\":[\"Containers\"],\"Cu6xuZ\":[\"This is a <0>dynamic large object (DLO) manifest. Metadata changes apply to the manifest only — segment objects are not affected.\"],\"CunRry\":[\"Invalid project ID format. Must be 32 hexadecimal characters (e.g., b90f9c4bc76140e18540b2cec1299e2a) or UUID format (e.g., 12345678-1234-1234-1234-123456789abc)\"],\"Cxgv2U\":[\"Min. Disk\"],\"D/8vkD\":[\"It will appear in your image list.\"],\"D3IRXw\":[\"Detaching Floating IP...\"],\"D7qT9F\":[\"Why Choose Aurora?\"],\"DDRhQm\":[\"Your session has expired.\"],\"DHrCY6\":[\"Common name\"],\"DJT9tB\":[\"Account quotas\"],\"DKkOPx\":[\"Extra Specs\"],\"DNVql8\":[\"Full lifecycle management of Floating IPs, including attachment, port association/disassociation, DNS settings, and deletion\"],\"DcMIiu\":[\"Could not update ACLs for container \\\"\",[\"containerName\"],\"\\\": \",[\"errorMessage\"]],\"Df0YHr\":[\"Update Security Group\"],\"Dh1qvV\":[\"You are about to delete \",[\"deletableCount\"],\" image(s). This action cannot be undone.\"],\"Dia2Ue\":[\"There are no RBAC policies for this security group\"],\"Do5/uH\":[\"The flavor or tenant could not be found. It may have already been removed.\"],\"Dqnh7K\":[\"Specific referrer: \",[\"host\"]],\"Dt5W9T\":[\"Remove RBAC Policy\"],\"DvB4XF\":[\"Drop your file here\"],\"E/QGRL\":[\"Disabled\"],\"E4QYe7\":[\"Suggested Images\"],\"E6nRW7\":[\"Copy URL\"],\"EF2EU9\":[\"Deleting...\"],\"EPMHs9\":[\"You don't have permission to delete flavors in this project.\"],\"EQnVgi\":[\"Flavor service is not available for this project.\"],\"EdQY6l\":[\"None\"],\"Ef7StM\":[\"Unknown\"],\"Enpdmy\":[\"Type <0>remove to confirm:\"],\"EoKe5U\":[\"Domain\"],\"Eq5PsT\":[\"Type \\\"detach\\\" to confirm\"],\"EqSPkP\":[\"Loading Flavors...\"],\"Erlvqg\":[\"Object name cannot have leading or trailing whitespace\"],\"ExLULX\":[\"Image Name\"],\"EztMB8\":[\"Failed to fetch flavors from server.\"],\"F02e8I\":[\"No custom metadata. Click \\\"Add Property\\\" to create one.\"],\"F6YIQe\":[\"Efficient bulk deletion\"],\"FKL6Jv\":[\"e.g. .r:*,.rlistings\"],\"FNcMGM\":[\"Creation Date\"],\"FOcBn3\":[\"Detach\"],\"FQBaXG\":[\"Activate\"],\"FRtmJJ\":[\"Storage container not found\"],\"FSbpS7\":[\"CPU\"],\"FjONW3\":[\"Error Loading Flavor\"],\"FjPnAE\":[\"Error loading security group\"],\"Flugry\":[[\"progressPct\"],\"%\"],\"FwSyEp\":[\"The specified project does not exist or you don't have permission to share with it.\"],\"Fzrzfe\":[\"Folder name is required\"],\"G6AP+o\":[\"Shared:\"],\"GDx4dP\":[\"Manage your Certificate\"],\"GEgjm+\":[\"Loading Objects...\"],\"GPuCEo\":[\"Leave empty for all types\"],\"GSIPwA\":[\"Temporary URL\"],\"GbKqnI\":[\"Activated \",[\"successCount\"],\" image(s), but \",[\"failedCount\"],\" image(s) could not be activated.\"],\"Gfx1qQ\":[\"Unable to Load Content\"],\"GxkJXS\":[\"Uploading...\"],\"Gyd3No\":[\"No specific tenant access configured for this private flavor. Click \\\"Add Tenant Access\\\" to grant access.\"],\"H+a5j6\":[\"Release\"],\"H4Qwmp\":[\"No objects match your search. Try adjusting your search term.\"],\"H7u085\":[\"No projects have access to this image yet. Click \\\"Add Project Access\\\" to grant access.\"],\"HAkrpK\":[\"At Aurora, our mission is to provide a centralized platform that unifies cloud management. We aim to simplify the complexities of provisioning, configuring, and scaling resources across diverse cloud environments while enabling seamless growth for your business.\"],\"HBpi4q\":[\"Loading Images...\"],\"HG0uMz\":[\"Back to Certificate Authorities\"],\"HM56Bx\":[\"Creating...\"],\"HNlEFZ\":[\"delete\"],\"HQH8HM\":[\"Could not update \\\"\",[\"objectName\"],\"\\\": \",[\"errorMessage\"]],\"HVdrr1\":[\"ANY referrer\"],\"HivZR9\":[\"Create Credential\"],\"Hivb/4\":[\"Server is experiencing issues. Please try again later.\"],\"Hiw1Ha\":[\"No containers found\"],\"HlwgQN\":[\"Object \\\"\",[\"objectName\"],\"\\\" was permanently deleted.\"],\"HuA8iQ\":[\"Allocating Floating IP...\"],\"HxTYrE\":[\"The flavor could not be found. It may have been deleted.\"],\"I5kZVK\":[\"Remote Source\"],\"INUP6f\":[\"<0>Effortless Resource Provisioning: Quickly provision, configure, and deploy resources like servers, networks, and volumes with just a few clicks.\"],\"IOkHLC\":[\"Failed to copy object: \",[\"errorMessage\"]],\"IQSLN+\":[\"Error loading Certificate Authority\"],\"IUwGEM\":[\"Save Changes\"],\"IWF68U\":[\"Storage Overview\"],\"IZ6Mh2\":[\"Enter your domain\"],\"IbYr/u\":[\"Content type\"],\"Io2Dvq\":[\"Certificate Authority not found\"],\"Ioblgz\":[\"This action is permanent. All objects in the container will be deleted and this cannot be undone.\"],\"J4DKSM\":[\"Container format is required\"],\"J6EOll\":[\"Move/Rename object:\"],\"J7+bZb\":[\"Folder Deleted\"],\"J9QcnV\":[\"Successfully activated \",[\"successCount\"],\" of \",[\"totalCount\"],\" image(s)\"],\"J9cmxx\":[\"Failed to update visibility to \",[\"newVisibility\"]],\"JB0bhm\":[\"Get Involved\"],\"JNGYAW\":[\"Container name is required\"],\"JT3I1g\":[\"Delete Flavor\"],\"JeRXll\":[\"This key is reserved and managed separately\"],\"JfWCsP\":[\"Partial Deactivation Success\"],\"Jh4rAZ\":[\"Error loading image\"],\"Jim5X9\":[\"Stateful\"],\"JoECY1\":[\"The extra spec data provided is invalid. Please check your input.\"],\"JpZn1L\":[\"Already deactivated (will be skipped)\"],\"JrmKyf\":[\"Failed: \",[\"errorDetails\"]],\"JtHgVz\":[\"Delete Images\"],\"K+e/0e\":[\"RAM (MiB)\"],\"K3bUTE\":[\"Minimum disk must be 0 or greater\"],\"K8Qnlj\":[\"Moving...\"],\"K9eC8x\":[\"This could be due to insufficient permissions or a temporary service issue. Please check your access rights or try refreshing the page.\"],\"KDw4GX\":[\"Try again\"],\"KJC+M7\":[\"Server error occurred while fetching flavor details. Please try again later.\"],\"KOpPMt\":[\"Total size quota\"],\"KSW/GC\":[\"There are no flavors available for this project with the current filters applied. Try adjusting your filter criteria or create a new flavor.\"],\"KZN4Lc\":[\"Delete All\"],\"Km4AGG\":[\"Creating security group...\"],\"KoQP4F\":[\"Server error occurred while adding tenant access. Please try again later.\"],\"KsIM0b\":[\"Boot RAM\"],\"KsnZ3m\":[\"Folder \\\"\",[\"folderName\"],\"\\\" was successfully created.\"],\"KzUd7m\":[\"new-folder-name\"],\"LI70tz\":[\"Error loading Certificate\"],\"LI8Z2I\":[\"Download \",[\"rowDisplayName\"]],\"LK0pQN\":[\"Disk format is required\"],\"LMdsuJ\":[\"Port (from)\"],\"LQQCas\":[\"Folder \\\"\",[\"folderName\"],\"\\\" and \",[\"deletedCount\"],\" object was permanently deleted.\"],\"Llcakz\":[\"Updated At\"],\"LqMb+g\":[\"To confirm this action, type the word <0>\\\"release\\\" in the field below.\"],\"LtI9AS\":[\"Owner\"],\"Lylr9Z\":[\"Object Copied\"],\"M470oJ\":[\"The flavor could not be found or has no extra specs.\"],\"M5Epeo\":[\"Edit Image Details\"],\"M5RhXF\":[\"Removing...\"],\"M5rEN5\":[\"Session Expired\"],\"M9H+/G\":[\"projects\"],\"MEIAzV\":[\"Unnamed\"],\"MILoeL\":[\"Services\"],\"MJtNLd\":[\"Images to delete:\"],\"MOug+V\":[\"Enter a tag and press Enter or click Add\"],\"MRB7nI\":[\"Direction\"],\"MXoA/6\":[\"Upload Object\"],\"MXw7Fr\":[\"Server Name\"],\"MZGbkp\":[\"VCPUs\"],\"MbKJNP\":[\"You don't have permission to access flavor access information for this flavor.\"],\"MgZyuJ\":[\"You are about to activate <0>\",[\"deactivatedCount\"],\" image(s). Activated images will be available for launching new instances.\"],\"MmtQVF\":[\"Invalid value for public flavor setting.\"],\"Mt6sRo\":[\"You are not authorized to access flavor access information. Please log in again.\"],\"MtzSbv\":[\"Object name is required\"],\"MuKU9V\":[\"Failed to load objects: \",[\"errorMessage\"]],\"N2S1rs\":[\"Empty\"],\"N5I2RJ\":[\"Type \\\"release\\\" to confirm\"],\"N5vGcw\":[\"Enter your credentials to access your account\"],\"NH2fsP\":[\"Already deactivated (will be skipped):\"],\"NOdFZR\":[\"Generating...\"],\"NQU1Nn\":[\"Copy container name\"],\"NRMm0E\":[\"This tenant already has access to the flavor.\"],\"NRP2uq\":[\"Share object:\"],\"NRVSdy\":[\"Member ID\"],\"NW4PIb\":[\"Could not create folder \\\"\",[\"folderName\"],\"\\\": \",[\"errorMessage\"]],\"NZJhro\":[\"Object name cannot contain slashes\"],\"Nc7QKU\":[\"Fixed IP Address\"],\"NeUjqc\":[\"Enable file listing\"],\"NixRmA\":[\"Min Disk (GB)\"],\"NlcF/v\":[\"No flavor selected for deletion.\"],\"NopYGU\":[\"Disk Format\"],\"Np28ib\":[\"or drag and drop\"],\"Nu4oKW\":[\"Description\"],\"Nvfd2b\":[\"Versioning is enabled\"],\"O80bQY\":[\"Loading object properties...\"],\"O8tK4v\":[\"Add rule\"],\"ONWvwQ\":[\"Upload\"],\"OR475H\":[\"Network\"],\"OSlLnz\":[\"Image Visibility\"],\"OYHzN1\":[\"Tags\"],\"OZImTR\":[\"Container listing limit\"],\"OaSktR\":[\"Device Owner\"],\"Oc8Aqv\":[\"Preview and Edit metadata\"],\"OlmKCg\":[\"A flavor with this ID or name already exists. Please use different values.\"],\"OvEjsP\":[\"Copying...\"],\"Ovofy+\":[\"Release Floating IP \",[\"floating_ip_address\"]],\"OxDN2m\":[\"Failed to create flavor. Please try again.\"],\"OxaeYj\":[\"We are building Aurora Dashboard to serve you better. Your feedback is invaluable in shaping a tool that meets the unique needs of businesses like yours. Stay connected and join us as we redefine cloud management.\"],\"Oxl1UN\":[\"If there is no index file, the URL displays a list of objects in the container.\"],\"PAKSdy\":[\"Enter a floating IP or leave blank to auto-assign one\"],\"PEGvy+\":[\"Minimum RAM must be 0 or greater\"],\"PHsq3v\":[\"Before proceeding, ensure that the Project ID and User ID you enter are correct. The system cannot validate these values, and incorrect IDs may apply access to wrong projects and users.\"],\"PHt+EV\":[\"Type <0>delete to confirm:\"],\"PIbPRX\":[\"RX/TX Factor must be an integer ≥ 1.\"],\"PLwzWR\":[\"All containers\"],\"PYQUjU\":[\"Failed to load metadata configuration.\"],\"PZnUbs\":[\"Please log in again to continue.\"],\"PgNNGl\":[\"More Actions\"],\"PiH3UR\":[\"Copied!\"],\"PiyQJ/\":[\"No flavors found\"],\"PkfPsB\":[\"Enter the ID of the project you want to share this security group with. You can find project IDs in the account/project switcher or in the Identity service.\"],\"Pkw7J9\":[\"This folder is empty.\"],\"PsEGri\":[\"Ubuntu 22.04 LTS\"],\"PtjzS+\":[\"Associates on the selected port. If the port has multiple IPs, select the desired fixed IP address.\"],\"PzgYM9\":[\"Checksum\"],\"Q1W//7\":[\"No services available for this project.\"],\"Q2xmVl\":[\"Symlinks\"],\"Q9f2QF\":[[\"numberDeleted\"],\" objects were deleted successfully, but some deletions failed.\"],\"QAUa4B\":[\"Enter a single port, or define a range by also filling \\\"Port (to)\\\". \\\"Port (to)\\\" is optional.\"],\"QEtDlS\":[\"Copying object...\"],\"QNHur0\":[\"Failed to load container ACLs: \",[\"errorMessage\"]],\"QQ8wUG\":[\"This action cannot be undone. The flavor will be permanently deleted.\"],\"QV1ZPO\":[\"Key is required\"],\"QWdKwH\":[\"Move\"],\"QYiqYb\":[\"Failed to update access status\"],\"Qb+14I\":[\"This action cannot be undone. The security group will be permanently deleted.\"],\"QetsXP\":[\"Upload failed: \",[\"uploadError\"]],\"Qg4EG6\":[\"Unable to connect to the compute service. Please check your connection and try again.\"],\"QuJSSl\":[\"Failed to create the flavor. Please try again.\"],\"QvqBQa\":[\"Target container\"],\"Qx7DM7\":[\"Capabilities\"],\"QxBGbh\":[\"Protected (will be skipped):\"],\"QytzQr\":[\"Type \\\"delete\\\" to confirm\"],\"R6kcsL\":[\"Must be a valid PQDN or FQDN (alphanumeric and hyphens only, cannot start or end with hyphen).\"],\"R6u5CR\":[\"Failed to activate \",[\"failedCount\"],\" of \",[\"totalCount\"],\" image(s). Some images may already be active or in an invalid state.\"],\"RByeNR\":[\"Your session expired. Please login again.\"],\"RCr0yv\":[\"Failed to load flavor details. Please try again.\"],\"RFDYCD\":[\"Minimum disk size required to boot this image\"],\"RGhYAo\":[\"RAM\"],\"RGrgxg\":[\"Could not delete container \\\"\",[\"containerName\"],\"\\\": \",[\"errorMessage\"]],\"RGwfoL\":[\"Max meta count\"],\"RNBvdl\":[\"Max SLO segments\"],\"RS0o7b\":[\"State\"],\"RSFkXF\":[\"Activate Image\"],\"RSMPjT\":[\"You are currently on the dashboard route.\"],\"RSg/pq\":[\"Failed to Delete Object\"],\"RTQFAw\":[\"You are not authorized to create extra specs. Please log in again.\"],\"RWQ6BN\":[\"Enter Common name (e.g., demo-ca.test.sci)\"],\"Rih53k\":[\"Max container name length\"],\"Rlp5zj\":[\"Create Flavor\"],\"S0kLOH\":[\"ID\"],\"S1iTXO\":[\"Image instance \\\"\",[\"imageName\"],\"\\\" has been deleted\"],\"S3olSf\":[\"No extra specs found. Click \\\"Add Metadata\\\" to create one.\"],\"S5CUKP\":[\"Member ID (project UUID) is required.\"],\"S63NbU\":[\"The image \\\"\",[\"imageId\"],\"\\\" could not be deactivated: \",[\"message\"]],\"S8/j2h\":[\"Failed to Empty Container\"],\"SBGiGm\":[\"Read ACLs\"],\"SCY5an\":[\"Failed to Move Object\"],\"SFo0kK\":[\"All Images\"],\"SIfYq6\":[\"Edit Metadata\"],\"SLEH7X\":[\"Enter DNS name\"],\"STc+7E\":[\"Max containers per extraction\"],\"SU0uxT\":[\"Upload object to:\"],\"SUSS9i\":[\"Container name\"],\"SVLToM\":[\"Type \\\"remove\\\" to confirm\"],\"SZw9tS\":[\"View Details\"],\"Sb/VT5\":[\"Container \\\"\",[\"containerName\"],\"\\\" was successfully emptied. \",[\"deletedCount\"],\" objects deleted.\"],\"Sf3Gvg\":[\"Failed to load PCAs\"],\"SfW/3r\":[\"There are no groups\"],\"Sgz1vJ\":[\"Member \\\"\",[\"trimmedMemberId\"],\"\\\" has been added successfully.\"],\"Smk7M2\":[\"Error loading floating IP\"],\"SuX2Ca\":[\"Basic Info\"],\"SysqAR\":[\"Flavor Details\"],\"T6Gm5y\":[\"Select an external network\"],\"T7mgdd\":[\"Successfully deleted \",[\"successCount\"],\" of \",[\"totalCount\"],\" image(s)\"],\"T8N6oi\":[\"Property Key\"],\"T9Mtpi\":[\"Tenant ID\"],\"T9o/az\":[\"Loading Certificates issued by Certificate Authority...\"],\"TM93nK\":[\"Delete Security Group Rule\"],\"TPMaxo\":[\"Type “release” to confirm\"],\"TQn3hH\":[\"Failed to create image. Please try again.\"],\"TZJiVf\":[\"Container \\\"\",[\"containerName\"],\"\\\" was successfully emptied. \",[\"deletedCount\"],\" object deleted.\"],\"TfC9O+\":[\"Last modified (UTC)\"],\"TfdeUd\":[\"Failed to delete the extra spec. Please try again.\"],\"TpGxnq\":[\"Enter member ID\"],\"Tx4Ym+\":[\"Enter a valid PQDN or FQDN (max 63 characters) to associate with the floating IP. A and PTR records are created automatically.\"],\"TyODHt\":[\"Save Metadata\"],\"U/oahm\":[\"URL Copied\"],\"U2wTy/\":[\"Note: The 'stateful' attribute cannot be changed if this security group is currently in use by one or more ports.\"],\"U4fmHG\":[\"The text must match “detach” in lowercase.\"],\"U6L+P/\":[\"Inactivity Timeout\"],\"U9q4M7\":[\"Back to Security Groups\"],\"UB+Q8v\":[\"Loading Certificate Details...\"],\"UGhVPl\":[\"Object Type\"],\"UJVf0u\":[\"Loading Image...\"],\"UJmAAK\":[\"Subject\"],\"UK2mpr\":[\"Generating temporary URL...\"],\"UKwOYH\":[\"Image File\"],\"UO3hJ2\":[\"Temporary URLs\"],\"UQ7Wyv\":[\"Manage Access for Image - \",[\"imageName\"]],\"URmyfc\":[\"Details\"],\"USiuNX\":[\"Container quotas\"],\"UVFHGY\":[\"e.g. PROJECT_ID:USER_ID\"],\"UVSFVV\":[\"Reject Shared Image\"],\"UYSopm\":[\"Minimum RAM (MB)\"],\"UbRKMZ\":[\"Pending\"],\"UbWeJA\":[\"Duration/validity\"],\"UdcGJu\":[\"Activate Images\"],\"UiNv/G\":[\"S3 Object Storage requires EC2 credentials (access key + secret key) to authenticate your requests. You need to create credentials before accessing S3 resources.\"],\"Uj+n/2\":[\"Failed to Delete Folder\"],\"UkVkoq\":[\"Leave empty for all codes\"],\"UmQ3/m\":[\"Deactivate Selected\"],\"Uwo8Xw\":[\"This image was shared with you by <0>\",[\"ownerProject\"],\" on \",[\"sharedAt\"],\".\"],\"UztfYZ\":[\"Select port to associate\"],\"V/8B9A\":[\"I confirm that all existing versions will also be deleted\"],\"V/SINY\":[\"Update object\"],\"V1TzeS\":[\"Container \\\"\",[\"containerName\"],\"\\\" was successfully deleted.\"],\"V66Jih\":[\"Access Status\"],\"V7fN5X\":[\"Copy object:\"],\"V804LY\":[\"Updating security group...\"],\"VCM3KS\":[\"Add Project Access\"],\"VKmlZ+\":[\"Containers to be emptied (\",[\"totalCount\"],\")\"],\"VLI9eO\":[\"Loading Floating IP Details...\"],\"VMh1t1\":[\"The text must match “delete” in lowercase.\"],\"VV1fdg\":[\"Any user has read access to objects. No token is required in the request.\"],\"VaA9mu\":[\"24 hours\"],\"VakxP/\":[\"Failed to Upload Object\"],\"Vg0k6h\":[\"Showing \",[\"filteredCount\"],\" of \",[\"totalCount\"],\" \",[\"itemName\"]],\"Vh/Uj5\":[\"Target path\"],\"Vj8XFg\":[\"Failed to Create Container\"],\"Vl4XTj\":[\"Folder name cannot contain slashes\"],\"Vmojta\":[\"Access status updated to \\\"\",[\"newStatus\"],\"\\\".\"],\"VoxR3s\":[\"Object was copied but could not be deleted from the source: \",[\"deleteErrorMessage\"]],\"Vz+7ZA\":[\"Could not create container \\\"\",[\"containerName\"],\"\\\": \",[\"errorMessage\"]],\"Vzlopx\":[\"Delete container:\"],\"W0MCSG\":[\"Accept access to image\"],\"W5FkH9\":[\"Enter container name\"],\"W9PZE0\":[\"Objects Deleted\"],\"W9kfjU\":[\"QoS Policy ID\"],\"WCKEqI\":[\"This is a <0>static large object (SLO) manifest. Metadata changes apply to the manifest only — segment objects are not affected.\"],\"WCLyHI\":[\"No Floating IPs found\"],\"WErCZy\":[\"Minimum RAM required to boot this image\"],\"WIx31g\":[\"Create Certificate\"],\"WRZ3Mt\":[\"Loading container properties...\"],\"WYb0Td\":[\"<0>Are you sure? All objects in the selected containers will be permanently deleted. This cannot be undone.\"],\"WYiUDa\":[\"Loading Containers...\"],\"Wbg1jv\":[\"Copy \",[\"text\"],\" to clipboard\"],\"Wca9WC\":[\"Failed to load security groups\"],\"WefafP\":[\"This container appears empty — the object count may not have synced yet due to a recent operation.\"],\"WidMsn\":[\"Create Certificate Authority\"],\"WlpcJv\":[\"DNS Domain\"],\"WoSkGY\":[\"Remote IP\"],\"WrUky8\":[\"Share (Temporary URL)\"],\"WyKwnD\":[\"Store old object versions\"],\"WzVwU0\":[\"Target Project ID\"],\"X2OnDx\":[\"Ephemeral Disk (GiB)\"],\"X70LXS\":[[\"numberDeleted\"],\" objects were permanently deleted.\"],\"XLk16/\":[\"Together, we can unlock the true potential of your cloud infrastructure.\"],\"XYZLy9\":[\"Key contains invalid characters\"],\"XvjC4F\":[\"Saving...\"],\"XwxJJB\":[\"Container \\\"\",[\"containerName\"],\"\\\" was successfully created.\"],\"XxjLdW\":[[\"emptiedCount\",\"plural\",{\"one\":[\"#\",\" container was already empty.\"],\"other\":[\"#\",\" containers were already empty.\"]}]],\"Y+2SDm\":[\"Delete Security Group \\\"\",[\"securityGroupName\"],\"\\\"\"],\"Y1YKad\":[\"Edit Details\"],\"Y8M9Uc\":[\"The container will be deleted. This action is permanent and cannot be undone.\"],\"YIix5Y\":[\"Search...\"],\"YNgcgc\":[\"Loading Flavor Details...\"],\"YRexkb\":[\"Object Updated\"],\"YUU0QW\":[\"Flavor ID is required and cannot be empty.\"],\"YZmsaT\":[\"Partial Activation Success\"],\"YiMCKk\":[\"Show ACLs Preview\"],\"Yin3uB\":[\"Releasing Floating IP...\"],\"YjAOtb\":[\"Create Security Group\"],\"YrAy/S\":[\"You don't have permission to delete extra specs for this flavor.\"],\"YsOJlj\":[\"Server error occurred while fetching flavor access information. Please try again later.\"],\"YsrbQh\":[\"Owner Project ID\"],\"YuC9dj\":[\"Associate\"],\"YuGQWb\":[\"Rule Type\"],\"YzUoh9\":[\"To confirm type <0>delete in the field below.\"],\"Z/eWPC\":[\"The object will be copied to this path. Navigate folders above to change the destination.\"],\"Z2fZGD\":[\"No project selected\"],\"Z3FXyt\":[\"Loading...\"],\"Z42tfY\":[\"Folders in object storage are virtual — they are created as zero-byte placeholder objects with a trailing slash. The folder will appear once created.\"],\"Z5r9vC\":[\"Partial Delete Success\"],\"Z8lGw6\":[\"Share\"],\"ZAx+d1\":[\"Max meta overall size\"],\"ZAy0zp\":[\"Successfully deactivated \",[\"successCount\"],\" of \",[\"totalCount\"],\" image(s)\"],\"ZUmOzn\":[\"Server returned unexpected data format for flavor details.\"],\"ZcWMT1\":[\"Image instance \\\"\",[\"imageName\"],\"\\\" has been deactivated\"],\"Zgp2Sm\":[\"No projects available.\"],\"ZhVSpK\":[\"Failed to remove tenant access from flavor. Please try again.\"],\"Zq6Y5u\":[\"DNS name must be at most 63 characters.\"],\"ZvIpwi\":[\"Select a security group...\"],\"Zw49f9\":[\"folder-name\"],\"Zw8Q49\":[\"Security group not found\"],\"a/nTb8\":[\"Create Image\"],\"a12lSo\":[\"Port (to)\"],\"a13wDR\":[\"Type to search containers...\"],\"a3LDKx\":[\"Security\"],\"a4A2uB\":[\"Account listing limit\"],\"a4N/Bg\":[\"Load More\"],\"a7C4YS\":[\"Container Updated\"],\"a88X3d\":[\"<0>Are you sure? Object <1>\\\"\",[\"displayName\"],\"\\\" will be permanently deleted. This cannot be undone.\"],\"aG9OiI\":[\"Sharing Details\"],\"aI8Tgp\":[\"Owning Project ID\"],\"aL1w5Z\":[\"Used\"],\"aOeFR+\":[\"Empty Containers\"],\"aSsVD3\":[\"Public read access is not enabled. Before configuring static website serving, go to <0>Manage Access and enable public read access.\"],\"aTqCTq\":[\"Image file is required\"],\"aV6KPH\":[\"Prevent accidental deletion\"],\"aiqFbS\":[\"<0>Are you sure? The selected objects will be permanently deleted. This cannot be undone.\"],\"an5hVd\":[\"Images\"],\"ao/ZJi\":[\"Deleting folder and all its contents...\"],\"aqagJH\":[\"Unable to Update Image Visibility\"],\"arel2K\":[\"No objects found\"],\"azXlY+\":[\"Access Status:\"],\"b0uU1G\":[\"Store old object versions in container:\"],\"b2BLBa\":[\"Add Security Group Rule\"],\"b5aNMO\":[\"The text must match \\\"delete\\\"\"],\"bISG26\":[\"Failed to fetch flavor access information. Please try again.\"],\"bM1O3m\":[\"Image Instance\"],\"bQBMTH\":[\"This is a <0>static large object. By default, all associated segment objects will also be permanently deleted.\"],\"bRgFkJ\":[\"Failed to upload file \\\"\",[\"fileName\"],\"\\\": \"],\"bYRFNi\":[\"Failed to Delete Objects\"],\"bc67JN\":[\"Custom Properties / Metadata\"],\"bmQLn5\":[\"Add Rule\"],\"bnql/K\":[\"Back to Images\"],\"boJ+Y1\":[\"Create Folder\"],\"boJlGf\":[\"Page Not Found\"],\"bpme7e\":[\"Flavor Not Found\"],\"bwRvnp\":[\"Action\"],\"bwhBhT\":[\"Security Group\"],\"byKna+\":[\"An unexpected error occurred. Please try again.\"],\"bzMKg7\":[\"Accepted\"],\"bzSI52\":[\"Discard\"],\"c+fUtV\":[\"Start typing to search for a container\"],\"c+xCSz\":[\"True\"],\"c1OE1x\":[\"CA ID\"],\"c1uL4p\":[\"The image \\\"\",[\"imageId\"],\"\\\" could not be deleted: \",[\"message\"]],\"c6b6fz\":[\"Delete Selected\"],\"cCfxH1\":[\"Downloading...\"],\"cJDQIO\":[\"Root Disk\"],\"cPKL6O\":[\"You don't have permission to create flavors in this project.\"],\"cWbW6w\":[\"Manage Access\"],\"cXuXkb\":[\"User \",[\"userId\"],\" from project \",[\"projectId\"]],\"chL5IG\":[\"Community\"],\"cj17eo\":[\"Image instance \\\"\",[\"imageName\"],\"\\\" has been activated\"],\"cjEOmc\":[\"Share this security group with another project. The target project will be able to view and use this security group, but will not be able to modify or delete it.\"],\"cnGeoo\":[\"Delete\"],\"cpw++p\":[\"Static large object support\"],\"cqQyPB\":[\"Folder name\"],\"ctc4XR\":[\"Delete certificate authority\"],\"d+F6q9\":[\"Created\"],\"d+Ugpw\":[\"<0>Are you sure? Folder <1>\\\"\",[\"folderDisplayName\"],\"\\\" and all objects within it will be permanently deleted. This cannot be undone.\"],\"d/I0J3\":[\"Activate Selected\"],\"d0pLfy\":[\"Failed to delete security group\"],\"dEgA5A\":[\"Cancel\"],\"dFb5Nt\":[\"Id\"],\"dLFiER\":[\"Error Loading Containers: \",[\"errorMessage\"]],\"dOevLB\":[\"Expires in \",[\"selectedPresetLabel\"],\" — at \",[\"expiresAtFormatted\"]],\"dPBJAJ\":[\"Empty All (\",[\"selectedCount\"],\")\"],\"dPj4yB\":[\"Login to Your Account\"],\"dPoCVe\":[\"Type “detach” to confirm\"],\"dTNzBI\":[\"Key must contain at least one alphanumeric character\"],\"dVdc7N\":[\"You are not authorized to delete extra specs. Please log in again.\"],\"dd2ndz\":[\"Entries in ACLs are comma-separated. Examples:\"],\"diFNkW\":[\"Error loading component\"],\"dxMaZH\":[\"Manage your Private Certificate Authority infrastructure\"],\"e0NrBM\":[\"Project\"],\"eChIh7\":[\"Flavor \\\"\",[\"flavorName\"],\"\\\" has been successfully created.\"],\"eGEHJE\":[\"DNS Name\"],\"eKC+EC\":[\"-\"],\"ePK91l\":[\"Edit\"],\"eYlnXt\":[\"No images found\"],\"eh/k36\":[\"Select a rule type...\"],\"ekCRTP\":[\"Rejected\"],\"eks7oA\":[\"Port ID\"],\"eu70nA\":[\"Expires at (UTC)\"],\"eyRsaH\":[\"Root\"],\"ezT9KW\":[\"Container syncing\"],\"f+Uq1E\":[\"New object name\"],\"f0cwjH\":[\"Object properties\"],\"fCPhho\":[\"One or more objects could not be deleted:\"],\"fIvd7X\":[\"Failed to Delete Images\"],\"fJpv9x\":[\"Failed to Deactivate Images\"],\"ffw//c\":[\"PCA\"],\"fj5byd\":[\"N/A\"],\"fnCEAB\":[\"Type “delete” to confirm\"],\"fxnDd7\":[\"Failed to generate temporary URL: \",[\"generalError\"]],\"fzfAAa\":[\"Ingress\"],\"g+Jead\":[\"IPv6\"],\"g1IxCo\":[\"RAM must be an integer ≥ 128 MB.\"],\"g3BSCe\":[\"Swap Disk must be an integer ≥ 0.\"],\"g3UF2V\":[\"Accept\"],\"g8Yxlg\":[\"Temporary URL for \\\"\",[\"objectName\"],\"\\\" was copied to clipboard.\"],\"g9m7gK\":[\"ACL entries control who can read from or write to this container. Multiple entries are comma-separated. Changes take effect immediately after saving.\"],\"gFKJBP\":[\"Folder name cannot have leading or trailing whitespace\"],\"gGdfWx\":[\"The compute service is currently unavailable for this project. Please try again later.\"],\"gHTJc/\":[\"Object Storage\"],\"gMYsdZ\":[\"Set to \\\"Shared\\\"\"],\"gU7JFm\":[\"Creating security group rule...\"],\"gYe+hC\":[\"Click to upload\"],\"go0J2x\":[\"Failed to copy the temporary URL to the clipboard\"],\"go9U+C\":[\"To confirm, type <0>\\\"delete\\\" in the field below.\"],\"grs4+e\":[\"Compute Overview\"],\"gy6L1u\":[\"Must be a valid common name (FQDN).\"],\"gztCjq\":[\"The tenant ID provided is invalid. Please check your input.\"],\"h3P8z+\":[\"Failed to delete the flavor. Please try again.\"],\"h47p9L\":[\"—\"],\"h8h6oz\":[\"Images Deactivated\"],\"h99+4y\":[\"Allocate Floating IP\"],\"hH3kDo\":[\"Loading Image Details...\"],\"hHL/wm\":[\"Image instance \\\"\",[\"imageName\"],\"\\\" has been updated\"],\"hLp49h\":[\"Type <0>\",[\"deleteWord\"],\" to confirm:\"],\"hPz54a\":[\"Failed to Download\"],\"hQr1Cr\":[\"Deactivate Image\"],\"hXUWyd\":[\"Value is required\"],\"hYgDIe\":[\"Create\"],\"he3ygx\":[\"Copy\"],\"he4q+i\":[\"e.g., b90f9c4bc76140e18540b2cec1299e2a\"],\"hgpMHD\":[\"Total Size\"],\"hkjZ7P\":[\"Failed to update visibility for \\\"\",[\"imageName\"],\"\\\": \"],\"hrBow7\":[\"Network ID\"],\"hz9da7\":[\"Failed to load Certificates issued by Certificate Authority.\"],\"i0qMbr\":[\"Home\"],\"i30J2U\":[\"No projects found\"],\"i41Xuw\":[\"Select a fixed IP address\"],\"i5MEDc\":[\"Failed to move object: \",[\"copyErrorMessage\"]],\"i6/ygf\":[\"A property with this key already exists\"],\"i9TIyi\":[\"Remote Security Group\"],\"i9qiyR\":[\"Expires in\"],\"iH8pgl\":[\"Back\"],\"igVDFt\":[\"Attach\"],\"iqUvrS\":[\"User C/D/I\"],\"izMhIO\":[\"User \",[\"userId\"],\" (any project)\"],\"j9hkgJ\":[\"rules\"],\"jBIkmi\":[\"QCOW2, Raw, VMDK, VHD, VHDX, VDI, AMI, ARI, AKI, ISO, PLOOP\"],\"jIPNJG\":[\"Basic Information\"],\"jK6wqe\":[\"Folder \\\"\",[\"folderName\"],\"\\\" and \",[\"deletedCount\"],\" objects were permanently deleted.\"],\"jKopCP\":[\"Network & Routing\"],\"jMc/mo\":[\"Server error occurred while creating extra specs. Please try again later.\"],\"jNm/qL\":[\"This container is already empty.\"],\"jNzyQo\":[\"Object versioning\"],\"jPxavx\":[\"Failed to update security group\"],\"jS4B2+\":[\"Container name does not match\"],\"jSG7wx\":[\"Please enter a valid number of minutes greater than 0\"],\"jVjr9h\":[\"Enter a valid common name in FQDN format (e.g., demo-ca.test.sci).\"],\"jhU93c\":[\"The image \\\"\",[\"imageName\"],\"\\\" could not be created: \"],\"js24f6\":[\"Delete folder:\"],\"jtnAf8\":[\"Protected images (cannot be deleted)\"],\"jyqLKs\":[\"This member already has access to this image.\"],\"k0vAWv\":[\"Object count quota\"],\"k5nYwm\":[\"vCPU\"],\"k7ENJG\":[\"Preview \",[\"rowDisplayName\"]],\"k99j0U\":[\"Cancel upload\"],\"kA2lMP\":[\"External Network\"],\"kCLnJG\":[\"Empty All\"],\"kGmM/p\":[\"You don't have permission to create extra specs for this flavor.\"],\"kIuDMT\":[\"Configure the ingress and egress rules that control which traffic is allowed for this security group.\"],\"kKK8AH\":[\"Remaining Quota:\"],\"kNeZrV\":[\"No Certificates issued by this Certificate Authority found\"],\"kQYfgO\":[\"One or more containers could not be emptied: \",[\"errorMessage\"]],\"kiRrtv\":[\"<0>Please note: for <1>dynamic and <2>static large objects only the manifests will be deleted. The related segments will not be deleted.\"],\"kqJVBO\":[\"You don't have permission to share this security group.\"],\"kuYWaD\":[\"Reserved keys: web-index, web-listings, quota-count, quota-bytes\"],\"kzkYE6\":[\"Checking S3 credentials...\"],\"l75CjT\":[\"Yes\"],\"lAsm87\":[\"Protect this image from being deleted\"],\"lBVhQs\":[\"Something went wrong:\"],\"lN/Z9n\":[\"Security group name is required\"],\"lN3xvy\":[\"Delete Rule\"],\"lQ3EIe\":[\"Max deletes per request\"],\"lWTy+Y\":[\"Unable to Create Image\"],\"lWxDDh\":[\"Flavor Name\"],\"lZvIXd\":[\"Description must be at most 255 characters.\"],\"lhIa6x\":[\"Failed to load extra specs. Please try again.\"],\"lq/mBZ\":[\"Loading object info...\"],\"lw1412\":[\"You have been logged out due to inactivity.\"],\"lxentK\":[\"An unexpected error occurred\"],\"m16xKo\":[\"Add\"],\"m6X3ro\":[\"Group Name\"],\"mQSO1Y\":[\"Port Forwarding\"],\"mSLePW\":[\"You don't have permission to access flavors for this project.\"],\"mSfwLL\":[\"Project ID\"],\"mYnJeY\":[\"The text must match “release” in lowercase.\"],\"miy5mb\":[\"PCA (Clavis)\"],\"mqljvE\":[\"Copy metadata\"],\"mvz5Eo\":[\"URL for public access\"],\"mxPfpY\":[\"Create new folder here\"],\"mzI/c+\":[\"Download\"],\"n0ZttO\":[\"Root Disk (GiB)\"],\"n1ekoW\":[\"Sign In\"],\"n1gB0L\":[\"Edit Floating IP \",[\"floating_ip_address\"]],\"n22YIM\":[\"Edit Description\"],\"n2IuBI\":[\"A temporary URL grants time-limited read access to this object without requiring authentication. Anyone with the link can download it until it expires.\"],\"n3eQzA\":[\"This property is reserved and cannot be modified\"],\"n46oLW\":[\"Failed to remove member\"],\"n9jJG6\":[\"Remove member access\"],\"nETBrc\":[\"Egress\"],\"nLvo6K\":[\"RBAC Policy Details:\"],\"nNKXt7\":[\"Deleting this Certificate Authority is permanent, and all the associated certificates will no longer apply to entities.\"],\"nUuaq8\":[\"Failed to update container: \",[\"errorMessage\"]],\"nW/hX9\":[\"General Image Data\"],\"nWNviN\":[\"Deleting certificate authority...\"],\"nZbdB+\":[\"Upload Cancelled\"],\"ne/GWZ\":[\"Inside a project, objects are stored in containers. Containers are where you define access permissions and quotas.\"],\"neiJm0\":[\"Flavors\"],\"ng+PCh\":[\"There are no PCAs available for this project.\"],\"nkpZyk\":[\"Container \\\"\",[\"containerName\"],\"\\\" was already empty.\"],\"nnxwBn\":[\"There are no rules for this security group\"],\"ntNlXu\":[\"Listing access\"],\"nzFJqC\":[\"Delete CA\"],\"o/VDOG\":[\"Unable to Delete Image\"],\"o6M6l0\":[\"Failed to create security group\"],\"oDkgME\":[\"You are not authorized to create flavors. Please log in again.\"],\"oEGiW3\":[\"Uploading... \",[\"progressPct\"],\"%\"],\"ocUvR+\":[\"False\"],\"odVI9Y\":[\"Container Deleted\"],\"og1m+J\":[\"Loading Certificate Authority Details...\"],\"okXQSt\":[\"Subject information\"],\"olfSYj\":[\"Access Control Updated\"],\"onHi/J\":[\"It will be removed from your image list.\"],\"p4nMut\":[\"Swap (MiB)\"],\"p6CSHM\":[\"Delete Objects\"],\"p7DzCB\":[\"Failed to Update Access Control\"],\"pFg+7w\":[\"Updated:\"],\"pOPvlj\":[\"Already active (will be skipped)\"],\"pU25+T\":[\"Upload of \\\"\",[\"objectName\"],\"\\\" was cancelled.\"],\"pbzA+s\":[\"Optional description\"],\"pebLmQ\":[\"Remove access for \",[\"memberIdDisplay\"]],\"plnnns\":[\"Deleted \",[\"successCount\"],\" image(s), but \",[\"failedCount\"],\" image(s) could not be deleted.\"],\"poCbZw\":[\"Loading ACLs...\"],\"podzPY\":[\"Project id\"],\"psPHye\":[\"Accept Shared Image\"],\"pubQie\":[\"Enter value\"],\"q0Rla3\":[\"Add Tenant Access\"],\"q44uUq\":[\"Containers Partially Emptied\"],\"q5sTNZ\":[\"<0>No Temp URL key configured. A temporary URL key must be set at the account or container level before temporary URLs can be generated. Contact your administrator to configure <1>X-Account-Meta-Temp-URL-Key or <2>X-Container-Meta-Temp-URL-Key.\"],\"q6K46F\":[\"Key already exists\"],\"q88/6A\":[\"Failed to Create Folder\"],\"qAkkjP\":[\"Max object name length\"],\"qEDO1j\":[\"This is a <0>dynamic large object. Only the manifest will be deleted — its segment objects (stored under the manifest prefix) are <1>not automatically removed and must be deleted separately.\"],\"qFDA8L\":[\"Reject access to image\"],\"qJb6G2\":[\"Try Again\"],\"qQ1QBh\":[\"Hardware Specifications\"],\"qST5TS\":[\"Error - Image Details\"],\"qUlxA+\":[\"Folder \\\"\",[\"folderName\"],\"\\\" was permanently deleted.\"],\"qaAo9Y\":[\"Server error occurred while creating the flavor. Please try again later.\"],\"qh5W8q\":[\"Remove Policy\"],\"qhDo93\":[\"Common name is required.\"],\"qs+BrU\":[\"You don't have permission to remove tenant access from this flavor.\"],\"qtoOYG\":[\"No limit\"],\"quU9wK\":[\"Failed to deactivate \",[\"failedCount\"],\" of \",[\"totalCount\"],\" image(s). Some images may already be deactivated or in an invalid state.\"],\"qvF2D8\":[\"There are no images available for this project with the current filters applied. Try adjusting your filter criteria or create a new image.\"],\"qxxo7y\":[\"No policies match your search\"],\"qyNaF7\":[\"Enter a timestamp like \\\"YYYY-MM-DD HH:mm:ss\\\" to schedule automatic deletion\"],\"qzIZOL\":[\"Invalid file format. Supported formats: \",[\"supportedFileFormats\"]],\"qzhUb9\":[\"Showing first \",[\"maxOptions\"],\" of \",[\"totalCount\"],\" — refine your search to narrow results\"],\"r5SQFW\":[\"Container name must be \",[\"maxContainerNameLength\"],\" characters or fewer\"],\"r9Aac8\":[\"Ephemeral Disk\"],\"rAtQcX\":[\"You can rename the object by changing the name here.\"],\"rD9yV1\":[\"Images to deactivate:\"],\"rIe0oV\":[\"Failed to add tenant access to flavor. Please try again.\"],\"rIi6x4\":[\"The flavor could not be found. It may have already been deleted.\"],\"rJe6vw\":[\"7 days\"],\"rbuO5A\":[\"This security group is already shared with the specified project.\"],\"rcBt6T\":[\"Failed to create credential: \",[\"errorMessage\"]],\"rdUucN\":[\"Preview\"],\"rhaNn7\":[\"Loading containers...\"],\"riR9oD\":[\"Note: for <0>static and dynamic large objects only the manifests are deleted — their segments outside this folder prefix are not affected.\"],\"rlgAtt\":[\"The object will be moved to this path. Navigate folders above to change the destination.\"],\"rp0Bd0\":[\"Compute\"],\"rrjuul\":[\"For more details, have a look at the <0>documentation.\"],\"rvT6l1\":[\"Services Overview\"],\"rvXsSb\":[\"Tenant access for \\\"\",[\"tenantIdToRemove\"],\"\\\" has been removed successfully.\"],\"rwBVXS\":[\"Images to be deleted (\",[\"deletableCount\"],\")\"],\"ryf/ee\":[\"Images Activated\"],\"ryxYVo\":[\"Images to be deactivated (\",[\"activeCount\"],\")\"],\"s/s1lz\":[\"Any user can perform a HEAD or GET operation on the container provided the user also has read access on objects. No token is required.\"],\"s2ubkU\":[\"Flavor ID\"],\"s4Vnq2\":[\"Emptying...\"],\"sNVNmf\":[\"MAC Address\"],\"sPFHpI\":[\"Disk\"],\"sSNyf3\":[\"Welcome to <0>Aurora Dashboard, your next-generation cloud management solution. We are dedicated to simplifying how you interact with and manage your cloud infrastructure. Designed with efficiency, scalability, and usability at its core, Aurora empowers you to streamline operations and unlock the full potential of your cloud resources.\"],\"sWBLli\":[\"Add Property\"],\"sXd+qS\":[\"Properties of \\\"\",[\"objectName\"],\"\\\" were successfully updated.\"],\"sa4CV6\":[\"All users from project \",[\"projectId\"]],\"shKIZu\":[\"Images to be activated (\",[\"deactivatedCount\"],\")\"],\"sheDTJ\":[\"Please note: for <0>dynamic and <1>static large objects only the manifests are deleted. The related segments are not deleted.\"],\"sihD20\":[\"Loading images...\"],\"sjMCOP\":[\"Last Modified\"],\"slWh5C\":[\"Associate Floating IP \",[\"floating_ip_address\"],\" with Port\"],\"sxbP3b\":[\"Object Count\"],\"t/YqKh\":[\"Remove\"],\"t0X9+8\":[\"Container Name\"],\"t1POAD\":[\"No custom metadata properties found. Click \\\"Add Property\\\" to create one.\"],\"t1fq6V\":[\"Server returned unexpected data format.\"],\"t7ff15\":[\"valid token required: false\"],\"t95VRV\":[\"About Aurora Dashboard\"],\"tASa/P\":[\"Server error occurred while deleting the flavor. Please try again later.\"],\"tIrNgH\":[\"Server error occurred while fetching extra specs. Please try again later.\"],\"tLerHy\":[\"Ephemeral Disk must be an integer ≥ 0.\"],\"tM5SEI\":[\"ACLs for container \\\"\",[\"containerName\"],\"\\\" were successfully updated.\"],\"tOkmLM\":[\"Failed to Copy Object\"],\"tV/Ozb\":[\"Port Range\"],\"tVSmFT\":[\"Loading more...\"],\"tX5yOZ\":[\"New Folder\"],\"tasfos\":[\"remove\"],\"tbwGSx\":[\"Minimum Disk (GB)\"],\"tejJLY\":[\"Associating Floating IP...\"],\"tfAKBU\":[\"Could not upload \\\"\",[\"objectName\"],\"\\\": \",[\"errorMessage\"]],\"tfDRzk\":[\"Save\"],\"tfxu04\":[\"Remove access for \",[\"tenantId\"]],\"thHAVL\":[\"Accepted Images\"],\"tiflqy\":[\"Unable to Re-activate Image\"],\"tlfxPP\":[\"Could not copy \\\"\",[\"objectName\"],\"\\\": \",[\"errorMessage\"]],\"tmpGvt\":[\"production, linux\"],\"u+VWhB\":[\"Copied to clipboard!\"],\"u2xIeO\":[\"Failed to update ACLs: \",[\"errorMessage\"]],\"u5HztT\":[\"RX/TX Factor\"],\"u77/s4\":[\"Floating IPs\"],\"u7En0V\":[\"Add Metadata\"],\"uAI0yI\":[\"Delete object:\"],\"uAQUqI\":[\"Status\"],\"uLtFAr\":[\"Could not update container \\\"\",[\"containerName\"],\"\\\": \",[\"errorMessage\"]],\"uSdnuQ\":[\"VCPUs must be an integer ≥ 1.\"],\"ujK/QN\":[\"Loading objects...\"],\"uly9ET\":[\"Rule Details:\"],\"up0ZSW\":[\"Fingerprint\"],\"uuKb0T\":[\"Description must be less than 65535 characters.\"],\"v0hPHE\":[\"Show Details\"],\"v3djpU\":[\"Move/Rename\"],\"v9Dn8m\":[\"Aurora Dashboard is more than just a tool—it's your partner in navigating the cloud. Whether you're a small startup or a global enterprise, Aurora provides the flexibility, power, and simplicity you need to achieve your goals.\"],\"vBUQNE\":[\"The extra spec could not be found. It may have already been deleted.\"],\"vEkTR9\":[\"Quota\"],\"vH2C/2\":[\"Swap\"],\"vR4HmN\":[\"Loading Instances...\"],\"vTh35P\":[\"Create Container\"],\"vXmL4D\":[\"Drop your image file here\"],\"vZUKSz\":[\"Detach Floating IP \",[\"floating_ip_address\"]],\"vbajgL\":[\"Public Flavor\"],\"vcQSZh\":[\"This folder is empty — use New Folder to create one.\"],\"vcXmqy\":[\"Network Overview\"],\"vcvCXq\":[\"Error - Flavor Details\"],\"vg84cD\":[[\"allCount\"],\" items\"],\"vmRPFm\":[\"Share Security Group\"],\"vmYyLY\":[\"Remote IP Prefix\"],\"vp5vfW\":[\"1 hour\"],\"vpt8cE\":[\"Generate URL\"],\"vrPCbw\":[\"Image ID\"],\"w3bAcf\":[\"This action is permanent. The address will be removed from your project and returned to the public pool. This action cannot be undone.\"],\"w9+8d7\":[\"Remove tenant access\"],\"wEfZld\":[\"Create New Flavor\"],\"wFaT8w\":[\"Failed to Empty Containers\"],\"wMHvYH\":[\"Value\"],\"wPrtGF\":[\"Enter key\"],\"wTg+FY\":[\"Max file size\"],\"wXxPjv\":[\"S3 Object Storage — Setup Required\"],\"wa1Bcq\":[\"Enter tenant ID\"],\"wbqM4L\":[[\"customMinutes\"],\" minutes\"],\"wcUecy\":[\"You don't have permission to view extra specs for this flavor.\"],\"wdUvGT\":[\"Creating Certificate Authority...\"],\"we28Pq\":[\"Hide ACLs Preview\"],\"wlQNTg\":[\"Members\"],\"wlUDbB\":[\"Last updated: \",[\"formattedDate\"]],\"wrXcuy\":[\"Object Name\"],\"wrk/xj\":[\"Image Details\"],\"wyIOMP\":[\"Image name is required\"],\"wzqqS+\":[\"Key Features\"],\"x/XQrD\":[\"Any file type\"],\"x1bK0h\":[\"There are no containers available with the current search criteria. Try adjusting your search term.\"],\"x3T4pq\":[\"The container metadata reports objects but none were listed. This may be a temporary synchronization delay — please wait a moment and try again.\"],\"x5l/TK\":[\"Already active (will be skipped):\"],\"x9AdZ8\":[\"property_key\"],\"xNG/3n\":[\"Floating IP Address\"],\"xNZKYy\":[\"Failed to delete \",[\"failedCount\"],\" of \",[\"totalCount\"],\" image(s). Some images may be protected or in use.\"],\"xqhyRT\":[\"Object Uploaded\"],\"xw2UtT\":[\"Create New Image\"],\"y+KBOY\":[\"e.g., production, linux, ubuntu\"],\"y02Bu1\":[\"Container:\"],\"y0u86k\":[\"The requested flavor could not be found. It may have been deleted or you may not have access to it.\"],\"y1GYnY\":[\"Could not move \\\"\",[\"objectName\"],\"\\\": \",[\"errorMessage\"]],\"yPWFWy\":[\"ICMP Type\"],\"yTtJTy\":[\"Edit Image Metadata\"],\"yYxB17\":[\"Clear all\"],\"ylfbpz\":[\"Extra spec key is required and cannot be empty.\"],\"yp0UjB\":[\"Ethertype\"],\"yqPflB\":[\"... and \",[\"hiddenCount\"],\" more\"],\"yu9G3x\":[\"Edit Security Group\"],\"ywe1H/\":[[\"totalCount\",\"plural\",{\"one\":[[\"totalCount\"],\" container\"],\"other\":[[\"totalCount\"],\" containers\"]}]],\"yz7wBu\":[\"Close\"],\"z+zpLP\":[\"valid token required: true\"],\"z1JceR\":[\"Back to Floating IPs\"],\"z45o5B\":[\"Object count\"],\"z9NAjZ\":[\"Object Deleted\"],\"zCD96i\":[\"You are not authorized to view flavor details. Please log in again.\"],\"zDS0JC\":[\"Name must be 2-50 characters long.\"],\"zWb/Nn\":[\"Max header size\"],\"zc5dcw\":[\"Login failed. Please check your credentials and try again.\"],\"zga9sT\":[\"OK\"],\"zhM8FP\":[\"Grant access to a user from a different project.\"],\"zm7+/D\":[\"You are about to deactivate <0>\",[\"activeCount\"],\" image(s). Deactivated images cannot be used to launch new instances.\"],\"zwBp5t\":[\"Private\"]}")as Messages; \ No newline at end of file +/*eslint-disable*/import type{Messages}from"@lingui/core";export const messages=JSON.parse("{\"+0B+ue\":[\"Projects\"],\"+9CXS9\":[\"Deactivate Images\"],\"+Jcye3\":[\"Key Name\"],\"+Lt5cp\":[\"You are not authorized to add tenant access. Please log in again.\"],\"+Nhol2\":[\"Certificate not found\"],\"+NwLgN\":[\"Activating this image will allow it to be used to launch new instances again.\"],\"+Nx1wc\":[\"Failed to load Floating IPs\"],\"+OEi73\":[\"Object Storage (Swift)\"],\"+nQTmZ\":[\"This tenant does not have access to the flavor.\"],\"+p6nHr\":[\"Failed to load object metadata: \",[\"metadataErrorMessage\"]],\"+zy2Nq\":[\"Type\"],\"/1MfrG\":[\"Could not download \\\"\",[\"objectName\"],\"\\\": \",[\"errorMessage\"]],\"/2a/eI\":[\"Loading Flavor...\"],\"/9Squ9\":[\"You don't have permission to view this flavor's details.\"],\"/BZLRP\":[\"To confirm this action, type the word <0>“detach” in the field below.\"],\"/EcdUM\":[\"Your action is required\"],\"/HgF9q\":[\"Sort by\"],\"/InK0O\":[\"Total size\"],\"/LqWNN\":[\"Could not delete \\\"\",[\"objectName\"],\"\\\": \",[\"errorMessage\"]],\"/NeNjH\":[\"Container \\\"\",[\"containerName\"],\"\\\" properties were successfully updated.\"],\"/Nmxy/\":[\"No key pairs available.\"],\"/QIkBY\":[\"<0>Secure & Reliable: Your data and operations are safeguarded with enterprise-grade security and robust reliability.\"],\"/Qox3b\":[\"A folder with this name already exists\"],\"/Z2leb\":[\"No containers found.\"],\"/Z5n1b\":[\"Create folder below:\"],\"/bUiYk\":[\"Router ID\"],\"/eFtWI\":[\"RBAC Policies\"],\"/pOQrn\":[\"This action is irreversible. Deleting a bucket permanently removes it and cannot be undone. The bucket must be empty before deletion.\"],\"/xnbdQ\":[\"The specified user has access. A token for the user (scoped to any project) must be included in the request.\"],\"01/uUD\":[\"Keep segments (delete manifest only)\"],\"07WXfc\":[\"Server returned unexpected data format for extra specs.\"],\"0BSSYj\":[\"Server error occurred while removing tenant access. Please try again later.\"],\"0Gd0NU\":[\"Shared\"],\"0P2gFy\":[\"The page you are looking for does not exist.\"],\"0WsqO0\":[\"Containers Emptied\"],\"0cVgUw\":[\"Filter by\"],\"0eY8Mz\":[\"There are no Floating IPs available for this project. Floating IPs allow you to map public IP addresses to instances.\"],\"0kCt7e\":[\"The flavor data provided is invalid. Please check your input.\"],\"0kc0zi\":[\"Server error occurred while deleting the extra spec. Please try again later.\"],\"0o0OhW\":[\"No objects found.\"],\"0p+s6m\":[\"Type: \",[\"typeValue\"],\", Code: \",[\"codeValue\"]],\"0u9jhd\":[\"Detaching this Floating IP will remove its association with the current port. The instance will no longer be reachable through this address.\"],\"16085O\":[\"IP Version\"],\"1H2g6v\":[\"Moving object...\"],\"1NFtQz\":[\"Failed to Delete Bucket\"],\"1NS3nd\":[[\"emptiedCount\",\"plural\",{\"one\":[\"#\",\" container\"],\"other\":[\"#\",\" containers\"]}],\" successfully emptied. \",[\"totalDeleted\",\"plural\",{\"one\":[\"#\",\" object\"],\"other\":[\"#\",\" objects\"]}],\" deleted in total.\"],\"1RwosK\":[\"Target project ID is required\"],\"1UzENP\":[\"No\"],\"1VDqZj\":[\"<0>Future-Ready: Aurora is designed to evolve with the latest trends in cloud technology, ensuring your solution is always cutting-edge.\"],\"1iQtS2\":[\"Showing first \",[\"actualObjectCount\"],\" of \",[\"total\"],\" objects\"],\"1iUuTT\":[\"Your session has expired. Please log in again.\"],\"1ojTVo\":[\"Select a DNS domain.\"],\"1pGUZa\":[\"Session expires in\"],\"1pdLQw\":[\"Image not found\"],\"1rLu3+\":[\"Could not empty container \\\"\",[\"containerName\"],\"\\\": \",[\"errorMessage\"]],\"1rPB1p\":[\"The flavor or tenant could not be found. Please verify they exist.\"],\"1t/NnN\":[\"Reject\"],\"1zZ1IK\":[\"Hi\"],\"20E+79\":[\"You need to login to access this page.\"],\"20Kpaw\":[\"Flavor \\\"\",[\"flavorName\"],\"\\\" has been successfully deleted.\"],\"20axE5\":[\"Shared by Project\"],\"23wBCX\":[\"Public Read Access\"],\"2G6hLq\":[\"Delete \",[\"specKey\"]],\"2Inn83\":[\"Bulk upload of archive files\"],\"2TtIL2\":[\"Stored as X-Object-Meta-* headers. Keys are case-insensitive.\"],\"2cJIlz\":[\"Floating Network ID\"],\"2d/OiW\":[\"Enter your username\"],\"2dnZwV\":[\"Could not delete folder \\\"\",[\"folderName\"],\"\\\": \",[\"errorMessage\"]],\"2gH+i8\":[\"You are not authorized to delete flavors. Please log in again.\"],\"2lq0gq\":[\"<0>Properties of <1>\",[\"displayName\"],\"\"],\"2mbisJ\":[\"Metadata \\\"\",[\"trimmedKey\"],\"\\\" has been added successfully.\"],\"2pnrGl\":[\"Expected format: YYYY-MM-DD HH:MM:SS\"],\"2q/Q7x\":[\"Visibility\"],\"2ysnjX\":[\"<0>Enhanced Productivity: By reducing operational complexity, Aurora helps your team focus on what truly matters—innovating and driving business success.\"],\"2zceEg\":[\"This action cannot be undone. The image will be permanently deleted.\"],\"33F2A+\":[\"Type container name to confirm\"],\"3AUpb4\":[\"Delete All (\",[\"selectedCount\"],\")\"],\"3Qn0me\":[\"Add Member\"],\"3dBmvU\":[\"The container cannot be deleted as it contains objects. Empty the container first.\"],\"3n+vCm\":[\"Custom duration (minutes)\"],\"3nWqQW\":[\"You are not authorized to view extra specs. Please log in again.\"],\"3nh/7E\":[\"If checked, this flavor will be available to all tenants. If unchecked, access must be explicitly granted to specific tenants.\"],\"3oChIh\":[\"<0>Unified Cloud Management: Consolidates all your cloud assets into one intuitive interface.\"],\"3oc18/\":[\"Private flavors could not be loaded. You may be seeing an incomplete list.\"],\"3q1GLx\":[\"Pending file upload...\"],\"3x7Sws\":[\"Loading Security Group Details...\"],\"4+2wZO\":[\"Back to Certificate Authorities Details page\"],\"47eI0x\":[\"Description must be at least 1 character.\"],\"48bMai\":[\"Bucket name is required\"],\"4EZrJN\":[\"Rules\"],\"4O2AH3\":[\"Member \\\"\",[\"memberIdToRemove\"],\"\\\" has been removed successfully.\"],\"4fh0Wj\":[\"Boot size\"],\"4fvDRe\":[\"Images to activate:\"],\"4fvcmm\":[\"Object will be uploaded as: <0>\",[\"selectedObjectName\"],\"\"],\"4h3Eyf\":[\"\\\"\",[\"objectName\"],\"\\\" was successfully uploaded.\"],\"4kjaAc\":[\"No server groups available.\"],\"4mbrAq\":[\"1 minute\"],\"4opp4r\":[\"Security Groups\"],\"4pOfUd\":[\"Our Mission\"],\"4t33sh\":[\"Failed to Update Object\"],\"4uXhtt\":[\"CIDR\"],\"4utWB4\":[\"Server Role:\"],\"5/wyf8\":[\"Enter a floating IP\"],\"50Piuj\":[\"This bucket contains \",[\"actualObjectCount\"],\" \",[\"actualObjectCount\",\"plural\",{\"one\":[\"object\"],\"other\":[\"objects\"]}],\" and cannot be deleted. Delete all objects first.\"],\"56IxdF\":[\"Failed to load container objects: \",[\"errorMessage\"]],\"5BLR6Q\":[\"IPv4\"],\"5JDSvn\":[\"Max meta value length\"],\"5M4Te3\":[\"DNS\"],\"5MF8U2\":[\"Failed to Update Container\"],\"5Ml1iQ\":[[\"allContainersCount\"],\" buckets\"],\"5Okch2\":[\"Empty:\"],\"5Yrl6N\":[\"Loading Server Groups...\"],\"5aNQ3F\":[\"\\\"\",[\"objectName\"],\"\\\" was successfully copied to \",[\"destination\"],\".\"],\"5g7owI\":[\"Updating Floating IP...\"],\"5y3O+A\":[\"Deactivate\"],\"6+7EwD\":[\"Serve objects as index when file name is:\"],\"6+OdGi\":[\"Protocol\"],\"6/xipy\":[\"Container Format\"],\"644xgx\":[\"Protected\"],\"66YUKF\":[\"Failed to Empty Bucket\"],\"6BDqha\":[\"Limits\"],\"6CDYXS\":[\"Static website serving\"],\"6GBt0m\":[\"Metadata\"],\"6H/Lg1\":[\"This is a public image. All users have access to it. Explicit sharing is not needed.\"],\"6KRclz\":[\"Folder Created\"],\"6Kjltl\":[\"Access Control for container:\"],\"6OopEX\":[\"Container Emptied\"],\"6Rnrsz\":[\"Manage Access - \",[\"flavorName\"]],\"6V3Ea3\":[\"Copied\"],\"6X/9Di\":[\"\\\"\",[\"objectName\"],\"\\\" was successfully moved to \",[\"destination\"],\".\"],\"6YtxFj\":[\"Name\"],\"6fuDFZ\":[\"Empty All Completed with Errors\"],\"6jAi8c\":[\"Range\"],\"6luZQA\":[\"Object Moved\"],\"6oolxV\":[\"This extra spec keys already exist. Please use different keys.\"],\"6qzsuS\":[\"Write ACLs\"],\"6sxz+g\":[\"Port Name\"],\"6w+VnM\":[\"Container Created\"],\"6z9W13\":[\"Restart\"],\"76RKuS\":[\"ICMP Code\"],\"78+riR\":[\"You are not authorized to remove tenant access. Please log in again.\"],\"7AfIPZ\":[\"Floating Network\"],\"7BpykL\":[\"Failed to create extra specs. Please try again.\"],\"7L01XJ\":[\"Actions\"],\"7NC3vm\":[\"Subnet\"],\"7NSdfG\":[\"Emptying container \",[\"progressCurrent\"],\" of \",[\"progressTotal\"],\", please wait...\"],\"7Q24LN\":[\"Policy\"],\"7RBR/D\":[\"Empty Buckets\"],\"7T1fHv\":[\"Failed to remove member \\\"\",[\"memberIdToRemove\"],\"\\\"\"],\"7UlHhT\":[\"Metadata \\\"\",[\"keyToDelete\"],\"\\\" has been deleted successfully.\"],\"7XQ3QJ\":[\"Denied referrer: \",[\"host\"]],\"7ZnTL8\":[\"Failed to update object: \",[\"mutationErrorMessage\"]],\"7a4DvD\":[\"No servers available.\"],\"7d1a0d\":[\"Public\"],\"7flw0l\":[\"Tenant access for \\\"\",[\"trimmedTenantId\"],\"\\\" has been added successfully.\"],\"7huC4O\":[\"There are no Certificates available for this Certificate Authority.\"],\"7sMeHQ\":[\"Key\"],\"88kg0+\":[\"Created At\"],\"8AriEH\":[\"Image instance \\\"\",[\"imageName\"],\"\\\" has been created\"],\"8HrqL8\":[\"<0>Are you sure? All \",[\"bucketCount\"],\" \",[\"bucketCount\",\"plural\",{\"one\":[\"object\"],\"other\":[\"objects\"]}],\" in bucket \\\"\",[\"bucketName\"],\"\\\" will be permanently deleted. This action cannot be undone.\"],\"8S2nDL\":[\"No PCAs found\"],\"8TSI9h\":[\"Deactivating this image will prevent it from being used to launch new instances. Existing instances will not be affected.\"],\"8Tg/JR\":[\"Custom\"],\"8ZOb7O\":[[\"numberDeleted\"],\" object was permanently deleted.\"],\"8ZsakT\":[\"Password\"],\"8c3/77\":[\"Max meta name length\"],\"8erw15\":[\"Bucket \\\"\",[\"bucketName\"],\"\\\" was already empty.\"],\"8jLXs3\":[\"Versioned writes\"],\"8s0tOH\":[\"You don't have permission to add tenant access to this flavor.\"],\"8t1+HU\":[\"Deactivated \",[\"successCount\"],\" image(s), but \",[\"failedCount\"],\" image(s) could not be deactivated.\"],\"8uPTwT\":[[\"filteredCount\",\"plural\",{\"one\":[[\"filteredCount\"],\" of \",[\"totalCount\"],\" container\"],\"other\":[[\"filteredCount\"],\" of \",[\"totalCount\"],\" containers\"]}]],\"8wdCNd\":[\"tcp, udp, icmp, or protocol number\"],\"8zAn1f\":[\"Failed to delete flavor. Please try again.\"],\"98Fs4G\":[\"Creating image...\"],\"9J93Xr\":[\"Container name cannot contain slashes\"],\"9SX0bO\":[\"The image \\\"\",[\"imageName\"],\"\\\" could not be updated: \"],\"9X8lAk\":[\"Allocate\"],\"9doWrf\":[\"Failed to add member\"],\"9dsDHD\":[\"The image \\\"\",[\"imageId\"],\"\\\" could not be re-activated: \",[\"message\"]],\"9iz2XW\":[\"Unable to Update Image\"],\"9njIiV\":[\"Failed to Activate Images\"],\"9rz81C\":[\"Device ID\"],\"9v5VLp\":[\"No custom properties defined\"],\"9vSW3U\":[\"Delete Recursively\"],\"9x6EkK\":[\"This is a public flavor. All tenants have access to it.\"],\"A7CVME\":[\"Select disk format first\"],\"AB4Tnl\":[\"Please select a file to upload\"],\"AGXLLY\":[\"Unable to Upload Image File\"],\"AJRhSM\":[\"Root Disk must be an integer ≥ 0.\"],\"AN0DBJ\":[\"Press Enter to add\"],\"ASJMIw\":[\"S3 bucket names must be 3-63 characters long and contain only lowercase letters, numbers, periods, and hyphens. They must start and end with a letter or number, and be globally unique within the cluster.\"],\"AX9Juz\":[\"ID must only contain alphanumeric characters, hyphens, underscores, and dots.\"],\"AZyHwC\":[\"Must be a valid IPv4 or IPv6 address (for example: 172.24.4.228 or 2001:db8::1).\"],\"Ac6dy9\":[\"Type name\"],\"AdtLNV\":[\"Ensure ACL entries are valid — correct project IDs, user IDs, and formats are your responsibility. Invalid entries may silently grant or deny unintended access.\"],\"AeXO77\":[\"Account\"],\"Afh/Lb\":[\"Select destination folder\"],\"AlbUVn\":[\"<0>Optimized Scalability: Built for businesses of all sizes, Aurora grows with you, supporting simple environments and intricate multi-cloud setups alike.\"],\"Alx2/L\":[\"Open in new tab\"],\"AuQtzx\":[\"Must be a non-negative integer\"],\"AxZkIr\":[\"Disk (GiB)\"],\"B2Czeb\":[\"Min. RAM\"],\"B2SpR8\":[\"Bucket name must not start with reserved prefix \\\"\",[\"prefix\"],\"\\\"\"],\"B2i9cQ\":[\"Objects to be deleted (\",[\"totalCount\"],\")\"],\"B3toQF\":[\"Objects\"],\"B4Jzm7\":[\"Ceph\"],\"BCJPTn\":[\"Grant access to all users from that project.\"],\"BCXapL\":[\"Failed to load container properties: \",[\"errorMessage\"]],\"BJt+PJ\":[\"Failed to Delete Container\"],\"BMTd81\":[\"This action cannot be undone. The target project will lose access to this security group immediately.\"],\"BMogtG\":[\"Issue End Entity Certificate\"],\"BOQYRn\":[\"Loading Key Pairs...\"],\"BOoOLQ\":[\"All Buckets Emptied\"],\"BP4Fwj\":[\"Error Loading Objects: \",[\"errorMessage\"]],\"BSaBkZ\":[\"Objects — \",[\"containerName\"]],\"BTsbBe\":[\"Bucket Name\"],\"BYH/2L\":[\"Unable to Deactivate Image\"],\"BZpsYm\":[\"Failed to load containers: \",[\"errorMessage\"]],\"BgMp/T\":[\"Invalid format combination for selected disk format\"],\"Blsc/x\":[\"Delete Certificate Authority\"],\"BoIAP6\":[\"The ID of the network associated with the floating IP.\"],\"BoPocW\":[\"MD5 checksum\"],\"BrrIs8\":[\"Storage\"],\"CA8ZeT\":[\"Image \\\"\",[\"imageName\"],\"\\\" visibility updated to \",[\"visibility\"]],\"CBFSfX\":[\"Please fix the validation errors below.\"],\"CFMxC8\":[\"Images Deleted\"],\"CMVP7y\":[\"This action cannot be undone. The rule will be permanently deleted.\"],\"CfKRC1\":[\"Empty Bucket\"],\"CgZxr7\":[\"Min RAM (MB)\"],\"ChOuUj\":[\"Floating IP not found\"],\"Cj2Gtd\":[\"Size\"],\"ClGcRq\":[\"Containers\"],\"CongmL\":[\"Could not delete bucket \\\"\",[\"bucketName\"],\"\\\": \",[\"errorMessage\"]],\"CtiFDz\":[\"This will permanently delete all objects from \",[\"totalCount\"],\" selected \",[\"totalCount\",\"plural\",{\"one\":[\"bucket\"],\"other\":[\"buckets\"]}],\". This action cannot be undone.\"],\"Cu6xuZ\":[\"This is a <0>dynamic large object (DLO) manifest. Metadata changes apply to the manifest only — segment objects are not affected.\"],\"CunRry\":[\"Invalid project ID format. Must be 32 hexadecimal characters (e.g., b90f9c4bc76140e18540b2cec1299e2a) or UUID format (e.g., 12345678-1234-1234-1234-123456789abc)\"],\"Cxgv2U\":[\"Min. Disk\"],\"D/8vkD\":[\"It will appear in your image list.\"],\"D3IRXw\":[\"Detaching Floating IP...\"],\"D7qT9F\":[\"Why Choose Aurora?\"],\"DDRhQm\":[\"Your session has expired.\"],\"DHrCY6\":[\"Common name\"],\"DJT9tB\":[\"Account quotas\"],\"DKkOPx\":[\"Extra Specs\"],\"DNVql8\":[\"Full lifecycle management of Floating IPs, including attachment, port association/disassociation, DNS settings, and deletion\"],\"DcMIiu\":[\"Could not update ACLs for container \\\"\",[\"containerName\"],\"\\\": \",[\"errorMessage\"]],\"Df0YHr\":[\"Update Security Group\"],\"Dh1qvV\":[\"You are about to delete \",[\"deletableCount\"],\" image(s). This action cannot be undone.\"],\"Dia2Ue\":[\"There are no RBAC policies for this security group\"],\"Do5/uH\":[\"The flavor or tenant could not be found. It may have already been removed.\"],\"Dqnh7K\":[\"Specific referrer: \",[\"host\"]],\"Dt5W9T\":[\"Remove RBAC Policy\"],\"DvB4XF\":[\"Drop your file here\"],\"E/QGRL\":[\"Disabled\"],\"E4QYe7\":[\"Suggested Images\"],\"E6nRW7\":[\"Copy URL\"],\"EF2EU9\":[\"Deleting...\"],\"EPMHs9\":[\"You don't have permission to delete flavors in this project.\"],\"EQnVgi\":[\"Flavor service is not available for this project.\"],\"EdQY6l\":[\"None\"],\"Ef7StM\":[\"Unknown\"],\"Enpdmy\":[\"Type <0>remove to confirm:\"],\"EoKe5U\":[\"Domain\"],\"Eq5PsT\":[\"Type \\\"detach\\\" to confirm\"],\"EqSPkP\":[\"Loading Flavors...\"],\"Erlvqg\":[\"Object name cannot have leading or trailing whitespace\"],\"ExLULX\":[\"Image Name\"],\"EztMB8\":[\"Failed to fetch flavors from server.\"],\"F02e8I\":[\"No custom metadata. Click \\\"Add Property\\\" to create one.\"],\"F6YIQe\":[\"Efficient bulk deletion\"],\"FKL6Jv\":[\"e.g. .r:*,.rlistings\"],\"FNcMGM\":[\"Creation Date\"],\"FOcBn3\":[\"Detach\"],\"FPsvA8\":[\"Got it!\"],\"FQBaXG\":[\"Activate\"],\"FRtmJJ\":[\"Storage container not found\"],\"FSbpS7\":[\"CPU\"],\"FjONW3\":[\"Error Loading Flavor\"],\"FjPnAE\":[\"Error loading security group\"],\"Flugry\":[[\"progressPct\"],\"%\"],\"FrLdVI\":[\"Bucket to empty:\"],\"FwSyEp\":[\"The specified project does not exist or you don't have permission to share with it.\"],\"Fzrzfe\":[\"Folder name is required\"],\"G6AP+o\":[\"Shared:\"],\"GDx4dP\":[\"Manage your Certificate\"],\"GEgjm+\":[\"Loading Objects...\"],\"GPuCEo\":[\"Leave empty for all types\"],\"GSIPwA\":[\"Temporary URL\"],\"GbKqnI\":[\"Activated \",[\"successCount\"],\" image(s), but \",[\"failedCount\"],\" image(s) could not be activated.\"],\"Gfx1qQ\":[\"Unable to Load Content\"],\"GxkJXS\":[\"Uploading...\"],\"Gyd3No\":[\"No specific tenant access configured for this private flavor. Click \\\"Add Tenant Access\\\" to grant access.\"],\"H+a5j6\":[\"Release\"],\"H4Qwmp\":[\"No objects match your search. Try adjusting your search term.\"],\"H7u085\":[\"No projects have access to this image yet. Click \\\"Add Project Access\\\" to grant access.\"],\"HAkrpK\":[\"At Aurora, our mission is to provide a centralized platform that unifies cloud management. We aim to simplify the complexities of provisioning, configuring, and scaling resources across diverse cloud environments while enabling seamless growth for your business.\"],\"HBpi4q\":[\"Loading Images...\"],\"HG0uMz\":[\"Back to Certificate Authorities\"],\"HM56Bx\":[\"Creating...\"],\"HNlEFZ\":[\"delete\"],\"HQH8HM\":[\"Could not update \\\"\",[\"objectName\"],\"\\\": \",[\"errorMessage\"]],\"HVdrr1\":[\"ANY referrer\"],\"HiDAFk\":[\"Create Bucket\"],\"HivZR9\":[\"Create Credential\"],\"Hivb/4\":[\"Server is experiencing issues. Please try again later.\"],\"Hiw1Ha\":[\"No containers found\"],\"HlwgQN\":[\"Object \\\"\",[\"objectName\"],\"\\\" was permanently deleted.\"],\"HuA8iQ\":[\"Allocating Floating IP...\"],\"HxTYrE\":[\"The flavor could not be found. It may have been deleted.\"],\"I5kZVK\":[\"Remote Source\"],\"INUP6f\":[\"<0>Effortless Resource Provisioning: Quickly provision, configure, and deploy resources like servers, networks, and volumes with just a few clicks.\"],\"IOkHLC\":[\"Failed to copy object: \",[\"errorMessage\"]],\"IQSLN+\":[\"Error loading Certificate Authority\"],\"IQldU4\":[\"Bucket name must contain only lowercase letters, numbers, periods, and hyphens\"],\"IUwGEM\":[\"Save Changes\"],\"IWF68U\":[\"Storage Overview\"],\"IZ6Mh2\":[\"Enter your domain\"],\"IbYr/u\":[\"Content type\"],\"Io2Dvq\":[\"Certificate Authority not found\"],\"Ioblgz\":[\"This action is permanent. All objects in the container will be deleted and this cannot be undone.\"],\"IwlPLb\":[\"Delete Bucket\"],\"J4DKSM\":[\"Container format is required\"],\"J6EOll\":[\"Move/Rename object:\"],\"J7+bZb\":[\"Folder Deleted\"],\"J9QcnV\":[\"Successfully activated \",[\"successCount\"],\" of \",[\"totalCount\"],\" image(s)\"],\"J9cmxx\":[\"Failed to update visibility to \",[\"newVisibility\"]],\"JB0bhm\":[\"Get Involved\"],\"JNGYAW\":[\"Container name is required\"],\"JPWqr2\":[\"Bucket \\\"\",[\"bucketName\"],\"\\\" was successfully emptied. \",[\"deletedCount\"],\" objects deleted.\"],\"JT3I1g\":[\"Delete Flavor\"],\"JeRXll\":[\"This key is reserved and managed separately\"],\"JfWCsP\":[\"Partial Deactivation Success\"],\"Jh4rAZ\":[\"Error loading image\"],\"Jim5X9\":[\"Stateful\"],\"JoECY1\":[\"The extra spec data provided is invalid. Please check your input.\"],\"JofLr3\":[\"Error Loading Buckets: \",[\"errorMessage\"]],\"JpZn1L\":[\"Already deactivated (will be skipped)\"],\"JrmKyf\":[\"Failed: \",[\"errorDetails\"]],\"JtHgVz\":[\"Delete Images\"],\"K+e/0e\":[\"RAM (MiB)\"],\"K3bUTE\":[\"Minimum disk must be 0 or greater\"],\"K8Qnlj\":[\"Moving...\"],\"K9eC8x\":[\"This could be due to insufficient permissions or a temporary service issue. Please check your access rights or try refreshing the page.\"],\"KDw4GX\":[\"Try again\"],\"KJC+M7\":[\"Server error occurred while fetching flavor details. Please try again later.\"],\"KOpPMt\":[\"Total size quota\"],\"KSW/GC\":[\"There are no flavors available for this project with the current filters applied. Try adjusting your filter criteria or create a new flavor.\"],\"KZN4Lc\":[\"Delete All\"],\"Km4AGG\":[\"Creating security group...\"],\"KoQP4F\":[\"Server error occurred while adding tenant access. Please try again later.\"],\"KsIM0b\":[\"Boot RAM\"],\"KsnZ3m\":[\"Folder \\\"\",[\"folderName\"],\"\\\" was successfully created.\"],\"KzUd7m\":[\"new-folder-name\"],\"LI70tz\":[\"Error loading Certificate\"],\"LI8Z2I\":[\"Download \",[\"rowDisplayName\"]],\"LK0pQN\":[\"Disk format is required\"],\"LMdsuJ\":[\"Port (from)\"],\"LQQCas\":[\"Folder \\\"\",[\"folderName\"],\"\\\" and \",[\"deletedCount\"],\" object was permanently deleted.\"],\"Llcakz\":[\"Updated At\"],\"LqMb+g\":[\"To confirm this action, type the word <0>\\\"release\\\" in the field below.\"],\"LtI9AS\":[\"Owner\"],\"Lylr9Z\":[\"Object Copied\"],\"M3bysB\":[\"Bucket Created\"],\"M470oJ\":[\"The flavor could not be found or has no extra specs.\"],\"M5Epeo\":[\"Edit Image Details\"],\"M5RhXF\":[\"Removing...\"],\"M5rEN5\":[\"Session Expired\"],\"M9H+/G\":[\"projects\"],\"MEIAzV\":[\"Unnamed\"],\"MILoeL\":[\"Services\"],\"MJLqeY\":[\"Failed to check bucket contents: \",[\"errorMessage\"]],\"MJtNLd\":[\"Images to delete:\"],\"MOug+V\":[\"Enter a tag and press Enter or click Add\"],\"MRB7nI\":[\"Direction\"],\"MXoA/6\":[\"Upload Object\"],\"MXw7Fr\":[\"Server Name\"],\"MZGbkp\":[\"VCPUs\"],\"MbKJNP\":[\"You don't have permission to access flavor access information for this flavor.\"],\"MgZyuJ\":[\"You are about to activate <0>\",[\"deactivatedCount\"],\" image(s). Activated images will be available for launching new instances.\"],\"MmtQVF\":[\"Invalid value for public flavor setting.\"],\"Mt6sRo\":[\"You are not authorized to access flavor access information. Please log in again.\"],\"MtzSbv\":[\"Object name is required\"],\"MuKU9V\":[\"Failed to load objects: \",[\"errorMessage\"]],\"N2S1rs\":[\"Empty\"],\"N5I2RJ\":[\"Type \\\"release\\\" to confirm\"],\"N5vGcw\":[\"Enter your credentials to access your account\"],\"NH2fsP\":[\"Already deactivated (will be skipped):\"],\"NNpgo3\":[\"Bucket name\"],\"NOdFZR\":[\"Generating...\"],\"NQU1Nn\":[\"Copy container name\"],\"NRMm0E\":[\"This tenant already has access to the flavor.\"],\"NRP2uq\":[\"Share object:\"],\"NRVSdy\":[\"Member ID\"],\"NW1XEL\":[\"Emptying bucket \",[\"progressCurrent\"],\" of \",[\"progressTotal\"],\", please wait...\"],\"NW4PIb\":[\"Could not create folder \\\"\",[\"folderName\"],\"\\\": \",[\"errorMessage\"]],\"NZJhro\":[\"Object name cannot contain slashes\"],\"Nc7QKU\":[\"Fixed IP Address\"],\"NeUjqc\":[\"Enable file listing\"],\"NixRmA\":[\"Min Disk (GB)\"],\"NlcF/v\":[\"No flavor selected for deletion.\"],\"No++00\":[\"Bucket to delete:\"],\"NopYGU\":[\"Disk Format\"],\"Np28ib\":[\"or drag and drop\"],\"Nu4oKW\":[\"Description\"],\"Nvfd2b\":[\"Versioning is enabled\"],\"O80bQY\":[\"Loading object properties...\"],\"O8tK4v\":[\"Add rule\"],\"ONWvwQ\":[\"Upload\"],\"OR475H\":[\"Network\"],\"OSlLnz\":[\"Image Visibility\"],\"OYHzN1\":[\"Tags\"],\"OZImTR\":[\"Container listing limit\"],\"OaSktR\":[\"Device Owner\"],\"Oc8Aqv\":[\"Preview and Edit metadata\"],\"OlmKCg\":[\"A flavor with this ID or name already exists. Please use different values.\"],\"OvEjsP\":[\"Copying...\"],\"Ovofy+\":[\"Release Floating IP \",[\"floating_ip_address\"]],\"OxDN2m\":[\"Failed to create flavor. Please try again.\"],\"OxaeYj\":[\"We are building Aurora Dashboard to serve you better. Your feedback is invaluable in shaping a tool that meets the unique needs of businesses like yours. Stay connected and join us as we redefine cloud management.\"],\"Oxl1UN\":[\"If there is no index file, the URL displays a list of objects in the container.\"],\"PAKSdy\":[\"Enter a floating IP or leave blank to auto-assign one\"],\"PEGvy+\":[\"Minimum RAM must be 0 or greater\"],\"PHsq3v\":[\"Before proceeding, ensure that the Project ID and User ID you enter are correct. The system cannot validate these values, and incorrect IDs may apply access to wrong projects and users.\"],\"PHt+EV\":[\"Type <0>delete to confirm:\"],\"PIbPRX\":[\"RX/TX Factor must be an integer ≥ 1.\"],\"PLwzWR\":[\"All containers\"],\"PYQUjU\":[\"Failed to load metadata configuration.\"],\"PZnUbs\":[\"Please log in again to continue.\"],\"PgNNGl\":[\"More Actions\"],\"PiH3UR\":[\"Copied!\"],\"PiyQJ/\":[\"No flavors found\"],\"PkfPsB\":[\"Enter the ID of the project you want to share this security group with. You can find project IDs in the account/project switcher or in the Identity service.\"],\"Pkw7J9\":[\"This folder is empty.\"],\"PsEGri\":[\"Ubuntu 22.04 LTS\"],\"PtjzS+\":[\"Associates on the selected port. If the port has multiple IPs, select the desired fixed IP address.\"],\"PzgYM9\":[\"Checksum\"],\"Q1W//7\":[\"No services available for this project.\"],\"Q2xmVl\":[\"Symlinks\"],\"Q9f2QF\":[[\"numberDeleted\"],\" objects were deleted successfully, but some deletions failed.\"],\"QAUa4B\":[\"Enter a single port, or define a range by also filling \\\"Port (to)\\\". \\\"Port (to)\\\" is optional.\"],\"QEtDlS\":[\"Copying object...\"],\"QNHur0\":[\"Failed to load container ACLs: \",[\"errorMessage\"]],\"QQ8wUG\":[\"This action cannot be undone. The flavor will be permanently deleted.\"],\"QRlXaL\":[\"Bucket name must not end with reserved suffix \\\"\",[\"suffix\"],\"\\\"\"],\"QV1ZPO\":[\"Key is required\"],\"QWdKwH\":[\"Move\"],\"QYiqYb\":[\"Failed to update access status\"],\"Qb+14I\":[\"This action cannot be undone. The security group will be permanently deleted.\"],\"QetsXP\":[\"Upload failed: \",[\"uploadError\"]],\"Qg4EG6\":[\"Unable to connect to the compute service. Please check your connection and try again.\"],\"QpbWpf\":[\"Bucket \\\"\",[\"bucketName\"],\"\\\" was successfully emptied. \",[\"deletedCount\"],\" object deleted.\"],\"QuJSSl\":[\"Failed to create the flavor. Please try again.\"],\"QvqBQa\":[\"Target container\"],\"Qx7DM7\":[\"Capabilities\"],\"QxBGbh\":[\"Protected (will be skipped):\"],\"QytzQr\":[\"Type \\\"delete\\\" to confirm\"],\"R6kcsL\":[\"Must be a valid PQDN or FQDN (alphanumeric and hyphens only, cannot start or end with hyphen).\"],\"R6u5CR\":[\"Failed to activate \",[\"failedCount\"],\" of \",[\"totalCount\"],\" image(s). Some images may already be active or in an invalid state.\"],\"RByeNR\":[\"Your session expired. Please login again.\"],\"RCr0yv\":[\"Failed to load flavor details. Please try again.\"],\"RFDYCD\":[\"Minimum disk size required to boot this image\"],\"RGhYAo\":[\"RAM\"],\"RGrgxg\":[\"Could not delete container \\\"\",[\"containerName\"],\"\\\": \",[\"errorMessage\"]],\"RGwfoL\":[\"Max meta count\"],\"RNBvdl\":[\"Max SLO segments\"],\"RS0o7b\":[\"State\"],\"RSFkXF\":[\"Activate Image\"],\"RSMPjT\":[\"You are currently on the dashboard route.\"],\"RSg/pq\":[\"Failed to Delete Object\"],\"RTQFAw\":[\"You are not authorized to create extra specs. Please log in again.\"],\"RWQ6BN\":[\"Enter Common name (e.g., demo-ca.test.sci)\"],\"Rih53k\":[\"Max container name length\"],\"Rlp5zj\":[\"Create Flavor\"],\"S0kLOH\":[\"ID\"],\"S1iTXO\":[\"Image instance \\\"\",[\"imageName\"],\"\\\" has been deleted\"],\"S3olSf\":[\"No extra specs found. Click \\\"Add Metadata\\\" to create one.\"],\"S5CUKP\":[\"Member ID (project UUID) is required.\"],\"S63NbU\":[\"The image \\\"\",[\"imageId\"],\"\\\" could not be deactivated: \",[\"message\"]],\"S8/j2h\":[\"Failed to Empty Container\"],\"SBGiGm\":[\"Read ACLs\"],\"SCY5an\":[\"Failed to Move Object\"],\"SFo0kK\":[\"All Images\"],\"SIfYq6\":[\"Edit Metadata\"],\"SLEH7X\":[\"Enter DNS name\"],\"STc+7E\":[\"Max containers per extraction\"],\"SU0uxT\":[\"Upload object to:\"],\"SUSS9i\":[\"Container name\"],\"SVLToM\":[\"Type \\\"remove\\\" to confirm\"],\"SZw9tS\":[\"View Details\"],\"Sb/VT5\":[\"Container \\\"\",[\"containerName\"],\"\\\" was successfully emptied. \",[\"deletedCount\"],\" objects deleted.\"],\"Sf3Gvg\":[\"Failed to load PCAs\"],\"SfW/3r\":[\"There are no groups\"],\"Sgz1vJ\":[\"Member \\\"\",[\"trimmedMemberId\"],\"\\\" has been added successfully.\"],\"Smk7M2\":[\"Error loading floating IP\"],\"SuX2Ca\":[\"Basic Info\"],\"SysqAR\":[\"Flavor Details\"],\"T6Gm5y\":[\"Select an external network\"],\"T7mgdd\":[\"Successfully deleted \",[\"successCount\"],\" of \",[\"totalCount\"],\" image(s)\"],\"T8N6oi\":[\"Property Key\"],\"T9Mtpi\":[\"Tenant ID\"],\"T9o/az\":[\"Loading Certificates issued by Certificate Authority...\"],\"TM93nK\":[\"Delete Security Group Rule\"],\"TPMaxo\":[\"Type “release” to confirm\"],\"TQn3hH\":[\"Failed to create image. Please try again.\"],\"TZJiVf\":[\"Container \\\"\",[\"containerName\"],\"\\\" was successfully emptied. \",[\"deletedCount\"],\" object deleted.\"],\"TfC9O+\":[\"Last modified (UTC)\"],\"TfdeUd\":[\"Failed to delete the extra spec. Please try again.\"],\"TpGxnq\":[\"Enter member ID\"],\"Tx4Ym+\":[\"Enter a valid PQDN or FQDN (max 63 characters) to associate with the floating IP. A and PTR records are created automatically.\"],\"TyODHt\":[\"Save Metadata\"],\"U/oahm\":[\"URL Copied\"],\"U2wTy/\":[\"Note: The 'stateful' attribute cannot be changed if this security group is currently in use by one or more ports.\"],\"U4fmHG\":[\"The text must match “detach” in lowercase.\"],\"U6L+P/\":[\"Inactivity Timeout\"],\"U9q4M7\":[\"Back to Security Groups\"],\"UB+Q8v\":[\"Loading Certificate Details...\"],\"UGhVPl\":[\"Object Type\"],\"UJVf0u\":[\"Loading Image...\"],\"UJmAAK\":[\"Subject\"],\"UK2mpr\":[\"Generating temporary URL...\"],\"UKwOYH\":[\"Image File\"],\"UO3hJ2\":[\"Temporary URLs\"],\"UQ7Wyv\":[\"Manage Access for Image - \",[\"imageName\"]],\"URmyfc\":[\"Details\"],\"USiuNX\":[\"Container quotas\"],\"UVFHGY\":[\"e.g. PROJECT_ID:USER_ID\"],\"UVSFVV\":[\"Reject Shared Image\"],\"UYSopm\":[\"Minimum RAM (MB)\"],\"UaP7Th\":[\"Failed to Create Bucket\"],\"UbRKMZ\":[\"Pending\"],\"UbWeJA\":[\"Duration/validity\"],\"UdcGJu\":[\"Activate Images\"],\"UiNv/G\":[\"S3 Object Storage requires EC2 credentials (access key + secret key) to authenticate your requests. You need to create credentials before accessing S3 resources.\"],\"Uj+n/2\":[\"Failed to Delete Folder\"],\"UkVkoq\":[\"Leave empty for all codes\"],\"UmQ3/m\":[\"Deactivate Selected\"],\"Uwo8Xw\":[\"This image was shared with you by <0>\",[\"ownerProject\"],\" on \",[\"sharedAt\"],\".\"],\"UztfYZ\":[\"Select port to associate\"],\"V/8B9A\":[\"I confirm that all existing versions will also be deleted\"],\"V/SINY\":[\"Update object\"],\"V1TzeS\":[\"Container \\\"\",[\"containerName\"],\"\\\" was successfully deleted.\"],\"V66Jih\":[\"Access Status\"],\"V7fN5X\":[\"Copy object:\"],\"V8/eES\":[\"Bucket name must not contain consecutive periods\"],\"V804LY\":[\"Updating security group...\"],\"VCM3KS\":[\"Add Project Access\"],\"VG2a+x\":[\"Bucket name must start and end with a letter or number\"],\"VKCkZH\":[\"Bucket \\\"\",[\"bucketName\"],\"\\\" was successfully created.\"],\"VKmlZ+\":[\"Containers to be emptied (\",[\"totalCount\"],\")\"],\"VLI9eO\":[\"Loading Floating IP Details...\"],\"VMh1t1\":[\"The text must match “delete” in lowercase.\"],\"VV1fdg\":[\"Any user has read access to objects. No token is required in the request.\"],\"VaA9mu\":[\"24 hours\"],\"VakxP/\":[\"Failed to Upload Object\"],\"Vg0k6h\":[\"Showing \",[\"filteredCount\"],\" of \",[\"totalCount\"],\" \",[\"itemName\"]],\"Vh/Uj5\":[\"Target path\"],\"Vj8XFg\":[\"Failed to Create Container\"],\"Vl4XTj\":[\"Folder name cannot contain slashes\"],\"Vmojta\":[\"Access status updated to \\\"\",[\"newStatus\"],\"\\\".\"],\"VoxR3s\":[\"Object was copied but could not be deleted from the source: \",[\"deleteErrorMessage\"]],\"Vz+7ZA\":[\"Could not create container \\\"\",[\"containerName\"],\"\\\": \",[\"errorMessage\"]],\"Vzlopx\":[\"Delete container:\"],\"W0MCSG\":[\"Accept access to image\"],\"W5FkH9\":[\"Enter container name\"],\"W8Rb/w\":[\"Bucket name must not be formatted as an IP address\"],\"W9PZE0\":[\"Objects Deleted\"],\"W9kfjU\":[\"QoS Policy ID\"],\"WCKEqI\":[\"This is a <0>static large object (SLO) manifest. Metadata changes apply to the manifest only — segment objects are not affected.\"],\"WCLyHI\":[\"No Floating IPs found\"],\"WErCZy\":[\"Minimum RAM required to boot this image\"],\"WIx31g\":[\"Create Certificate\"],\"WRZ3Mt\":[\"Loading container properties...\"],\"WYb0Td\":[\"<0>Are you sure? All objects in the selected containers will be permanently deleted. This cannot be undone.\"],\"WYiUDa\":[\"Loading Containers...\"],\"Wbg1jv\":[\"Copy \",[\"text\"],\" to clipboard\"],\"Wca9WC\":[\"Failed to load security groups\"],\"WefafP\":[\"This container appears empty — the object count may not have synced yet due to a recent operation.\"],\"WidMsn\":[\"Create Certificate Authority\"],\"WlpcJv\":[\"DNS Domain\"],\"WoSkGY\":[\"Remote IP\"],\"WrUky8\":[\"Share (Temporary URL)\"],\"WyKwnD\":[\"Store old object versions\"],\"WzVwU0\":[\"Target Project ID\"],\"X2OnDx\":[\"Ephemeral Disk (GiB)\"],\"X70LXS\":[[\"numberDeleted\"],\" objects were permanently deleted.\"],\"XLk16/\":[\"Together, we can unlock the true potential of your cloud infrastructure.\"],\"XYZLy9\":[\"Key contains invalid characters\"],\"XvjC4F\":[\"Saving...\"],\"XwxJJB\":[\"Container \\\"\",[\"containerName\"],\"\\\" was successfully created.\"],\"XxjLdW\":[[\"emptiedCount\",\"plural\",{\"one\":[\"#\",\" container was already empty.\"],\"other\":[\"#\",\" containers were already empty.\"]}]],\"Y+2SDm\":[\"Delete Security Group \\\"\",[\"securityGroupName\"],\"\\\"\"],\"Y1YKad\":[\"Edit Details\"],\"Y8M9Uc\":[\"The container will be deleted. This action is permanent and cannot be undone.\"],\"YIix5Y\":[\"Search...\"],\"YNgcgc\":[\"Loading Flavor Details...\"],\"YRexkb\":[\"Object Updated\"],\"YUU0QW\":[\"Flavor ID is required and cannot be empty.\"],\"YVLcyI\":[\"Successfully emptied \",[\"emptiedCount\"],\" \",[\"emptiedCount\",\"plural\",{\"one\":[\"bucket\"],\"other\":[\"buckets\"]}],\", deleting \",[\"totalDeleted\"],\" \",[\"totalDeleted\",\"plural\",{\"one\":[\"object\"],\"other\":[\"objects\"]}],\".\"],\"YZmsaT\":[\"Partial Activation Success\"],\"YiMCKk\":[\"Show ACLs Preview\"],\"Yin3uB\":[\"Releasing Floating IP...\"],\"YjAOtb\":[\"Create Security Group\"],\"YrAy/S\":[\"You don't have permission to delete extra specs for this flavor.\"],\"YsOJlj\":[\"Server error occurred while fetching flavor access information. Please try again later.\"],\"YsrbQh\":[\"Owner Project ID\"],\"YuC9dj\":[\"Associate\"],\"YuGQWb\":[\"Rule Type\"],\"YzUoh9\":[\"To confirm type <0>delete in the field below.\"],\"Z/eWPC\":[\"The object will be copied to this path. Navigate folders above to change the destination.\"],\"Z2fZGD\":[\"No project selected\"],\"Z3FXyt\":[\"Loading...\"],\"Z42tfY\":[\"Folders in object storage are virtual — they are created as zero-byte placeholder objects with a trailing slash. The folder will appear once created.\"],\"Z5r9vC\":[\"Partial Delete Success\"],\"Z8lGw6\":[\"Share\"],\"ZAx+d1\":[\"Max meta overall size\"],\"ZAy0zp\":[\"Successfully deactivated \",[\"successCount\"],\" of \",[\"totalCount\"],\" image(s)\"],\"ZUmOzn\":[\"Server returned unexpected data format for flavor details.\"],\"ZcWMT1\":[\"Image instance \\\"\",[\"imageName\"],\"\\\" has been deactivated\"],\"Zgp2Sm\":[\"No projects available.\"],\"ZhVSpK\":[\"Failed to remove tenant access from flavor. Please try again.\"],\"Zq6Y5u\":[\"DNS name must be at most 63 characters.\"],\"Zshv0H\":[\"Buckets to empty:\"],\"ZvIpwi\":[\"Select a security group...\"],\"Zw49f9\":[\"folder-name\"],\"Zw8Q49\":[\"Security group not found\"],\"a/nTb8\":[\"Create Image\"],\"a12lSo\":[\"Port (to)\"],\"a13wDR\":[\"Type to search containers...\"],\"a3LDKx\":[\"Security\"],\"a4A2uB\":[\"Account listing limit\"],\"a4N/Bg\":[\"Load More\"],\"a7C4YS\":[\"Container Updated\"],\"a88X3d\":[\"<0>Are you sure? Object <1>\\\"\",[\"displayName\"],\"\\\" will be permanently deleted. This cannot be undone.\"],\"aG9OiI\":[\"Sharing Details\"],\"aI8Tgp\":[\"Owning Project ID\"],\"aL1w5Z\":[\"Used\"],\"aOeFR+\":[\"Empty Containers\"],\"aSsVD3\":[\"Public read access is not enabled. Before configuring static website serving, go to <0>Manage Access and enable public read access.\"],\"aTqCTq\":[\"Image file is required\"],\"aV6KPH\":[\"Prevent accidental deletion\"],\"aiqFbS\":[\"<0>Are you sure? The selected objects will be permanently deleted. This cannot be undone.\"],\"an5hVd\":[\"Images\"],\"ao/ZJi\":[\"Deleting folder and all its contents...\"],\"aqagJH\":[\"Unable to Update Image Visibility\"],\"arel2K\":[\"No objects found\"],\"azXlY+\":[\"Access Status:\"],\"b0uU1G\":[\"Store old object versions in container:\"],\"b2BLBa\":[\"Add Security Group Rule\"],\"b5aNMO\":[\"The text must match \\\"delete\\\"\"],\"b95YH9\":[\"Bucket Emptied\"],\"bHUarC\":[\"Bucket Deleted\"],\"bISG26\":[\"Failed to fetch flavor access information. Please try again.\"],\"bM1O3m\":[\"Image Instance\"],\"bQBMTH\":[\"This is a <0>static large object. By default, all associated segment objects will also be permanently deleted.\"],\"bRgFkJ\":[\"Failed to upload file \\\"\",[\"fileName\"],\"\\\": \"],\"bYRFNi\":[\"Failed to Delete Objects\"],\"bc67JN\":[\"Custom Properties / Metadata\"],\"bmQLn5\":[\"Add Rule\"],\"bnql/K\":[\"Back to Images\"],\"boJ+Y1\":[\"Create Folder\"],\"boJlGf\":[\"Page Not Found\"],\"boefwq\":[\"Could not create bucket \\\"\",[\"bucketName\"],\"\\\": \",[\"errorMessage\"]],\"bpme7e\":[\"Flavor Not Found\"],\"bwRvnp\":[\"Action\"],\"bwhBhT\":[\"Security Group\"],\"byKna+\":[\"An unexpected error occurred. Please try again.\"],\"bzMKg7\":[\"Accepted\"],\"bzSI52\":[\"Discard\"],\"c+fUtV\":[\"Start typing to search for a container\"],\"c+xCSz\":[\"True\"],\"c1OE1x\":[\"CA ID\"],\"c1uL4p\":[\"The image \\\"\",[\"imageId\"],\"\\\" could not be deleted: \",[\"message\"]],\"c5i+X0\":[\"Loading Buckets...\"],\"c6b6fz\":[\"Delete Selected\"],\"cCfxH1\":[\"Downloading...\"],\"cJDQIO\":[\"Root Disk\"],\"cPKL6O\":[\"You don't have permission to create flavors in this project.\"],\"cWbW6w\":[\"Manage Access\"],\"cXuXkb\":[\"User \",[\"userId\"],\" from project \",[\"projectId\"]],\"chL5IG\":[\"Community\"],\"cj17eo\":[\"Image instance \\\"\",[\"imageName\"],\"\\\" has been activated\"],\"cjEOmc\":[\"Share this security group with another project. The target project will be able to view and use this security group, but will not be able to modify or delete it.\"],\"cnGeoo\":[\"Delete\"],\"cpw++p\":[\"Static large object support\"],\"cqQyPB\":[\"Folder name\"],\"ctc4XR\":[\"Delete certificate authority\"],\"d+F6q9\":[\"Created\"],\"d+Ugpw\":[\"<0>Are you sure? Folder <1>\\\"\",[\"folderDisplayName\"],\"\\\" and all objects within it will be permanently deleted. This cannot be undone.\"],\"d/I0J3\":[\"Activate Selected\"],\"d0pLfy\":[\"Failed to delete security group\"],\"dEHa3L\":[\"Type the bucket name to confirm\"],\"dEgA5A\":[\"Cancel\"],\"dFb5Nt\":[\"Id\"],\"dLFiER\":[\"Error Loading Containers: \",[\"errorMessage\"]],\"dOevLB\":[\"Expires in \",[\"selectedPresetLabel\"],\" — at \",[\"expiresAtFormatted\"]],\"dPBJAJ\":[\"Empty All (\",[\"selectedCount\"],\")\"],\"dPj4yB\":[\"Login to Your Account\"],\"dPoCVe\":[\"Type “detach” to confirm\"],\"dTNzBI\":[\"Key must contain at least one alphanumeric character\"],\"dVdc7N\":[\"You are not authorized to delete extra specs. Please log in again.\"],\"dd2ndz\":[\"Entries in ACLs are comma-separated. Examples:\"],\"diFNkW\":[\"Error loading component\"],\"dxMaZH\":[\"Manage your Private Certificate Authority infrastructure\"],\"e0NrBM\":[\"Project\"],\"eChIh7\":[\"Flavor \\\"\",[\"flavorName\"],\"\\\" has been successfully created.\"],\"eGEHJE\":[\"DNS Name\"],\"eKC+EC\":[\"-\"],\"ePK91l\":[\"Edit\"],\"eYlnXt\":[\"No images found\"],\"eh/k36\":[\"Select a rule type...\"],\"ekCRTP\":[\"Rejected\"],\"eks7oA\":[\"Port ID\"],\"emkxJa\":[\"There are no buckets available with the current search criteria. Try adjusting your search term.\"],\"eu70nA\":[\"Expires at (UTC)\"],\"eyRsaH\":[\"Root\"],\"ezT9KW\":[\"Container syncing\"],\"f+Uq1E\":[\"New object name\"],\"f0cwjH\":[\"Object properties\"],\"fCPhho\":[\"One or more objects could not be deleted:\"],\"fIvd7X\":[\"Failed to Delete Images\"],\"fJpv9x\":[\"Failed to Deactivate Images\"],\"ffw//c\":[\"PCA\"],\"fj5byd\":[\"N/A\"],\"fnCEAB\":[\"Type “delete” to confirm\"],\"fxnDd7\":[\"Failed to generate temporary URL: \",[\"generalError\"]],\"fzfAAa\":[\"Ingress\"],\"g+Jead\":[\"IPv6\"],\"g1IxCo\":[\"RAM must be an integer ≥ 128 MB.\"],\"g3BSCe\":[\"Swap Disk must be an integer ≥ 0.\"],\"g3UF2V\":[\"Accept\"],\"g8Yxlg\":[\"Temporary URL for \\\"\",[\"objectName\"],\"\\\" was copied to clipboard.\"],\"g9m7gK\":[\"ACL entries control who can read from or write to this container. Multiple entries are comma-separated. Changes take effect immediately after saving.\"],\"gFKJBP\":[\"Folder name cannot have leading or trailing whitespace\"],\"gFsjJK\":[[\"bucketCount\",\"plural\",{\"one\":[\"object\"],\"other\":[\"objects\"]}]],\"gGdfWx\":[\"The compute service is currently unavailable for this project. Please try again later.\"],\"gHTJc/\":[\"Object Storage\"],\"gMYsdZ\":[\"Set to \\\"Shared\\\"\"],\"gU7JFm\":[\"Creating security group rule...\"],\"gYe+hC\":[\"Click to upload\"],\"gjop1H\":[\"Bucket name must be at least 3 characters\"],\"go0J2x\":[\"Failed to copy the temporary URL to the clipboard\"],\"go9U+C\":[\"To confirm, type <0>\\\"delete\\\" in the field below.\"],\"grs4+e\":[\"Compute Overview\"],\"gy6L1u\":[\"Must be a valid common name (FQDN).\"],\"gztCjq\":[\"The tenant ID provided is invalid. Please check your input.\"],\"h3P8z+\":[\"Failed to delete the flavor. Please try again.\"],\"h47p9L\":[\"—\"],\"h8h6oz\":[\"Images Deactivated\"],\"h99+4y\":[\"Allocate Floating IP\"],\"hH3kDo\":[\"Loading Image Details...\"],\"hHL/wm\":[\"Image instance \\\"\",[\"imageName\"],\"\\\" has been updated\"],\"hLp49h\":[\"Type <0>\",[\"deleteWord\"],\" to confirm:\"],\"hPz54a\":[\"Failed to Download\"],\"hQdfmR\":[\"Bucket \\\"\",[\"bucketName\"],\"\\\" was successfully deleted.\"],\"hQr1Cr\":[\"Deactivate Image\"],\"hXUWyd\":[\"Value is required\"],\"hYgDIe\":[\"Create\"],\"he3ygx\":[\"Copy\"],\"he4q+i\":[\"e.g., b90f9c4bc76140e18540b2cec1299e2a\"],\"hgpMHD\":[\"Total Size\"],\"hkjZ7P\":[\"Failed to update visibility for \\\"\",[\"imageName\"],\"\\\": \"],\"hrBow7\":[\"Network ID\"],\"hz9da7\":[\"Failed to load Certificates issued by Certificate Authority.\"],\"i0qMbr\":[\"Home\"],\"i30J2U\":[\"No projects found\"],\"i41Xuw\":[\"Select a fixed IP address\"],\"i5MEDc\":[\"Failed to move object: \",[\"copyErrorMessage\"]],\"i6/ygf\":[\"A property with this key already exists\"],\"i9TIyi\":[\"Remote Security Group\"],\"i9qiyR\":[\"Expires in\"],\"iH8pgl\":[\"Back\"],\"igVDFt\":[\"Attach\"],\"ih2lfP\":[\"No buckets found\"],\"iqUvrS\":[\"User C/D/I\"],\"is0Bhk\":[\"Bucket name must be 63 characters or fewer\"],\"izMhIO\":[\"User \",[\"userId\"],\" (any project)\"],\"j9hkgJ\":[\"rules\"],\"jBIkmi\":[\"QCOW2, Raw, VMDK, VHD, VHDX, VDI, AMI, ARI, AKI, ISO, PLOOP\"],\"jIPNJG\":[\"Basic Information\"],\"jK6wqe\":[\"Folder \\\"\",[\"folderName\"],\"\\\" and \",[\"deletedCount\"],\" objects were permanently deleted.\"],\"jKopCP\":[\"Network & Routing\"],\"jMc/mo\":[\"Server error occurred while creating extra specs. Please try again later.\"],\"jNm/qL\":[\"This container is already empty.\"],\"jNzyQo\":[\"Object versioning\"],\"jPxavx\":[\"Failed to update security group\"],\"jS4B2+\":[\"Container name does not match\"],\"jSG7wx\":[\"Please enter a valid number of minutes greater than 0\"],\"jVjr9h\":[\"Enter a valid common name in FQDN format (e.g., demo-ca.test.sci).\"],\"jhU93c\":[\"The image \\\"\",[\"imageName\"],\"\\\" could not be created: \"],\"js24f6\":[\"Delete folder:\"],\"jtnAf8\":[\"Protected images (cannot be deleted)\"],\"jyqLKs\":[\"This member already has access to this image.\"],\"k0vAWv\":[\"Object count quota\"],\"k5nYwm\":[\"vCPU\"],\"k7ENJG\":[\"Preview \",[\"rowDisplayName\"]],\"k99j0U\":[\"Cancel upload\"],\"kA2lMP\":[\"External Network\"],\"kCLnJG\":[\"Empty All\"],\"kGmM/p\":[\"You don't have permission to create extra specs for this flavor.\"],\"kIuDMT\":[\"Configure the ingress and egress rules that control which traffic is allowed for this security group.\"],\"kKK8AH\":[\"Remaining Quota:\"],\"kNeZrV\":[\"No Certificates issued by this Certificate Authority found\"],\"kQYfgO\":[\"One or more containers could not be emptied: \",[\"errorMessage\"]],\"kiRrtv\":[\"<0>Please note: for <1>dynamic and <2>static large objects only the manifests will be deleted. The related segments will not be deleted.\"],\"kqJVBO\":[\"You don't have permission to share this security group.\"],\"kuYWaD\":[\"Reserved keys: web-index, web-listings, quota-count, quota-bytes\"],\"l75CjT\":[\"Yes\"],\"lAsm87\":[\"Protect this image from being deleted\"],\"lBVhQs\":[\"Something went wrong:\"],\"lN/Z9n\":[\"Security group name is required\"],\"lN3xvy\":[\"Delete Rule\"],\"lQ3EIe\":[\"Max deletes per request\"],\"lWTy+Y\":[\"Unable to Create Image\"],\"lWxDDh\":[\"Flavor Name\"],\"lZvIXd\":[\"Description must be at most 255 characters.\"],\"lhIa6x\":[\"Failed to load extra specs. Please try again.\"],\"lq/mBZ\":[\"Loading object info...\"],\"lw1412\":[\"You have been logged out due to inactivity.\"],\"lxentK\":[\"An unexpected error occurred\"],\"m16xKo\":[\"Add\"],\"m6X3ro\":[\"Group Name\"],\"mQSO1Y\":[\"Port Forwarding\"],\"mSLePW\":[\"You don't have permission to access flavors for this project.\"],\"mSfwLL\":[\"Project ID\"],\"mYnJeY\":[\"The text must match “release” in lowercase.\"],\"miy5mb\":[\"PCA (Clavis)\"],\"mqljvE\":[\"Copy metadata\"],\"mvz5Eo\":[\"URL for public access\"],\"mxPfpY\":[\"Create new folder here\"],\"mzI/c+\":[\"Download\"],\"n0ZttO\":[\"Root Disk (GiB)\"],\"n1ekoW\":[\"Sign In\"],\"n1gB0L\":[\"Edit Floating IP \",[\"floating_ip_address\"]],\"n22YIM\":[\"Edit Description\"],\"n2IuBI\":[\"A temporary URL grants time-limited read access to this object without requiring authentication. Anyone with the link can download it until it expires.\"],\"n3eQzA\":[\"This property is reserved and cannot be modified\"],\"n46oLW\":[\"Failed to remove member\"],\"n9jJG6\":[\"Remove member access\"],\"nETBrc\":[\"Egress\"],\"nLvo6K\":[\"RBAC Policy Details:\"],\"nNKXt7\":[\"Deleting this Certificate Authority is permanent, and all the associated certificates will no longer apply to entities.\"],\"nUuaq8\":[\"Failed to update container: \",[\"errorMessage\"]],\"nW/hX9\":[\"General Image Data\"],\"nWNviN\":[\"Deleting certificate authority...\"],\"nZbdB+\":[\"Upload Cancelled\"],\"ne/GWZ\":[\"Inside a project, objects are stored in containers. Containers are where you define access permissions and quotas.\"],\"neiJm0\":[\"Flavors\"],\"ng+PCh\":[\"There are no PCAs available for this project.\"],\"nkpZyk\":[\"Container \\\"\",[\"containerName\"],\"\\\" was already empty.\"],\"nnxwBn\":[\"There are no rules for this security group\"],\"ntNlXu\":[\"Listing access\"],\"nzFJqC\":[\"Delete CA\"],\"o/VDOG\":[\"Unable to Delete Image\"],\"o6M6l0\":[\"Failed to create security group\"],\"oDkgME\":[\"You are not authorized to create flavors. Please log in again.\"],\"oEGiW3\":[\"Uploading... \",[\"progressPct\"],\"%\"],\"ocUvR+\":[\"False\"],\"odVI9Y\":[\"Container Deleted\"],\"og1m+J\":[\"Loading Certificate Authority Details...\"],\"okXQSt\":[\"Subject information\"],\"olfSYj\":[\"Access Control Updated\"],\"onHi/J\":[\"It will be removed from your image list.\"],\"p4nMut\":[\"Swap (MiB)\"],\"p6CSHM\":[\"Delete Objects\"],\"p7DzCB\":[\"Failed to Update Access Control\"],\"pD/2BQ\":[\"Successfully emptied \",[\"emptiedCount\"],\" of \",[\"totalBuckets\"],\" \",[\"totalBuckets\",\"plural\",{\"one\":[\"bucket\"],\"other\":[\"buckets\"]}],\", deleting \",[\"totalDeleted\"],\" \",[\"totalDeleted\",\"plural\",{\"one\":[\"object\"],\"other\":[\"objects\"]}],\". \",[\"errorsLength\"],\" \",[\"errorsLength\",\"plural\",{\"one\":[\"bucket\"],\"other\":[\"buckets\"]}],\" failed.\"],\"pFg+7w\":[\"Updated:\"],\"pOPvlj\":[\"Already active (will be skipped)\"],\"pU25+T\":[\"Upload of \\\"\",[\"objectName\"],\"\\\" was cancelled.\"],\"pbzA+s\":[\"Optional description\"],\"pebLmQ\":[\"Remove access for \",[\"memberIdDisplay\"]],\"plnnns\":[\"Deleted \",[\"successCount\"],\" image(s), but \",[\"failedCount\"],\" image(s) could not be deleted.\"],\"poCbZw\":[\"Loading ACLs...\"],\"podzPY\":[\"Project id\"],\"psPHye\":[\"Accept Shared Image\"],\"pubQie\":[\"Enter value\"],\"q0Rla3\":[\"Add Tenant Access\"],\"q44uUq\":[\"Containers Partially Emptied\"],\"q5sTNZ\":[\"<0>No Temp URL key configured. A temporary URL key must be set at the account or container level before temporary URLs can be generated. Contact your administrator to configure <1>X-Account-Meta-Temp-URL-Key or <2>X-Container-Meta-Temp-URL-Key.\"],\"q6K46F\":[\"Key already exists\"],\"q88/6A\":[\"Failed to Create Folder\"],\"qAkkjP\":[\"Max object name length\"],\"qEDO1j\":[\"This is a <0>dynamic large object. Only the manifest will be deleted — its segment objects (stored under the manifest prefix) are <1>not automatically removed and must be deleted separately.\"],\"qFDA8L\":[\"Reject access to image\"],\"qJb6G2\":[\"Try Again\"],\"qQ1QBh\":[\"Hardware Specifications\"],\"qST5TS\":[\"Error - Image Details\"],\"qUlxA+\":[\"Folder \\\"\",[\"folderName\"],\"\\\" was permanently deleted.\"],\"qaAo9Y\":[\"Server error occurred while creating the flavor. Please try again later.\"],\"qh5W8q\":[\"Remove Policy\"],\"qhDo93\":[\"Common name is required.\"],\"qs+BrU\":[\"You don't have permission to remove tenant access from this flavor.\"],\"qtoOYG\":[\"No limit\"],\"quU9wK\":[\"Failed to deactivate \",[\"failedCount\"],\" of \",[\"totalCount\"],\" image(s). Some images may already be deactivated or in an invalid state.\"],\"qvF2D8\":[\"There are no images available for this project with the current filters applied. Try adjusting your filter criteria or create a new image.\"],\"qxxo7y\":[\"No policies match your search\"],\"qyNaF7\":[\"Enter a timestamp like \\\"YYYY-MM-DD HH:mm:ss\\\" to schedule automatic deletion\"],\"qzIZOL\":[\"Invalid file format. Supported formats: \",[\"supportedFileFormats\"]],\"qzhUb9\":[\"Showing first \",[\"maxOptions\"],\" of \",[\"totalCount\"],\" — refine your search to narrow results\"],\"r5SQFW\":[\"Container name must be \",[\"maxContainerNameLength\"],\" characters or fewer\"],\"r9Aac8\":[\"Ephemeral Disk\"],\"rAtQcX\":[\"You can rename the object by changing the name here.\"],\"rD9yV1\":[\"Images to deactivate:\"],\"rIe0oV\":[\"Failed to add tenant access to flavor. Please try again.\"],\"rIi6x4\":[\"The flavor could not be found. It may have already been deleted.\"],\"rJe6vw\":[\"7 days\"],\"rPuPb+\":[\"Checking bucket contents...\"],\"rayTRr\":[[\"allContainersCount\"],\" bucket\"],\"rbuO5A\":[\"This security group is already shared with the specified project.\"],\"rcBt6T\":[\"Failed to create credential: \",[\"errorMessage\"]],\"rdUucN\":[\"Preview\"],\"rhaNn7\":[\"Loading containers...\"],\"riR9oD\":[\"Note: for <0>static and dynamic large objects only the manifests are deleted — their segments outside this folder prefix are not affected.\"],\"rlgAtt\":[\"The object will be moved to this path. Navigate folders above to change the destination.\"],\"rp0Bd0\":[\"Compute\"],\"rrjuul\":[\"For more details, have a look at the <0>documentation.\"],\"rvT6l1\":[\"Services Overview\"],\"rvXsSb\":[\"Tenant access for \\\"\",[\"tenantIdToRemove\"],\"\\\" has been removed successfully.\"],\"rwBVXS\":[\"Images to be deleted (\",[\"deletableCount\"],\")\"],\"ryf/ee\":[\"Images Activated\"],\"ryxYVo\":[\"Images to be deactivated (\",[\"activeCount\"],\")\"],\"s/s1lz\":[\"Any user can perform a HEAD or GET operation on the container provided the user also has read access on objects. No token is required.\"],\"s2ubkU\":[\"Flavor ID\"],\"s4Vnq2\":[\"Emptying...\"],\"sNVNmf\":[\"MAC Address\"],\"sPFHpI\":[\"Disk\"],\"sSNyf3\":[\"Welcome to <0>Aurora Dashboard, your next-generation cloud management solution. We are dedicated to simplifying how you interact with and manage your cloud infrastructure. Designed with efficiency, scalability, and usability at its core, Aurora empowers you to streamline operations and unlock the full potential of your cloud resources.\"],\"sWBLli\":[\"Add Property\"],\"sXd+qS\":[\"Properties of \\\"\",[\"objectName\"],\"\\\" were successfully updated.\"],\"sa4CV6\":[\"All users from project \",[\"projectId\"]],\"shKIZu\":[\"Images to be activated (\",[\"deactivatedCount\"],\")\"],\"sheDTJ\":[\"Please note: for <0>dynamic and <1>static large objects only the manifests are deleted. The related segments are not deleted.\"],\"sihD20\":[\"Loading images...\"],\"sjMCOP\":[\"Last Modified\"],\"slWh5C\":[\"Associate Floating IP \",[\"floating_ip_address\"],\" with Port\"],\"sxbP3b\":[\"Object Count\"],\"t/YqKh\":[\"Remove\"],\"t0X9+8\":[\"Container Name\"],\"t1POAD\":[\"No custom metadata properties found. Click \\\"Add Property\\\" to create one.\"],\"t1fq6V\":[\"Server returned unexpected data format.\"],\"t7ewhH\":[\"Could not empty bucket \\\"\",[\"bucketName\"],\"\\\": \",[\"errorMessage\"]],\"t7ff15\":[\"valid token required: false\"],\"t95VRV\":[\"About Aurora Dashboard\"],\"tASa/P\":[\"Server error occurred while deleting the flavor. Please try again later.\"],\"tIrNgH\":[\"Server error occurred while fetching extra specs. Please try again later.\"],\"tL9it8\":[\"Nothing to do. Bucket is already empty.\"],\"tLerHy\":[\"Ephemeral Disk must be an integer ≥ 0.\"],\"tM5SEI\":[\"ACLs for container \\\"\",[\"containerName\"],\"\\\" were successfully updated.\"],\"tOkmLM\":[\"Failed to Copy Object\"],\"tV/Ozb\":[\"Port Range\"],\"tVSmFT\":[\"Loading more...\"],\"tX5yOZ\":[\"New Folder\"],\"tasfos\":[\"remove\"],\"tbwGSx\":[\"Minimum Disk (GB)\"],\"tejJLY\":[\"Associating Floating IP...\"],\"tfAKBU\":[\"Could not upload \\\"\",[\"objectName\"],\"\\\": \",[\"errorMessage\"]],\"tfDRzk\":[\"Save\"],\"tfxu04\":[\"Remove access for \",[\"tenantId\"]],\"thHAVL\":[\"Accepted Images\"],\"tiflqy\":[\"Unable to Re-activate Image\"],\"tlfxPP\":[\"Could not copy \\\"\",[\"objectName\"],\"\\\": \",[\"errorMessage\"]],\"tmpGvt\":[\"production, linux\"],\"u+VWhB\":[\"Copied to clipboard!\"],\"u2xIeO\":[\"Failed to update ACLs: \",[\"errorMessage\"]],\"u5HztT\":[\"RX/TX Factor\"],\"u77/s4\":[\"Floating IPs\"],\"u7En0V\":[\"Add Metadata\"],\"uAI0yI\":[\"Delete object:\"],\"uAQUqI\":[\"Status\"],\"uAZE7K\":[\"Delete bucket\"],\"uLtFAr\":[\"Could not update container \\\"\",[\"containerName\"],\"\\\": \",[\"errorMessage\"]],\"uSdnuQ\":[\"VCPUs must be an integer ≥ 1.\"],\"ujK/QN\":[\"Loading objects...\"],\"uly9ET\":[\"Rule Details:\"],\"up0ZSW\":[\"Fingerprint\"],\"uuKb0T\":[\"Description must be less than 65535 characters.\"],\"v0hPHE\":[\"Show Details\"],\"v3djpU\":[\"Move/Rename\"],\"v9Dn8m\":[\"Aurora Dashboard is more than just a tool—it's your partner in navigating the cloud. Whether you're a small startup or a global enterprise, Aurora provides the flexibility, power, and simplicity you need to achieve your goals.\"],\"vBUQNE\":[\"The extra spec could not be found. It may have already been deleted.\"],\"vEkTR9\":[\"Quota\"],\"vH2C/2\":[\"Swap\"],\"vR4HmN\":[\"Loading Instances...\"],\"vTh35P\":[\"Create Container\"],\"vXmL4D\":[\"Drop your image file here\"],\"vZUKSz\":[\"Detach Floating IP \",[\"floating_ip_address\"]],\"vbajgL\":[\"Public Flavor\"],\"vcQSZh\":[\"This folder is empty — use New Folder to create one.\"],\"vcXmqy\":[\"Network Overview\"],\"vcvCXq\":[\"Error - Flavor Details\"],\"vg84cD\":[[\"allCount\"],\" items\"],\"vmRPFm\":[\"Share Security Group\"],\"vmYyLY\":[\"Remote IP Prefix\"],\"vp5vfW\":[\"1 hour\"],\"vpt8cE\":[\"Generate URL\"],\"vrPCbw\":[\"Image ID\"],\"w3bAcf\":[\"This action is permanent. The address will be removed from your project and returned to the public pool. This action cannot be undone.\"],\"w9+8d7\":[\"Remove tenant access\"],\"wEfZld\":[\"Create New Flavor\"],\"wFaT8w\":[\"Failed to Empty Containers\"],\"wMHvYH\":[\"Value\"],\"wPrtGF\":[\"Enter key\"],\"wTg+FY\":[\"Max file size\"],\"wXxPjv\":[\"S3 Object Storage — Setup Required\"],\"wa1Bcq\":[\"Enter tenant ID\"],\"wbqM4L\":[[\"customMinutes\"],\" minutes\"],\"wcUecy\":[\"You don't have permission to view extra specs for this flavor.\"],\"wdUvGT\":[\"Creating Certificate Authority...\"],\"we28Pq\":[\"Hide ACLs Preview\"],\"wlQNTg\":[\"Members\"],\"wlUDbB\":[\"Last updated: \",[\"formattedDate\"]],\"wrXcuy\":[\"Object Name\"],\"wrk/xj\":[\"Image Details\"],\"wxVsr5\":[\"Bucket name does not match\"],\"wyIOMP\":[\"Image name is required\"],\"wzqqS+\":[\"Key Features\"],\"x/XQrD\":[\"Any file type\"],\"x1bK0h\":[\"There are no containers available with the current search criteria. Try adjusting your search term.\"],\"x3T4pq\":[\"The container metadata reports objects but none were listed. This may be a temporary synchronization delay — please wait a moment and try again.\"],\"x5l/TK\":[\"Already active (will be skipped):\"],\"x9AdZ8\":[\"property_key\"],\"xNG/3n\":[\"Floating IP Address\"],\"xNZKYy\":[\"Failed to delete \",[\"failedCount\"],\" of \",[\"totalCount\"],\" image(s). Some images may be protected or in use.\"],\"xqhyRT\":[\"Object Uploaded\"],\"xw2UtT\":[\"Create New Image\"],\"y+KBOY\":[\"e.g., production, linux, ubuntu\"],\"y02Bu1\":[\"Container:\"],\"y0u86k\":[\"The requested flavor could not be found. It may have been deleted or you may not have access to it.\"],\"y1GYnY\":[\"Could not move \\\"\",[\"objectName\"],\"\\\": \",[\"errorMessage\"]],\"yDHGP+\":[\"my-bucket-name\"],\"yPWFWy\":[\"ICMP Type\"],\"yTtJTy\":[\"Edit Image Metadata\"],\"yYxB17\":[\"Clear all\"],\"ylfbpz\":[\"Extra spec key is required and cannot be empty.\"],\"yp0UjB\":[\"Ethertype\"],\"yqPflB\":[\"... and \",[\"hiddenCount\"],\" more\"],\"yu9G3x\":[\"Edit Security Group\"],\"ywe1H/\":[[\"totalCount\",\"plural\",{\"one\":[[\"totalCount\"],\" container\"],\"other\":[[\"totalCount\"],\" containers\"]}]],\"yz7wBu\":[\"Close\"],\"z+zpLP\":[\"valid token required: true\"],\"z1JceR\":[\"Back to Floating IPs\"],\"z45o5B\":[\"Object count\"],\"z9NAjZ\":[\"Object Deleted\"],\"zCD96i\":[\"You are not authorized to view flavor details. Please log in again.\"],\"zDS0JC\":[\"Name must be 2-50 characters long.\"],\"zWb/Nn\":[\"Max header size\"],\"zc5dcw\":[\"Login failed. Please check your credentials and try again.\"],\"zga9sT\":[\"OK\"],\"zhM8FP\":[\"Grant access to a user from a different project.\"],\"zm7+/D\":[\"You are about to deactivate <0>\",[\"activeCount\"],\" image(s). Deactivated images cannot be used to launch new instances.\"],\"zwBp5t\":[\"Private\"]}")as Messages; \ No newline at end of file diff --git a/apps/aurora-portal/src/server/Storage/cephProcedure.test.ts b/apps/aurora-portal/src/server/Storage/cephProcedure.test.ts new file mode 100644 index 000000000..cb08b2499 --- /dev/null +++ b/apps/aurora-portal/src/server/Storage/cephProcedure.test.ts @@ -0,0 +1,395 @@ +import { describe, it, expect, vi, beforeEach } from "vitest" +import { TRPCError } from "@trpc/server" +import type { S3Client } from "@aws-sdk/client-s3" +import { AuroraPortalContext } from "../context" +import { cephProcedure, cephProtectedProcedure, NO_CEPH_CREDENTIALS } from "./cephProcedure" +import { createCallerFactory, auroraRouter } from "../trpc" +import { z } from "zod" + +// ============================================================================ +// MOCK DEPENDENCIES +// ============================================================================ + +vi.mock("./middleware/resolveEC2Credential", () => ({ + resolveEC2Credential: vi.fn(), +})) + +vi.mock("./clients/s3Client", () => ({ + createS3Client: vi.fn(), +})) + +import { resolveEC2Credential } from "./middleware/resolveEC2Credential" +import { createS3Client } from "./clients/s3Client" + +const mockResolveEC2Credential = vi.mocked(resolveEC2Credential) +const mockCreateS3Client = vi.mocked(createS3Client) + +// Mock S3Client type for tests +type MockS3Client = Pick + +// ============================================================================ +// MOCK DATA / TEST CONSTANTS +// ============================================================================ + +const TEST_PROJECT_ID = "test-project-id" +const TEST_USER_ID = "test-user-id" +const TEST_ACCESS = "AKIAIOSFODNN7EXAMPLE" +const TEST_SECRET = "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY" +const TEST_CEPH_ENDPOINT = "https://rgw.st1.qa-de-1.cloud.sap/swift/v1/AUTH_project" +const TEST_CEPH_ENDPOINT_NO_SWIFT = "https://rgw.st1.eu-de-2.cloud.sap" + +// ============================================================================ +// MOCK CONTEXT FACTORY +// ============================================================================ + +interface MockContextOptions { + shouldFailAuth?: boolean + hasCredentials?: boolean + endpoint?: string + region?: string + hasToken?: boolean + hasCatalog?: boolean + hasCephService?: boolean + hasRegion?: boolean +} + +const createMockContext = (options: MockContextOptions = {}) => { + const { + shouldFailAuth = false, + endpoint = TEST_CEPH_ENDPOINT, + region = "qa-de-1", + hasToken = true, + hasCatalog = true, + hasCephService = true, + hasRegion = true, + } = options + + const mockCephService = { + getEndpoint: () => endpoint, + availableEndpoints: () => + hasRegion ? [{ region, url: endpoint, interface: "public", id: "test-id", region_id: region }] : [], + } + + const cephCatalogEntry = hasCephService + ? { + type: "ceph", + name: "ceph", + endpoints: hasRegion ? [{ region, url: endpoint }] : [], + } + : null + + const mockToken = hasToken + ? { + tokenData: { + project: { id: TEST_PROJECT_ID }, + user: { + id: TEST_USER_ID, + domain: { id: "default", name: "Default" }, + name: "test-user", + password_expires_at: "", + }, + catalog: hasCatalog && cephCatalogEntry ? [cephCatalogEntry] : [], + expires_at: "", + issued_at: "", + methods: [], + roles: [], + }, + } + : null + + const mockOpenstack = + hasToken && hasCatalog + ? { + service: (serviceName: string) => { + if (serviceName === "ceph" && hasCephService) return mockCephService + return null + }, + getToken: vi.fn().mockReturnValue(mockToken), + } + : { + service: () => null, + getToken: vi.fn().mockReturnValue(null), + } + + return { + req: { headers: {} }, + validateSession: vi.fn().mockReturnValue(!shouldFailAuth), + createSession: vi.fn(), + terminateSession: vi.fn(), + openstack: mockOpenstack, + rescopeSession: vi.fn().mockResolvedValue(mockOpenstack), + } as unknown as AuroraPortalContext +} + +// ============================================================================ +// TEST ROUTERS +// ============================================================================ + +// Simple test router to verify middleware behavior +const testRouter = { + checkStatus: cephProcedure.input(z.object({ project_id: z.string() })).query(async ({ ctx }) => { + return { + hasCredentials: !!ctx.cephCredentials, + region: ctx.cephRegion, + } + }), + + requireCredentials: cephProtectedProcedure.input(z.object({ project_id: z.string() })).query(async ({ ctx }) => { + return { + hasCredentials: !!ctx.cephCredentials, + region: ctx.cephRegion, + } + }), + + getClient: cephProcedure.input(z.object({ project_id: z.string() })).query(async ({ ctx }) => { + const client = ctx.getCephClient() + return { clientCreated: !!client } + }), +} + +const createCaller = createCallerFactory(auroraRouter({ test: testRouter })) + +// ============================================================================ +// TESTS +// ============================================================================ + +describe("cephProcedure", () => { + describe("resolveS3Config", () => { + describe("endpoint extraction", () => { + beforeEach(() => { + vi.clearAllMocks() + // Set default CEPH_REGION for tests + process.env.CEPH_REGION = "ceph-objectstore-ec-st1-qa-de-1" + mockResolveEC2Credential.mockResolvedValue({ + credentialId: "cred-id", + access: TEST_ACCESS, + secret: TEST_SECRET, + }) + mockCreateS3Client.mockReturnValue({ send: vi.fn() } as MockS3Client as S3Client) + }) + + it("extracts base endpoint by removing /swift/ suffix", async () => { + const ctx = createMockContext({ endpoint: TEST_CEPH_ENDPOINT }) + const caller = createCaller(ctx) + + await caller.test.getClient({ project_id: TEST_PROJECT_ID }) + + expect(mockCreateS3Client).toHaveBeenCalledWith( + TEST_ACCESS, + TEST_SECRET, + "https://rgw.st1.qa-de-1.cloud.sap", + expect.any(String) + ) + }) + + it("handles endpoints without Swift suffix", async () => { + const ctx = createMockContext({ endpoint: TEST_CEPH_ENDPOINT_NO_SWIFT, region: "eu-de-2" }) + const caller = createCaller(ctx) + + await caller.test.getClient({ project_id: TEST_PROJECT_ID }) + + expect(mockCreateS3Client).toHaveBeenCalledWith( + TEST_ACCESS, + TEST_SECRET, + TEST_CEPH_ENDPOINT_NO_SWIFT, + expect.any(String) + ) + }) + }) + + describe("region construction", () => { + beforeEach(() => { + vi.clearAllMocks() + mockResolveEC2Credential.mockResolvedValue({ + credentialId: "cred-id", + access: TEST_ACCESS, + secret: TEST_SECRET, + }) + mockCreateS3Client.mockReturnValue({ send: vi.fn() } as MockS3Client as S3Client) + }) + + it("constructs standard region for non-qa-de-1 regions", async () => { + process.env.CEPH_REGION = "ceph-objectstore-st1-eu-de-2" + const ctx = createMockContext({ endpoint: TEST_CEPH_ENDPOINT_NO_SWIFT, region: "eu-de-2" }) + const caller = createCaller(ctx) + + await caller.test.getClient({ project_id: TEST_PROJECT_ID }) + + expect(mockCreateS3Client).toHaveBeenCalledWith( + expect.any(String), + expect.any(String), + expect.any(String), + "ceph-objectstore-st1-eu-de-2" + ) + }) + + it("constructs QA region for qa-de-1 with ec prefix", async () => { + process.env.CEPH_REGION = "ceph-objectstore-ec-st1-qa-de-1" + const ctx = createMockContext({ endpoint: TEST_CEPH_ENDPOINT, region: "qa-de-1" }) + const caller = createCaller(ctx) + + await caller.test.getClient({ project_id: TEST_PROJECT_ID }) + + expect(mockCreateS3Client).toHaveBeenCalledWith( + expect.any(String), + expect.any(String), + expect.any(String), + "ceph-objectstore-ec-st1-qa-de-1" + ) + }) + }) + + describe("error handling", () => { + beforeEach(() => { + vi.clearAllMocks() + process.env.CEPH_REGION = "ceph-objectstore-ec-st1-qa-de-1" + mockResolveEC2Credential.mockResolvedValue({ + credentialId: "cred-id", + access: TEST_ACCESS, + secret: TEST_SECRET, + }) + }) + + it("throws when OpenStack token is missing", async () => { + const ctx = createMockContext({ hasToken: false }) + const caller = createCaller(ctx) + + await expect(caller.test.checkStatus({ project_id: TEST_PROJECT_ID })).rejects.toThrow( + "Ceph service not found in catalog" + ) + }) + + it("throws when service catalog is missing", async () => { + const ctx = createMockContext({ hasCatalog: false }) + const caller = createCaller(ctx) + + await expect(caller.test.checkStatus({ project_id: TEST_PROJECT_ID })).rejects.toThrow( + "Ceph service not found in catalog" + ) + }) + + it("throws when Ceph service not found in catalog", async () => { + const ctx = createMockContext({ hasCephService: false }) + const caller = createCaller(ctx) + + await expect(caller.test.checkStatus({ project_id: TEST_PROJECT_ID })).rejects.toThrow( + "Ceph service not found in catalog" + ) + }) + + it("throws when region not found in Ceph service endpoints", async () => { + const ctx = createMockContext({ hasRegion: false }) + const caller = createCaller(ctx) + + await expect(caller.test.checkStatus({ project_id: TEST_PROJECT_ID })).rejects.toThrow( + "Ceph service not found in catalog" + ) + }) + + it("throws when CEPH_REGION environment variable is not set", async () => { + delete process.env.CEPH_REGION + const ctx = createMockContext() + const caller = createCaller(ctx) + + await expect(caller.test.checkStatus({ project_id: TEST_PROJECT_ID })).rejects.toThrow( + "Ceph service not found in catalog" + ) + }) + }) + }) + + describe("cephCredentialMiddleware", () => { + beforeEach(() => { + vi.clearAllMocks() + process.env.CEPH_REGION = "ceph-objectstore-ec-st1-qa-de-1" + mockCreateS3Client.mockReturnValue({ send: vi.fn() } as MockS3Client as S3Client) + }) + + it("resolves EC2 credentials and adds to context", async () => { + mockResolveEC2Credential.mockResolvedValue({ credentialId: "cred-id", access: TEST_ACCESS, secret: TEST_SECRET }) + const ctx = createMockContext() + const caller = createCaller(ctx) + + const result = await caller.test.checkStatus({ project_id: TEST_PROJECT_ID }) + + expect(result.hasCredentials).toBe(true) + expect(mockResolveEC2Credential).toHaveBeenCalledWith(ctx) + }) + + it("adds cephRegion to context", async () => { + process.env.CEPH_REGION = "ceph-objectstore-st1-eu-de-2" + mockResolveEC2Credential.mockResolvedValue({ credentialId: "cred-id", access: TEST_ACCESS, secret: TEST_SECRET }) + const ctx = createMockContext({ region: "eu-de-2" }) + const caller = createCaller(ctx) + + const result = await caller.test.checkStatus({ project_id: TEST_PROJECT_ID }) + + expect(result.region).toBe("ceph-objectstore-st1-eu-de-2") + }) + + it("adds getCephClient factory to context", async () => { + mockResolveEC2Credential.mockResolvedValue({ credentialId: "cred-id", access: TEST_ACCESS, secret: TEST_SECRET }) + const ctx = createMockContext() + const caller = createCaller(ctx) + + const result = await caller.test.getClient({ project_id: TEST_PROJECT_ID }) + + expect(result.clientCreated).toBe(true) + expect(mockCreateS3Client).toHaveBeenCalledWith(TEST_ACCESS, TEST_SECRET, expect.any(String), expect.any(String)) + }) + + it("returns null credentials when no EC2 credentials exist", async () => { + mockResolveEC2Credential.mockResolvedValue(null) + const ctx = createMockContext() + const caller = createCaller(ctx) + + const result = await caller.test.checkStatus({ project_id: TEST_PROJECT_ID }) + + expect(result.hasCredentials).toBe(false) + }) + + it("getCephClient throws FORBIDDEN when credentials are null", async () => { + mockResolveEC2Credential.mockResolvedValue(null) + const ctx = createMockContext() + const caller = createCaller(ctx) + + await expect(caller.test.getClient({ project_id: TEST_PROJECT_ID })).rejects.toThrow( + new TRPCError({ + code: "FORBIDDEN", + message: NO_CEPH_CREDENTIALS, + }) + ) + }) + }) + + describe("cephProtectedProcedure", () => { + beforeEach(() => { + vi.clearAllMocks() + process.env.CEPH_REGION = "ceph-objectstore-ec-st1-qa-de-1" + mockCreateS3Client.mockReturnValue({ send: vi.fn() } as MockS3Client as S3Client) + }) + + it("throws FORBIDDEN with NO_CEPH_CREDENTIALS when credentials missing", async () => { + mockResolveEC2Credential.mockResolvedValue(null) + const ctx = createMockContext() + const caller = createCaller(ctx) + + await expect(caller.test.requireCredentials({ project_id: TEST_PROJECT_ID })).rejects.toThrow( + new TRPCError({ + code: "FORBIDDEN", + message: NO_CEPH_CREDENTIALS, + }) + ) + }) + + it("allows execution when credentials exist", async () => { + mockResolveEC2Credential.mockResolvedValue({ credentialId: "cred-id", access: TEST_ACCESS, secret: TEST_SECRET }) + const ctx = createMockContext() + const caller = createCaller(ctx) + + const result = await caller.test.requireCredentials({ project_id: TEST_PROJECT_ID }) + + expect(result.hasCredentials).toBe(true) + }) + }) +}) diff --git a/apps/aurora-portal/src/server/Storage/cephProcedure.ts b/apps/aurora-portal/src/server/Storage/cephProcedure.ts index 7aa29750f..e8c05b744 100644 --- a/apps/aurora-portal/src/server/Storage/cephProcedure.ts +++ b/apps/aurora-portal/src/server/Storage/cephProcedure.ts @@ -7,47 +7,70 @@ import { createS3Client } from "./clients/s3Client" export const NO_CEPH_CREDENTIALS = "NO_CEPH_CREDENTIALS" as const +/** + * Resolves S3 endpoint and region configuration from OpenStack service catalog. + * + * Extracts the Ceph RGW endpoint and constructs the appropriate region identifier + * for AWS SDK operations (signing, bucket creation with LocationConstraint). + * + * @param ctx - Aurora Portal context containing OpenStack token and service catalog + * @returns Object with endpoint URL and Ceph-compatible region identifier + * @throws Error if OpenStack token, service catalog, Ceph service, or region is not available + */ function resolveS3Config(ctx: AuroraPortalContext): { endpoint: string; region: string } { - // Region is not used by Ceph RGW, but required by AWS SDK - // Use a default constant value - const region = "default" - - // Get endpoint from Ceph service catalog try { const service = ctx.openstack?.service("ceph") - const endpoint = service?.getEndpoint?.() - if (endpoint) { - // Extract base URL by removing Swift path - // Ceph RGW serves both Swift and S3 APIs on the same host - // Swift: https://rgw.st1.qa-de-1.cloud.sap/swift/v1/AUTH_xxx - // S3: https://rgw.st1.qa-de-1.cloud.sap - const swiftIndex = endpoint.indexOf("/swift/") + if (!service) { + throw new Error("Ceph service not found in OpenStack service catalog") + } + + const endpoint = service.getEndpoint?.() + + if (!endpoint) { + throw new Error("Ceph service endpoint not found in catalog. Ensure the Ceph service is registered in OpenStack.") + } + + // Extract base URL by removing Swift path suffix. + // Ceph RGW serves both Swift and S3 APIs on the same host but different paths: + // Swift: https://rgw.st1.qa-de-1.cloud.sap/swift/v1/AUTH_xxx + // S3: https://rgw.st1.qa-de-1.cloud.sap + const swiftIndex = endpoint.indexOf("/swift/") + const baseEndpoint = swiftIndex !== -1 ? endpoint.substring(0, swiftIndex) : endpoint - if (swiftIndex !== -1) { - return { endpoint: endpoint.substring(0, swiftIndex), region } - } + const endpoints = service.availableEndpoints?.() + const openstackRegion = endpoints?.[0]?.region - // Already a base URL without Swift path - return { endpoint, region } + if (!openstackRegion) { + throw new Error("Region not found in Ceph service endpoints") } + + if (!process.env.CEPH_REGION) { + throw new Error("CEPH_REGION environment variable is required. ") + } + + const region = process.env.CEPH_REGION + + return { endpoint: baseEndpoint, region } } catch (error) { console.error("[ceph] Failed to resolve Ceph service from catalog:", error) throw new Error("Ceph service not found in catalog. Ensure the Ceph service is registered in OpenStack.", { cause: error, }) } - - throw new Error("Ceph service endpoint not found in catalog. Ensure the Ceph service is registered in OpenStack.") } /** - * Base Ceph middleware - resolves EC2 credentials and S3 config, but does NOT throw on missing credentials. + * Base Ceph middleware - resolves EC2 credentials and S3 config. + * + * Does NOT throw on missing credentials - allows procedures to check credential status gracefully. + * * Adds to context: * - cephCredentials: EC2CredentialResult | null - * - getCephClient: () => S3Client - throws FORBIDDEN if credentials missing + * - cephRegion: string - Ceph-compatible region identifier + * - getCephClient: () => S3Client - factory function that throws FORBIDDEN if credentials missing * - * Use this for procedures that need to check credential status without failing. + * Use this for procedures that need to check credential status without failing immediately. * Note: getCephClient() throws TRPCError when called without credentials. */ const cephCredentialMiddleware = projectScopedProcedure.use(async function resolveCeph(opts) { @@ -59,6 +82,7 @@ const cephCredentialMiddleware = projectScopedProcedure.use(async function resol ctx: { ...ctx, cephCredentials: credentials, + cephRegion: region, getCephClient: (): S3Client => { if (!credentials) { throw new TRPCError({ @@ -74,14 +98,16 @@ const cephCredentialMiddleware = projectScopedProcedure.use(async function resol /** * Base procedure with Ceph credentials resolved (may be null). - * For status checks or other operations that handle missing credentials gracefully. + * + * Use for status checks or operations that handle missing credentials gracefully. */ export const cephProcedure = cephCredentialMiddleware /** * Protected procedure - requires EC2 credentials to exist. + * * Throws FORBIDDEN with NO_CEPH_CREDENTIALS if credentials not found. - * For actual Ceph S3 operations (list containers, get objects, etc). + * Use for actual Ceph S3 operations (list buckets, create bucket, delete bucket, etc). */ export const cephProtectedProcedure = cephCredentialMiddleware.use(async function requireCredentials(opts) { const { ctx, next } = opts diff --git a/apps/aurora-portal/src/server/Storage/clients/s3Client.test.ts b/apps/aurora-portal/src/server/Storage/clients/s3Client.test.ts new file mode 100644 index 000000000..054a2d533 --- /dev/null +++ b/apps/aurora-portal/src/server/Storage/clients/s3Client.test.ts @@ -0,0 +1,131 @@ +import { describe, it, expect } from "vitest" +import { createS3Client } from "./s3Client" +import { S3Client } from "@aws-sdk/client-s3" + +// ============================================================================ +// MOCK DATA / TEST CONSTANTS +// ============================================================================ + +const TEST_ACCESS = "AKIAIOSFODNN7EXAMPLE" +const TEST_SECRET = "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY" +const TEST_ENDPOINT = "https://test-ceph.example.com" +const TEST_REGION = "ceph-objectstore-st1-eu-de-2" + +// ============================================================================ +// TESTS +// ============================================================================ + +describe("createS3Client", () => { + describe("successful client creation", () => { + it("creates S3Client with correct configuration", () => { + const client = createS3Client(TEST_ACCESS, TEST_SECRET, TEST_ENDPOINT, TEST_REGION) + + expect(client).toBeInstanceOf(S3Client) + }) + + it("sets forcePathStyle to true for Ceph compatibility", () => { + const client = createS3Client(TEST_ACCESS, TEST_SECRET, TEST_ENDPOINT, TEST_REGION) + + expect(client.config.forcePathStyle).toBe(true) + }) + + it("uses provided access key, secret key, endpoint, and region", async () => { + const client = createS3Client(TEST_ACCESS, TEST_SECRET, TEST_ENDPOINT, TEST_REGION) + + const credentialsFn = client.config.credentials + if (typeof credentialsFn === "function") { + const credentials = await credentialsFn() + expect(credentials?.accessKeyId).toBe(TEST_ACCESS) + expect(credentials?.secretAccessKey).toBe(TEST_SECRET) + } + + const endpointFn = client.config.endpoint + if (typeof endpointFn === "function") { + expect(await endpointFn()).toMatchObject({ + protocol: "https:", + hostname: "test-ceph.example.com", + }) + } + + const regionFn = client.config.region + if (typeof regionFn === "function") { + expect(await regionFn()).toBe(TEST_REGION) + } + }) + + it("defaults region to 'default' when not provided", async () => { + const client = createS3Client(TEST_ACCESS, TEST_SECRET, TEST_ENDPOINT) + + const regionFn = client.config.region + if (typeof regionFn === "function") { + expect(await regionFn()).toBe("default") + } + }) + }) + + describe("access key validation", () => { + it("throws when access key is empty string", () => { + expect(() => createS3Client("", TEST_SECRET, TEST_ENDPOINT, TEST_REGION)).toThrow("S3 access key is required") + }) + + it("throws when access key is whitespace only", () => { + expect(() => createS3Client(" ", TEST_SECRET, TEST_ENDPOINT, TEST_REGION)).toThrow("S3 access key is required") + }) + + it("throws when access key is null", () => { + expect(() => createS3Client(null as unknown as string, TEST_SECRET, TEST_ENDPOINT, TEST_REGION)).toThrow( + "S3 access key is required" + ) + }) + + it("throws when access key is undefined", () => { + expect(() => createS3Client(undefined as unknown as string, TEST_SECRET, TEST_ENDPOINT, TEST_REGION)).toThrow( + "S3 access key is required" + ) + }) + }) + + describe("secret key validation", () => { + it("throws when secret key is empty string", () => { + expect(() => createS3Client(TEST_ACCESS, "", TEST_ENDPOINT, TEST_REGION)).toThrow("S3 secret key is required") + }) + + it("throws when secret key is whitespace only", () => { + expect(() => createS3Client(TEST_ACCESS, " ", TEST_ENDPOINT, TEST_REGION)).toThrow("S3 secret key is required") + }) + + it("throws when secret key is null", () => { + expect(() => createS3Client(TEST_ACCESS, null as unknown as string, TEST_ENDPOINT, TEST_REGION)).toThrow( + "S3 secret key is required" + ) + }) + + it("throws when secret key is undefined", () => { + expect(() => createS3Client(TEST_ACCESS, undefined as unknown as string, TEST_ENDPOINT, TEST_REGION)).toThrow( + "S3 secret key is required" + ) + }) + }) + + describe("endpoint validation", () => { + it("throws when endpoint is empty string", () => { + expect(() => createS3Client(TEST_ACCESS, TEST_SECRET, "", TEST_REGION)).toThrow("S3 endpoint is required") + }) + + it("throws when endpoint is whitespace only", () => { + expect(() => createS3Client(TEST_ACCESS, TEST_SECRET, " ", TEST_REGION)).toThrow("S3 endpoint is required") + }) + + it("throws when endpoint is null", () => { + expect(() => createS3Client(TEST_ACCESS, TEST_SECRET, null as unknown as string, TEST_REGION)).toThrow( + "S3 endpoint is required" + ) + }) + + it("throws when endpoint is undefined", () => { + expect(() => createS3Client(TEST_ACCESS, TEST_SECRET, undefined as unknown as string, TEST_REGION)).toThrow( + "S3 endpoint is required" + ) + }) + }) +}) diff --git a/apps/aurora-portal/src/server/Storage/constants.ts b/apps/aurora-portal/src/server/Storage/constants.ts new file mode 100644 index 000000000..2a1b6a585 --- /dev/null +++ b/apps/aurora-portal/src/server/Storage/constants.ts @@ -0,0 +1,10 @@ +/** + * Maximum number of keys returned per S3 ListObjects request. + * + * This is the AWS S3 maximum. We use this value as a performance trade-off: + * - Fast response times for list operations + * - Bucket metadata (count, size) are estimates for buckets > 1000 objects + * + * See: https://docs.aws.amazon.com/AmazonS3/latest/API/API_ListObjectsV2.html + */ +export const S3_MAX_KEYS_PER_REQUEST = 1000 diff --git a/apps/aurora-portal/src/server/Storage/helpers/s3ErrorMapper.test.ts b/apps/aurora-portal/src/server/Storage/helpers/s3ErrorMapper.test.ts new file mode 100644 index 000000000..4ea82f551 --- /dev/null +++ b/apps/aurora-portal/src/server/Storage/helpers/s3ErrorMapper.test.ts @@ -0,0 +1,341 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest" +import { TRPCError } from "@trpc/server" +import { mapS3ErrorToTRPCError } from "./s3ErrorMapper" + +// ============================================================================ +// MOCK DATA / TEST CONSTANTS +// ============================================================================ + +const TEST_BUCKET = "my-test-bucket" +const TEST_KEY = "path/to/object.txt" +const TEST_OPERATION = "test operation" + +// ============================================================================ +// TESTS +// ============================================================================ + +describe("mapS3ErrorToTRPCError", () => { + describe("S3 error code mapping", () => { + it("maps NoSuchBucket to NOT_FOUND", () => { + const error = Object.assign(new Error("The specified bucket does not exist"), { Code: "NoSuchBucket" }) + + expect(() => mapS3ErrorToTRPCError(error, { operation: TEST_OPERATION, bucket: TEST_BUCKET })).toThrow( + expect.objectContaining({ + code: "NOT_FOUND", + }) + ) + }) + + it("maps NoSuchKey to NOT_FOUND", () => { + const error = Object.assign(new Error("The specified key does not exist"), { Code: "NoSuchKey" }) + + expect(() => + mapS3ErrorToTRPCError(error, { operation: TEST_OPERATION, bucket: TEST_BUCKET, key: TEST_KEY }) + ).toThrow( + expect.objectContaining({ + code: "NOT_FOUND", + }) + ) + }) + + it("maps NoSuchUpload to NOT_FOUND", () => { + const error = Object.assign(new Error("The specified upload does not exist"), { Code: "NoSuchUpload" }) + + expect(() => mapS3ErrorToTRPCError(error, { operation: TEST_OPERATION })).toThrow( + expect.objectContaining({ + code: "NOT_FOUND", + }) + ) + }) + + it("maps BucketAlreadyExists to CONFLICT", () => { + const error = Object.assign(new Error("The requested bucket name is not available"), { + Code: "BucketAlreadyExists", + }) + + expect(() => mapS3ErrorToTRPCError(error, { operation: TEST_OPERATION, bucket: TEST_BUCKET })).toThrow( + expect.objectContaining({ + code: "CONFLICT", + }) + ) + }) + + it("maps BucketAlreadyOwnedByYou to CONFLICT", () => { + const error = Object.assign(new Error("Your previous request to create the named bucket succeeded"), { + Code: "BucketAlreadyOwnedByYou", + }) + + expect(() => mapS3ErrorToTRPCError(error, { operation: TEST_OPERATION, bucket: TEST_BUCKET })).toThrow( + expect.objectContaining({ + code: "CONFLICT", + }) + ) + }) + + it("maps BucketNotEmpty to PRECONDITION_FAILED", () => { + const error = Object.assign(new Error("The bucket you tried to delete is not empty"), { + Code: "BucketNotEmpty", + }) + + expect(() => mapS3ErrorToTRPCError(error, { operation: TEST_OPERATION, bucket: TEST_BUCKET })).toThrow( + expect.objectContaining({ + code: "PRECONDITION_FAILED", + }) + ) + }) + + it("maps AccessDenied to FORBIDDEN", () => { + const error = Object.assign(new Error("Access Denied"), { Code: "AccessDenied" }) + + expect(() => mapS3ErrorToTRPCError(error, { operation: TEST_OPERATION })).toThrow( + expect.objectContaining({ + code: "FORBIDDEN", + }) + ) + }) + + it("maps AllAccessDisabled to FORBIDDEN", () => { + const error = Object.assign(new Error("All access to this bucket has been disabled"), { + Code: "AllAccessDisabled", + }) + + expect(() => mapS3ErrorToTRPCError(error, { operation: TEST_OPERATION, bucket: TEST_BUCKET })).toThrow( + expect.objectContaining({ + code: "FORBIDDEN", + }) + ) + }) + + it("maps InvalidAccessKeyId to UNAUTHORIZED", () => { + const error = Object.assign(new Error("The AWS access key ID you provided does not exist"), { + Code: "InvalidAccessKeyId", + }) + + expect(() => mapS3ErrorToTRPCError(error, { operation: TEST_OPERATION })).toThrow( + expect.objectContaining({ + code: "UNAUTHORIZED", + }) + ) + }) + + it("maps SignatureDoesNotMatch to UNAUTHORIZED", () => { + const error = Object.assign(new Error("The request signature we calculated does not match"), { + Code: "SignatureDoesNotMatch", + }) + + expect(() => mapS3ErrorToTRPCError(error, { operation: TEST_OPERATION })).toThrow( + expect.objectContaining({ + code: "UNAUTHORIZED", + }) + ) + }) + + it("maps TokenRefreshRequired to UNAUTHORIZED", () => { + const error = Object.assign(new Error("The provided token must be refreshed"), { + Code: "TokenRefreshRequired", + }) + + expect(() => mapS3ErrorToTRPCError(error, { operation: TEST_OPERATION })).toThrow( + expect.objectContaining({ + code: "UNAUTHORIZED", + }) + ) + }) + + it("maps RequestTimeTooSkewed to UNAUTHORIZED", () => { + const error = Object.assign(new Error("The difference between request time and server time is too large"), { + Code: "RequestTimeTooSkewed", + }) + + expect(() => mapS3ErrorToTRPCError(error, { operation: TEST_OPERATION })).toThrow( + expect.objectContaining({ + code: "UNAUTHORIZED", + }) + ) + }) + + it("maps InvalidBucketName to BAD_REQUEST", () => { + const error = Object.assign(new Error("The specified bucket is not valid"), { Code: "InvalidBucketName" }) + + expect(() => mapS3ErrorToTRPCError(error, { operation: TEST_OPERATION, bucket: TEST_BUCKET })).toThrow( + expect.objectContaining({ + code: "BAD_REQUEST", + }) + ) + }) + + it("maps KeyTooLongError to BAD_REQUEST", () => { + const error = Object.assign(new Error("Your key is too long"), { Code: "KeyTooLongError" }) + + expect(() => + mapS3ErrorToTRPCError(error, { operation: TEST_OPERATION, bucket: TEST_BUCKET, key: TEST_KEY }) + ).toThrow( + expect.objectContaining({ + code: "BAD_REQUEST", + }) + ) + }) + + it("maps EntityTooLarge to PAYLOAD_TOO_LARGE", () => { + const error = Object.assign(new Error("Your proposed upload exceeds the maximum allowed size"), { + Code: "EntityTooLarge", + }) + + expect(() => mapS3ErrorToTRPCError(error, { operation: TEST_OPERATION })).toThrow( + expect.objectContaining({ + code: "PAYLOAD_TOO_LARGE", + }) + ) + }) + + it("maps EntityTooSmall to BAD_REQUEST", () => { + const error = Object.assign(new Error("Your proposed upload is smaller than minimum allowed size"), { + Code: "EntityTooSmall", + }) + + expect(() => mapS3ErrorToTRPCError(error, { operation: TEST_OPERATION })).toThrow( + expect.objectContaining({ + code: "BAD_REQUEST", + }) + ) + }) + + it("maps unmapped error codes to INTERNAL_SERVER_ERROR", () => { + const error = Object.assign(new Error("Some unknown error"), { Code: "UnknownErrorCode" }) + + expect(() => mapS3ErrorToTRPCError(error, { operation: TEST_OPERATION })).toThrow( + expect.objectContaining({ + code: "INTERNAL_SERVER_ERROR", + }) + ) + }) + + it("reads error code from 'name' property when 'Code' is missing", () => { + const error = Object.assign(new Error("The specified bucket does not exist"), { name: "NoSuchBucket" }) + + expect(() => mapS3ErrorToTRPCError(error, { operation: TEST_OPERATION, bucket: TEST_BUCKET })).toThrow( + expect.objectContaining({ + code: "NOT_FOUND", + }) + ) + }) + }) + + describe("error message construction", () => { + it("includes operation in error message", () => { + const error = Object.assign(new Error("S3 error"), { Code: "NoSuchBucket" }) + + expect(() => mapS3ErrorToTRPCError(error, { operation: "list buckets" })).toThrow( + expect.objectContaining({ + message: expect.stringContaining("Failed to list buckets"), + }) + ) + }) + + it("includes bucket in error message when provided", () => { + const error = Object.assign(new Error("S3 error"), { Code: "NoSuchBucket" }) + + expect(() => mapS3ErrorToTRPCError(error, { operation: TEST_OPERATION, bucket: TEST_BUCKET })).toThrow( + expect.objectContaining({ + message: expect.stringContaining(`bucket: ${TEST_BUCKET}`), + }) + ) + }) + + it("includes key in error message when provided", () => { + const error = Object.assign(new Error("S3 error"), { Code: "NoSuchKey" }) + + expect(() => + mapS3ErrorToTRPCError(error, { operation: TEST_OPERATION, bucket: TEST_BUCKET, key: TEST_KEY }) + ).toThrow( + expect.objectContaining({ + message: expect.stringContaining(`key: ${TEST_KEY}`), + }) + ) + }) + + it("includes original S3 message when available", () => { + const errorMessage = "The specified bucket does not exist in this region" + const error = Object.assign(new Error(errorMessage), { Code: "NoSuchBucket" }) + + expect(() => mapS3ErrorToTRPCError(error, { operation: TEST_OPERATION, bucket: TEST_BUCKET })).toThrow( + expect.objectContaining({ + message: expect.stringContaining(errorMessage), + }) + ) + }) + + it("constructs message with all context parts", () => { + const errorMessage = "Access Denied" + const error = Object.assign(new Error(errorMessage), { Code: "AccessDenied" }) + + expect(() => + mapS3ErrorToTRPCError(error, { operation: "delete object", bucket: TEST_BUCKET, key: TEST_KEY }) + ).toThrow( + expect.objectContaining({ + message: `Failed to delete object — bucket: ${TEST_BUCKET} — key: ${TEST_KEY} — ${errorMessage}`, + }) + ) + }) + }) + + describe("TRPCError pass-through", () => { + it("preserves and re-throws existing TRPCError", () => { + const existingError = new TRPCError({ + code: "BAD_REQUEST", + message: "Custom TRPC error", + }) + + expect(() => mapS3ErrorToTRPCError(existingError, { operation: TEST_OPERATION })).toThrow(existingError) + }) + }) + + describe("unmapped error logging", () => { + let consoleWarnSpy: ReturnType + + beforeEach(() => { + consoleWarnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}) + }) + + afterEach(() => { + consoleWarnSpy.mockRestore() + }) + + it("logs unmapped error codes to console", () => { + const error = Object.assign(new Error("Unknown error"), { Code: "UnmappedErrorCode" }) + + try { + mapS3ErrorToTRPCError(error, { operation: TEST_OPERATION }) + } catch { + // Expected to throw + } + + expect(consoleWarnSpy).toHaveBeenCalledWith("[s3] Unmapped S3 error code: UnmappedErrorCode") + }) + + it("does not log when error code is mapped", () => { + const error = Object.assign(new Error("Known error"), { Code: "NoSuchBucket" }) + + try { + mapS3ErrorToTRPCError(error, { operation: TEST_OPERATION }) + } catch { + // Expected to throw + } + + expect(consoleWarnSpy).not.toHaveBeenCalled() + }) + + it("does not log when error code is empty", () => { + // Create an error without Code or a meaningful name property + const error = { message: "Error without code" } + + try { + mapS3ErrorToTRPCError(error, { operation: TEST_OPERATION }) + } catch { + // Expected to throw + } + + expect(consoleWarnSpy).not.toHaveBeenCalled() + }) + }) +}) diff --git a/apps/aurora-portal/src/server/Storage/routers/containerRouter.test.ts b/apps/aurora-portal/src/server/Storage/routers/ceph/containerRouter.test.ts similarity index 73% rename from apps/aurora-portal/src/server/Storage/routers/containerRouter.test.ts rename to apps/aurora-portal/src/server/Storage/routers/ceph/containerRouter.test.ts index 44971c4f6..1bb900d1d 100644 --- a/apps/aurora-portal/src/server/Storage/routers/containerRouter.test.ts +++ b/apps/aurora-portal/src/server/Storage/routers/ceph/containerRouter.test.ts @@ -1,8 +1,8 @@ import { describe, it, expect, vi, beforeEach } from "vitest" import { TRPCError } from "@trpc/server" -import { AuroraPortalContext } from "../../context" +import { AuroraPortalContext } from "../../../context" import { containerRouter } from "./containerRouter" -import { createCallerFactory, auroraRouter } from "../../trpc" +import { createCallerFactory, auroraRouter } from "../../../trpc" // ============================================================================ // MOCK AWS SDK S3 CLIENT @@ -10,7 +10,7 @@ import { createCallerFactory, auroraRouter } from "../../trpc" const mockSend = vi.fn() -vi.mock("../clients/s3Client", () => ({ +vi.mock("../../clients/s3Client", () => ({ createS3Client: vi.fn(() => ({ send: mockSend })), })) @@ -45,6 +45,15 @@ const createMockContext = (shouldFailAuth = false, hasCredentials = true) => { const mockCephService = { getEndpoint: () => "https://test-ceph.example.com", + availableEndpoints: () => [ + { + region: "test-region", + url: "https://test-ceph.example.com", + interface: "public", + id: "test-id", + region_id: "test-region", + }, + ], } const mockToken = { @@ -56,6 +65,13 @@ const createMockContext = (shouldFailAuth = false, hasCredentials = true) => { name: "test-user", password_expires_at: "", }, + catalog: [ + { + type: "ceph", + name: "ceph", + endpoints: [{ region: "test-region", url: "https://test-ceph.example.com" }], + }, + ], expires_at: "", issued_at: "", methods: [], @@ -90,10 +106,18 @@ const createCaller = createCallerFactory(auroraRouter({ storage: { ceph: { conta describe("buckets.list", () => { beforeEach(() => { vi.clearAllMocks() - mockSend.mockResolvedValue({ + process.env.CEPH_REGION = "ceph-objectstore-st1-test-region" + // First call returns list of buckets + mockSend.mockResolvedValueOnce({ Buckets: [{ Name: TEST_BUCKET_NAME, CreationDate: TEST_CREATION_DATE }], $metadata: { httpStatusCode: 200 }, }) + // Second call returns bucket metadata (ListObjectsV2) + mockSend.mockResolvedValueOnce({ + Contents: [], + KeyCount: 0, + $metadata: { httpStatusCode: 200 }, + }) }) it("returns list of buckets", async () => { @@ -102,10 +126,19 @@ describe("buckets.list", () => { const result = await caller.storage.ceph.containers.list({ project_id: TEST_PROJECT_ID }) - expect(result).toEqual([{ name: TEST_BUCKET_NAME, creationDate: TEST_CREATION_DATE.toISOString() }]) + expect(result).toEqual([ + { + name: TEST_BUCKET_NAME, + creationDate: TEST_CREATION_DATE.toISOString(), + count: 0, + bytes: 0, + last_modified: undefined, + }, + ]) }) it("returns empty array when no buckets exist", async () => { + mockSend.mockReset() mockSend.mockResolvedValue({ Buckets: [], $metadata: { httpStatusCode: 200 } }) const ctx = createMockContext() const caller = createCaller(ctx) @@ -116,6 +149,7 @@ describe("buckets.list", () => { }) it("returns empty array when Buckets is undefined", async () => { + mockSend.mockReset() mockSend.mockResolvedValue({ $metadata: { httpStatusCode: 200 } }) const ctx = createMockContext() const caller = createCaller(ctx) @@ -144,6 +178,7 @@ describe("buckets.list", () => { }) it("maps S3 errors to TRPCError", async () => { + mockSend.mockReset() const s3Error = Object.assign(new Error("Access denied"), { Code: "AccessDenied" }) mockSend.mockRejectedValue(s3Error) const ctx = createMockContext() @@ -152,46 +187,3 @@ describe("buckets.list", () => { await expect(caller.storage.ceph.containers.list({ project_id: TEST_PROJECT_ID })).rejects.toThrow(TRPCError) }) }) - -// ============================================================================ -// buckets.getDetails -// ============================================================================ - -describe("buckets.getDetails", () => { - beforeEach(() => { - vi.clearAllMocks() - mockSend.mockResolvedValue({ $metadata: { httpStatusCode: 200 } }) - }) - - it("returns bucket details", async () => { - const ctx = createMockContext() - const caller = createCaller(ctx) - - const result = await caller.storage.ceph.containers.getDetails({ - project_id: TEST_PROJECT_ID, - containerName: TEST_BUCKET_NAME, - }) - - expect(result).toEqual({ name: TEST_BUCKET_NAME }) - }) - - it("throws NOT_FOUND when bucket does not exist", async () => { - const s3Error = Object.assign(new Error("NoSuchBucket"), { Code: "NoSuchBucket" }) - mockSend.mockRejectedValue(s3Error) - const ctx = createMockContext() - const caller = createCaller(ctx) - - await expect( - caller.storage.ceph.containers.getDetails({ project_id: TEST_PROJECT_ID, containerName: "nonexistent" }) - ).rejects.toMatchObject({ code: "NOT_FOUND" }) - }) - - it("throws UNAUTHORIZED when session is invalid", async () => { - const ctx = createMockContext(true) - const caller = createCaller(ctx) - - await expect( - caller.storage.ceph.containers.getDetails({ project_id: TEST_PROJECT_ID, containerName: TEST_BUCKET_NAME }) - ).rejects.toThrow(new TRPCError({ code: "UNAUTHORIZED", message: "The session is invalid" })) - }) -}) diff --git a/apps/aurora-portal/src/server/Storage/routers/ceph/containerRouter.ts b/apps/aurora-portal/src/server/Storage/routers/ceph/containerRouter.ts new file mode 100644 index 000000000..ed9992c93 --- /dev/null +++ b/apps/aurora-portal/src/server/Storage/routers/ceph/containerRouter.ts @@ -0,0 +1,186 @@ +import { ListBucketsCommand, CreateBucketCommand, DeleteBucketCommand, ListObjectsV2Command } from "@aws-sdk/client-s3" +import { cephProtectedProcedure, cephProcedure } from "../../cephProcedure" +import { mapS3ErrorToTRPCError } from "../../helpers/s3ErrorMapper" +import { projectScopedInputSchema } from "../../../trpc" +import { + containerSchema, + listContainersInputSchema, + createBucketInputSchema, + deleteBucketInputSchema, + type Container, + type S3Status, +} from "../../types/ceph" +import { S3_MAX_KEYS_PER_REQUEST } from "../../constants" + +export const containerRouter = { + status: cephProcedure.input(projectScopedInputSchema).query(async ({ ctx }): Promise => { + return { hasCredentials: !!ctx.cephCredentials } + }), + + /** + * List all buckets with optional metadata (count, bytes, last_modified). + * + * When includeMetadata=false (default): Returns basic bucket info only (fast) + * When includeMetadata=true: Fetches full metadata with controlled concurrency (slower) + * + * Note: Metadata fetching makes one ListObjectsV2 request per bucket, which can be + * expensive for many buckets. Use includeMetadata=true only when necessary. + */ + list: cephProtectedProcedure.input(listContainersInputSchema).query(async ({ input, ctx }): Promise => { + const s3 = ctx.getCephClient() + const { includeMetadata } = input + + try { + const response = await s3.send(new ListBucketsCommand({})) + const buckets = response.Buckets ?? [] + + // If metadata not requested, return buckets with basic info only (fast path) + if (!includeMetadata) { + return buckets.map((bucket) => + containerSchema.parse({ + name: bucket.Name ?? "", + count: 0, + bytes: 0, + last_modified: undefined, + creationDate: bucket.CreationDate?.toISOString(), + }) + ) + } + + // Fetch metadata for each bucket with controlled concurrency (slow path) + // Limit concurrent requests to avoid overwhelming S3 API and hitting rate limits + const CONCURRENCY_LIMIT = 5 + const containersWithMetadata: Container[] = [] + + for (let i = 0; i < buckets.length; i += CONCURRENCY_LIMIT) { + const batch = buckets.slice(i, i + CONCURRENCY_LIMIT) + const batchResults = await Promise.all( + batch.map(async (bucket) => { + const bucketName = bucket.Name ?? "" + + try { + // List objects to get count, total size, and last modified + // IMPORTANT: Using S3_MAX_KEYS_PER_REQUEST means these are ESTIMATES for buckets with >1000 objects: + // - count: Will be capped at 1000 (use KeyCount for actual count up to 1000) + // - bytes: Only sums first 1000 objects + // - last_modified: May miss newer objects beyond the first 1000 + // + // This is a deliberate trade-off: fast response time for UI > perfect accuracy. + // Full pagination would be prohibitively expensive for large buckets in a list view. + const listObjResponse = await s3.send( + new ListObjectsV2Command({ + Bucket: bucketName, + MaxKeys: S3_MAX_KEYS_PER_REQUEST, + }) + ) + + const objects = listObjResponse.Contents ?? [] + const count = listObjResponse.KeyCount ?? 0 + const bytes = objects.reduce((sum, obj) => sum + (obj.Size ?? 0), 0) + + // Get last modified from most recent object + // Objects are typically ordered by key, not date, so we need to find the latest + const lastModified = + objects.length > 0 + ? objects + .reduce( + (latest, obj) => { + const objDate = obj.LastModified + if (!objDate) return latest + if (!latest || objDate > latest) return objDate + return latest + }, + undefined as Date | undefined + ) + ?.toISOString() + : undefined + + return containerSchema.parse({ + name: bucketName, + count, + bytes, + last_modified: lastModified, + creationDate: bucket.CreationDate?.toISOString(), + }) + } catch (error) { + // If bucket is inaccessible or listing fails, return minimal data + console.error(`Failed to get metadata for bucket ${bucketName}:`, error) + return containerSchema.parse({ + name: bucketName, + count: 0, + bytes: 0, + creationDate: bucket.CreationDate?.toISOString(), + }) + } + }) + ) + containersWithMetadata.push(...batchResults) + } + + return containersWithMetadata + } catch (error) { + throw mapS3ErrorToTRPCError(error, { operation: "list containers" }) + } + }), + + /** + * Create a new S3 bucket. + * + * Uses AWS SDK CreateBucketCommand. The AWS SDK automatically adds LocationConstraint + * based on the region configured in the S3 client (resolved from OpenStack service catalog). + * + * Bucket naming rules (validated client-side and by S3 API): + * - 3-63 characters + * - Lowercase letters, numbers, hyphens, periods only + * - Must start/end with letter or number + * - DNS-safe (no consecutive periods, not IP address format) + * - No reserved prefixes/suffixes + * + * @throws TRPCError CONFLICT - bucket already exists + * @throws TRPCError BAD_REQUEST - invalid bucket name + * @throws TRPCError FORBIDDEN - no credentials or access denied + */ + create: cephProtectedProcedure.input(createBucketInputSchema).mutation(async ({ ctx, input }): Promise => { + const s3 = ctx.getCephClient() + const { bucketName } = input + + try { + await s3.send( + new CreateBucketCommand({ + Bucket: bucketName, + }) + ) + return true + } catch (error) { + throw mapS3ErrorToTRPCError(error, { operation: "create bucket", bucket: bucketName }) + } + }), + + /** + * Delete an empty S3 bucket. + * + * Uses AWS SDK DeleteBucketCommand. The bucket must be empty before deletion. + * If the bucket contains objects, S3 returns BucketNotEmpty error (mapped to PRECONDITION_FAILED). + * + * Client-side performs a preflight check to verify the bucket is empty and blocks + * the delete action if objects are found. + * + * @throws TRPCError PRECONDITION_FAILED - bucket not empty + * @throws TRPCError NOT_FOUND - bucket does not exist + * @throws TRPCError FORBIDDEN - no credentials or access denied + */ + delete: cephProtectedProcedure.input(deleteBucketInputSchema).mutation(async ({ ctx, input }): Promise => { + const s3 = ctx.getCephClient() + const { bucketName } = input + + try { + await s3.send(new DeleteBucketCommand({ Bucket: bucketName })) + return true + } catch (error) { + throw mapS3ErrorToTRPCError(error, { + operation: "delete bucket", + bucket: bucketName, + }) + } + }), +} diff --git a/apps/aurora-portal/src/server/Storage/routers/ec2CredentialRouter.test.ts b/apps/aurora-portal/src/server/Storage/routers/ceph/ec2CredentialRouter.test.ts similarity index 99% rename from apps/aurora-portal/src/server/Storage/routers/ec2CredentialRouter.test.ts rename to apps/aurora-portal/src/server/Storage/routers/ceph/ec2CredentialRouter.test.ts index cbfb9140f..b4c997881 100644 --- a/apps/aurora-portal/src/server/Storage/routers/ec2CredentialRouter.test.ts +++ b/apps/aurora-portal/src/server/Storage/routers/ceph/ec2CredentialRouter.test.ts @@ -1,8 +1,8 @@ import { describe, it, expect, vi, beforeEach } from "vitest" import { TRPCError } from "@trpc/server" -import { AuroraPortalContext } from "../../context" +import { AuroraPortalContext } from "../../../context" import { ec2CredentialRouter } from "./ec2CredentialRouter" -import { createCallerFactory, auroraRouter } from "../../trpc" +import { createCallerFactory, auroraRouter } from "../../../trpc" // ============================================================================ // MOCK DATA diff --git a/apps/aurora-portal/src/server/Storage/routers/ec2CredentialRouter.ts b/apps/aurora-portal/src/server/Storage/routers/ceph/ec2CredentialRouter.ts similarity index 99% rename from apps/aurora-portal/src/server/Storage/routers/ec2CredentialRouter.ts rename to apps/aurora-portal/src/server/Storage/routers/ceph/ec2CredentialRouter.ts index 698baa159..c370065d0 100644 --- a/apps/aurora-portal/src/server/Storage/routers/ec2CredentialRouter.ts +++ b/apps/aurora-portal/src/server/Storage/routers/ceph/ec2CredentialRouter.ts @@ -1,13 +1,13 @@ import { TRPCError } from "@trpc/server" import { z } from "zod" import { randomBytes } from "crypto" -import { projectScopedProcedure, projectScopedInputSchema } from "../../trpc" +import { projectScopedProcedure, projectScopedInputSchema } from "../../../trpc" import { ec2CredentialSchema, ec2CredentialWithSecretSchema, type Ec2Credential, type Ec2CredentialWithSecret, -} from "../types/ceph" +} from "../../types/ceph" // ============================================================================ // INTERNAL TYPES (raw Identity API response shapes) diff --git a/apps/aurora-portal/src/server/Storage/routers/ceph/index.ts b/apps/aurora-portal/src/server/Storage/routers/ceph/index.ts new file mode 100644 index 000000000..77c5f4132 --- /dev/null +++ b/apps/aurora-portal/src/server/Storage/routers/ceph/index.ts @@ -0,0 +1,3 @@ +export { containerRouter } from "./containerRouter" +export { objectRouter } from "./objectRouter" +export { ec2CredentialRouter } from "./ec2CredentialRouter" diff --git a/apps/aurora-portal/src/server/Storage/routers/objectRouter.test.ts b/apps/aurora-portal/src/server/Storage/routers/ceph/objectRouter.test.ts similarity index 75% rename from apps/aurora-portal/src/server/Storage/routers/objectRouter.test.ts rename to apps/aurora-portal/src/server/Storage/routers/ceph/objectRouter.test.ts index 9d7ec2a0d..678ead948 100644 --- a/apps/aurora-portal/src/server/Storage/routers/objectRouter.test.ts +++ b/apps/aurora-portal/src/server/Storage/routers/ceph/objectRouter.test.ts @@ -1,8 +1,8 @@ import { describe, it, expect, vi, beforeEach } from "vitest" import { TRPCError } from "@trpc/server" -import { AuroraPortalContext } from "../../context" +import { AuroraPortalContext } from "../../../context" import { objectRouter } from "./objectRouter" -import { createCallerFactory, auroraRouter } from "../../trpc" +import { createCallerFactory, auroraRouter } from "../../../trpc" // ============================================================================ // MOCK AWS SDK S3 CLIENT @@ -10,7 +10,7 @@ import { createCallerFactory, auroraRouter } from "../../trpc" const mockSend = vi.fn() -vi.mock("../clients/s3Client", () => ({ +vi.mock("../../clients/s3Client", () => ({ createS3Client: vi.fn(() => ({ send: mockSend })), })) @@ -47,6 +47,15 @@ const createMockContext = (shouldFailAuth = false, hasCredentials = true) => { const mockCephService = { getEndpoint: () => "https://test-ceph.example.com", + availableEndpoints: () => [ + { + region: "test-region", + url: "https://test-ceph.example.com", + interface: "public", + id: "test-id", + region_id: "test-region", + }, + ], } const mockToken = { @@ -58,6 +67,13 @@ const createMockContext = (shouldFailAuth = false, hasCredentials = true) => { name: "test-user", password_expires_at: "", }, + catalog: [ + { + type: "ceph", + name: "ceph", + endpoints: [{ region: "test-region", url: "https://test-ceph.example.com" }], + }, + ], expires_at: "", issued_at: "", methods: [], @@ -92,6 +108,7 @@ const createCaller = createCallerFactory(auroraRouter({ storage: { ceph: { objec describe("objects.list", () => { beforeEach(() => { vi.clearAllMocks() + process.env.CEPH_REGION = "ceph-objectstore-st1-test-region" }) it("returns list of objects and folders", async () => { @@ -281,6 +298,7 @@ describe("objects.list", () => { describe("objects.getDetails", () => { beforeEach(() => { vi.clearAllMocks() + process.env.CEPH_REGION = "ceph-objectstore-st1-test-region" }) it("returns object metadata", async () => { @@ -415,3 +433,122 @@ describe("objects.getDetails", () => { ).rejects.toThrow(TRPCError) }) }) + +// ============================================================================ +// TESTS: deleteAll +// ============================================================================ + +describe("objects.deleteAll", () => { + beforeEach(() => { + vi.clearAllMocks() + process.env.CEPH_REGION = "ceph-objectstore-st1-test-region" + }) + + it("deletes all objects from a bucket and returns count", async () => { + const ctx = createMockContext() + const caller = createCaller(ctx) + + // Mock list response with objects + mockSend.mockResolvedValueOnce({ + Contents: [{ Key: "file1.txt" }, { Key: "file2.txt" }, { Key: "file3.txt" }], + IsTruncated: false, + }) + + // Mock delete response + mockSend.mockResolvedValueOnce({ + Deleted: [{ Key: "file1.txt" }, { Key: "file2.txt" }, { Key: "file3.txt" }], + }) + + const result = await caller.storage.ceph.objects.deleteAll({ + project_id: TEST_PROJECT_ID, + containerName: TEST_BUCKET_NAME, + }) + + expect(result).toBe(3) + }) + + it("handles paginated deletion with multiple batches", async () => { + const ctx = createMockContext() + const caller = createCaller(ctx) + + // First batch + mockSend.mockResolvedValueOnce({ + Contents: [{ Key: "file1.txt" }, { Key: "file2.txt" }], + IsTruncated: true, + NextContinuationToken: "token1", + }) + mockSend.mockResolvedValueOnce({ + Deleted: [{ Key: "file1.txt" }, { Key: "file2.txt" }], + }) + + // Second batch + mockSend.mockResolvedValueOnce({ + Contents: [{ Key: "file3.txt" }], + IsTruncated: false, + }) + mockSend.mockResolvedValueOnce({ + Deleted: [{ Key: "file3.txt" }], + }) + + const result = await caller.storage.ceph.objects.deleteAll({ + project_id: TEST_PROJECT_ID, + containerName: TEST_BUCKET_NAME, + }) + + expect(result).toBe(3) + }) + + it("returns 0 when bucket is empty", async () => { + const ctx = createMockContext() + const caller = createCaller(ctx) + + mockSend.mockResolvedValueOnce({ + Contents: [], + IsTruncated: false, + }) + + const result = await caller.storage.ceph.objects.deleteAll({ + project_id: TEST_PROJECT_ID, + containerName: TEST_BUCKET_NAME, + }) + + expect(result).toBe(0) + }) + + it("throws error when objects have undefined keys", async () => { + const ctx = createMockContext() + const caller = createCaller(ctx) + + // Mock list response with object missing Key + mockSend.mockResolvedValueOnce({ + Contents: [ + { Key: "file1.txt" }, + { Key: undefined }, // Invalid object without key + { Key: "file3.txt" }, + ], + IsTruncated: false, + }) + + await expect( + caller.storage.ceph.objects.deleteAll({ + project_id: TEST_PROJECT_ID, + containerName: TEST_BUCKET_NAME, + }) + ).rejects.toThrow(/Encountered 1 object\(s\) without Key field/) + }) + + it("throws NOT_FOUND when bucket does not exist", async () => { + const ctx = createMockContext() + const caller = createCaller(ctx) + + const s3Error = Object.assign(new Error("NoSuchBucket"), { Code: "NoSuchBucket" }) + mockSend.mockRejectedValue(s3Error) + + await expect( + caller.storage.ceph.objects.deleteAll({ + project_id: TEST_PROJECT_ID, + containerName: "nonexistent", + }) + ).rejects.toMatchObject({ code: "NOT_FOUND" }) + }) +}) diff --git a/apps/aurora-portal/src/server/Storage/routers/ceph/objectRouter.ts b/apps/aurora-portal/src/server/Storage/routers/ceph/objectRouter.ts new file mode 100644 index 000000000..2bc13e026 --- /dev/null +++ b/apps/aurora-portal/src/server/Storage/routers/ceph/objectRouter.ts @@ -0,0 +1,181 @@ +import { ListObjectsV2Command, HeadObjectCommand, DeleteObjectsCommand } from "@aws-sdk/client-s3" +import { TRPCError } from "@trpc/server" +import { cephProtectedProcedure } from "../../cephProcedure" +import { mapS3ErrorToTRPCError } from "../../helpers/s3ErrorMapper" +import { + listObjectsInputSchema, + listObjectsOutputSchema, + getObjectDetailsInputSchema, + s3ObjectDetailsSchema, + s3ObjectSchema, + s3FolderPrefixSchema, + type ListObjectsOutput, + type S3ObjectDetails, +} from "../../types/ceph" +import { S3_MAX_KEYS_PER_REQUEST } from "../../constants" +import { z } from "zod" + +const deleteAllObjectsInputSchema = z.object({ + project_id: z.string(), + containerName: z.string().min(1), +}) + +export const objectRouter = { + /** + * List objects in a container with optional prefix filtering and pagination. + * Returns both objects and "folders" (CommonPrefixes). + */ + list: cephProtectedProcedure + .input(listObjectsInputSchema) + .query(async ({ ctx, input }): Promise => { + const s3 = ctx.getCephClient!() + const { containerName, prefix, delimiter = "/", maxKeys, continuationToken } = input + + try { + const response = await s3.send( + new ListObjectsV2Command({ + Bucket: containerName, + Prefix: prefix || undefined, + Delimiter: delimiter || undefined, + MaxKeys: maxKeys, + ContinuationToken: continuationToken, + }) + ) + + const objects = (response.Contents ?? []).map((obj) => + s3ObjectSchema.parse({ + key: obj.Key ?? "", + lastModified: obj.LastModified?.toISOString(), + size: obj.Size ?? 0, + etag: obj.ETag, + storageClass: obj.StorageClass, + }) + ) + + const folders = (response.CommonPrefixes ?? []).map((cp) => + s3FolderPrefixSchema.parse({ + prefix: cp.Prefix ?? "", + }) + ) + + return listObjectsOutputSchema.parse({ + objects, + folders, + isTruncated: response.IsTruncated ?? false, + nextContinuationToken: response.NextContinuationToken, + }) + } catch (error) { + throw mapS3ErrorToTRPCError(error, { + operation: "list objects", + bucket: containerName, + }) + } + }), + + /** + * Get detailed metadata for a specific object. + */ + getDetails: cephProtectedProcedure + .input(getObjectDetailsInputSchema) + .query(async ({ ctx, input }): Promise => { + const s3 = ctx.getCephClient!() + const { containerName, objectKey } = input + + try { + const response = await s3.send( + new HeadObjectCommand({ + Bucket: containerName, + Key: objectKey, + }) + ) + + return s3ObjectDetailsSchema.parse({ + key: objectKey, + size: response.ContentLength ?? 0, + lastModified: response.LastModified?.toISOString(), + etag: response.ETag, + contentType: response.ContentType, + storageClass: response.StorageClass, + metadata: response.Metadata, + }) + } catch (error) { + throw mapS3ErrorToTRPCError(error, { + operation: "get object details", + bucket: containerName, + key: objectKey, + }) + } + }), + + /** + * Delete all objects in a bucket (empty the bucket). + * Uses batched DeleteObjectsCommand for efficiency (up to 1000 objects per request). + * Loops until all objects are deleted. + */ + deleteAll: cephProtectedProcedure + .input(deleteAllObjectsInputSchema) + .mutation(async ({ ctx, input }): Promise => { + const s3 = ctx.getCephClient!() + const { containerName } = input + let totalDeleted = 0 + let continuationToken: string | undefined + + try { + // Loop until all objects are deleted + while (true) { + // List next batch of objects + const listResponse = await s3.send( + new ListObjectsV2Command({ + Bucket: containerName, + MaxKeys: S3_MAX_KEYS_PER_REQUEST, + ContinuationToken: continuationToken, + }) + ) + + const objects = listResponse.Contents ?? [] + if (objects.length === 0) break + + // Validate all objects have keys before deletion + const objectsWithoutKeys = objects.filter((obj) => !obj.Key) + if (objectsWithoutKeys.length > 0) { + throw new Error( + `Encountered ${objectsWithoutKeys.length} object(s) without Key field in S3 list response. Cannot proceed with deletion.` + ) + } + + // Batch delete (up to 1000 objects per request) + const objectsToDelete = objects.map((obj) => ({ Key: obj.Key! })) + + const deleteResponse = await s3.send( + new DeleteObjectsCommand({ + Bucket: containerName, + Delete: { Objects: objectsToDelete }, + }) + ) + + const deletedCount = deleteResponse.Deleted?.length ?? 0 + totalDeleted += deletedCount + + // Check for errors + if (deleteResponse.Errors && deleteResponse.Errors.length > 0) { + const errorKeys = deleteResponse.Errors.map((e) => e.Key).join(", ") + throw new TRPCError({ + code: "INTERNAL_SERVER_ERROR", + message: `Failed to delete some objects: ${errorKeys}`, + }) + } + + // Continue if there are more objects + if (!listResponse.IsTruncated) break + continuationToken = listResponse.NextContinuationToken + } + + return totalDeleted + } catch (error) { + throw mapS3ErrorToTRPCError(error, { + operation: "delete all objects", + bucket: containerName, + }) + } + }), +} diff --git a/apps/aurora-portal/src/server/Storage/routers/containerRouter.ts b/apps/aurora-portal/src/server/Storage/routers/containerRouter.ts deleted file mode 100644 index b35cd2589..000000000 --- a/apps/aurora-portal/src/server/Storage/routers/containerRouter.ts +++ /dev/null @@ -1,52 +0,0 @@ -import { ListBucketsCommand, HeadBucketCommand } from "@aws-sdk/client-s3" -import { cephProtectedProcedure, cephProcedure } from "../cephProcedure" -import { mapS3ErrorToTRPCError } from "../helpers/s3ErrorMapper" -import { projectScopedInputSchema } from "../../trpc" -import { - containerSchema, - containerDetailsSchema, - listContainersInputSchema, - getContainerDetailsInputSchema, - type Container, - type ContainerDetails, - type S3Status, -} from "../types/ceph" - -export const containerRouter = { - status: cephProcedure.input(projectScopedInputSchema).query(async ({ ctx }): Promise => { - return { hasCredentials: !!ctx.cephCredentials } - }), - - list: cephProtectedProcedure.input(listContainersInputSchema).query(async ({ ctx }): Promise => { - const s3 = ctx.getCephClient() - - try { - const response = await s3.send(new ListBucketsCommand({})) - return (response.Buckets ?? []).map((b) => - containerSchema.parse({ - name: b.Name ?? "", - creationDate: b.CreationDate?.toISOString(), - }) - ) - } catch (error) { - throw mapS3ErrorToTRPCError(error, { operation: "list containers" }) - } - }), - - getDetails: cephProtectedProcedure - .input(getContainerDetailsInputSchema) - .query(async ({ ctx, input }): Promise => { - const s3 = ctx.getCephClient() - const { containerName } = input - - try { - await s3.send(new HeadBucketCommand({ Bucket: containerName })) - - return containerDetailsSchema.parse({ - name: containerName, - }) - } catch (error) { - throw mapS3ErrorToTRPCError(error, { operation: "get container details", bucket: containerName }) - } - }), -} diff --git a/apps/aurora-portal/src/server/Storage/routers/index.ts b/apps/aurora-portal/src/server/Storage/routers/index.ts index a22a6bdc8..d05fd6d1b 100644 --- a/apps/aurora-portal/src/server/Storage/routers/index.ts +++ b/apps/aurora-portal/src/server/Storage/routers/index.ts @@ -1,7 +1,5 @@ -import { swiftRouter } from "./swiftRouter" -import { ec2CredentialRouter } from "./ec2CredentialRouter" -import { containerRouter } from "./containerRouter" -import { objectRouter } from "./objectRouter" +import { swiftRouter } from "./swift" +import { ec2CredentialRouter, containerRouter, objectRouter } from "./ceph" import { auroraRouter } from "../../trpc" export const objectStorageRouters = { diff --git a/apps/aurora-portal/src/server/Storage/routers/objectRouter.ts b/apps/aurora-portal/src/server/Storage/routers/objectRouter.ts deleted file mode 100644 index 06a5fa7e0..000000000 --- a/apps/aurora-portal/src/server/Storage/routers/objectRouter.ts +++ /dev/null @@ -1,101 +0,0 @@ -import { ListObjectsV2Command, HeadObjectCommand } from "@aws-sdk/client-s3" -import { cephProtectedProcedure } from "../cephProcedure" -import { mapS3ErrorToTRPCError } from "../helpers/s3ErrorMapper" -import { - listObjectsInputSchema, - listObjectsOutputSchema, - getObjectDetailsInputSchema, - s3ObjectDetailsSchema, - s3ObjectSchema, - s3FolderPrefixSchema, - type ListObjectsOutput, - type S3ObjectDetails, -} from "../types/ceph" - -export const objectRouter = { - /** - * List objects in a container with optional prefix filtering and pagination. - * Returns both objects and "folders" (CommonPrefixes). - */ - list: cephProtectedProcedure - .input(listObjectsInputSchema) - .query(async ({ ctx, input }): Promise => { - const s3 = ctx.getCephClient!() - const { containerName, prefix, delimiter = "/", maxKeys, continuationToken } = input - - try { - const response = await s3.send( - new ListObjectsV2Command({ - Bucket: containerName, - Prefix: prefix || undefined, - Delimiter: delimiter || undefined, - MaxKeys: maxKeys, - ContinuationToken: continuationToken, - }) - ) - - const objects = (response.Contents ?? []).map((obj) => - s3ObjectSchema.parse({ - key: obj.Key ?? "", - lastModified: obj.LastModified?.toISOString(), - size: obj.Size ?? 0, - etag: obj.ETag, - storageClass: obj.StorageClass, - }) - ) - - const folders = (response.CommonPrefixes ?? []).map((cp) => - s3FolderPrefixSchema.parse({ - prefix: cp.Prefix ?? "", - }) - ) - - return listObjectsOutputSchema.parse({ - objects, - folders, - isTruncated: response.IsTruncated ?? false, - nextContinuationToken: response.NextContinuationToken, - }) - } catch (error) { - throw mapS3ErrorToTRPCError(error, { - operation: "list objects", - bucket: containerName, - }) - } - }), - - /** - * Get detailed metadata for a specific object. - */ - getDetails: cephProtectedProcedure - .input(getObjectDetailsInputSchema) - .query(async ({ ctx, input }): Promise => { - const s3 = ctx.getCephClient!() - const { containerName, objectKey } = input - - try { - const response = await s3.send( - new HeadObjectCommand({ - Bucket: containerName, - Key: objectKey, - }) - ) - - return s3ObjectDetailsSchema.parse({ - key: objectKey, - size: response.ContentLength ?? 0, - lastModified: response.LastModified?.toISOString(), - etag: response.ETag, - contentType: response.ContentType, - storageClass: response.StorageClass, - metadata: response.Metadata, - }) - } catch (error) { - throw mapS3ErrorToTRPCError(error, { - operation: "get object details", - bucket: containerName, - key: objectKey, - }) - } - }), -} diff --git a/apps/aurora-portal/src/server/Storage/routers/swift/index.ts b/apps/aurora-portal/src/server/Storage/routers/swift/index.ts new file mode 100644 index 000000000..0c96d0381 --- /dev/null +++ b/apps/aurora-portal/src/server/Storage/routers/swift/index.ts @@ -0,0 +1 @@ +export { swiftRouter } from "./swiftRouter" diff --git a/apps/aurora-portal/src/server/Storage/routers/swiftRouter.test.ts b/apps/aurora-portal/src/server/Storage/routers/swift/swiftRouter.test.ts similarity index 99% rename from apps/aurora-portal/src/server/Storage/routers/swiftRouter.test.ts rename to apps/aurora-portal/src/server/Storage/routers/swift/swiftRouter.test.ts index 09204caaa..45b56fcb5 100644 --- a/apps/aurora-portal/src/server/Storage/routers/swiftRouter.test.ts +++ b/apps/aurora-portal/src/server/Storage/routers/swift/swiftRouter.test.ts @@ -1,9 +1,9 @@ import { describe, it, expect, vi, beforeEach, Mock } from "vitest" import { TRPCError } from "@trpc/server" import { Readable } from "node:stream" -import { AuroraPortalContext } from "../../context" +import { AuroraPortalContext } from "../../../context" import { swiftRouter } from "./swiftRouter" -import * as swiftHelpers from "../helpers/swiftHelpers" +import * as swiftHelpers from "../../helpers/swiftHelpers" import { ContainerSummary, ObjectSummary, @@ -11,11 +11,11 @@ import { ContainerInfo, ObjectMetadata, ServiceInfo, -} from "../types/swift" -import { createCallerFactory, auroraRouter } from "../../trpc" +} from "../../types/swift" +import { createCallerFactory, auroraRouter } from "../../../trpc" // Mock the helpers -vi.mock("../helpers/swiftHelpers", async (importOriginal) => { +vi.mock("../../helpers/swiftHelpers", async (importOriginal) => { const actual: object = await importOriginal() return { @@ -1864,7 +1864,7 @@ describe("swiftRouter", () => { /** Default validated result returned by the validateSwiftUploadInput mock. */ const makeValidatedUploadInput = ( - overrides?: Partial> + overrides?: Partial> ) => ({ validatedContainer: "test-container", validatedObject: "folder/sample.txt", diff --git a/apps/aurora-portal/src/server/Storage/routers/swiftRouter.ts b/apps/aurora-portal/src/server/Storage/routers/swift/swiftRouter.ts similarity index 99% rename from apps/aurora-portal/src/server/Storage/routers/swiftRouter.ts rename to apps/aurora-portal/src/server/Storage/routers/swift/swiftRouter.ts index 0d226033d..3e562778d 100644 --- a/apps/aurora-portal/src/server/Storage/routers/swiftRouter.ts +++ b/apps/aurora-portal/src/server/Storage/routers/swift/swiftRouter.ts @@ -2,7 +2,7 @@ import { TRPCError } from "@trpc/server" import { z } from "zod" import EventEmitter from "node:events" import { Readable, Transform } from "node:stream" -import { protectedProcedure, projectScopedProcedure, projectScopedInputSchema } from "../../trpc" +import { protectedProcedure, projectScopedProcedure, projectScopedInputSchema } from "../../../trpc" import { octetInputParser } from "@trpc/server/http" import { validateSwiftService, @@ -22,7 +22,7 @@ import { isFolderMarker, generateTempUrlSignature, constructTempUrl, -} from "../helpers/swiftHelpers" +} from "../../helpers/swiftHelpers" import { listContainersInputSchema, updateAccountMetadataInputSchema, @@ -56,7 +56,7 @@ import { TempUrl, generateTempUrlInputSchema, downloadObjectInputSchema, -} from "../types/swift" +} from "../../types/swift" // ============================================================================ // UPLOAD PROGRESS TRACKING diff --git a/apps/aurora-portal/src/server/Storage/types/ceph.ts b/apps/aurora-portal/src/server/Storage/types/ceph.ts index 0c50ecb1c..d74590f6a 100644 --- a/apps/aurora-portal/src/server/Storage/types/ceph.ts +++ b/apps/aurora-portal/src/server/Storage/types/ceph.ts @@ -35,20 +35,32 @@ export type Ec2CredentialWithSecret = z.infer1000 objects. + * The list endpoint uses S3_MAX_KEYS_PER_REQUEST for performance, so these values are based on + * a sample of objects. For accurate counts, pagination would be needed (expensive). + */ export const containerSchema = z.object({ name: z.string(), - creationDate: z.string().optional(), + count: z.number().default(0), // Estimated number of objects (based on first 1000) + bytes: z.number().default(0), // Estimated total size in bytes (based on first 1000) + last_modified: z.string().optional(), // ISO date string (may not be the absolute latest if >1000 objects) + creationDate: z.string().optional(), // Bucket creation date (Ceph-specific, accurate) }) -export const containerDetailsSchema = containerSchema.extend({ - objectCount: z.number().optional(), - sizeBytes: z.number().optional(), +export const listContainersInputSchema = projectScopedInputSchema.extend({ + includeMetadata: z.boolean().optional().default(false), }) -export const listContainersInputSchema = projectScopedInputSchema +export const createBucketInputSchema = projectScopedInputSchema.extend({ + bucketName: z.string().min(3).max(63), +}) -export const getContainerDetailsInputSchema = projectScopedInputSchema.extend({ - containerName: z.string().min(1), +export const deleteBucketInputSchema = projectScopedInputSchema.extend({ + bucketName: z.string().min(1), }) // ============================================================================ @@ -56,7 +68,6 @@ export const getContainerDetailsInputSchema = projectScopedInputSchema.extend({ // ============================================================================ export type Container = z.infer -export type ContainerDetails = z.infer // ============================================================================ // S3 STATUS SCHEMAS