From c2bf8cf56fb6e33a5df5aff1ee22b228cbd3393f Mon Sep 17 00:00:00 2001 From: Kirill Ilin Date: Mon, 2 Mar 2026 21:26:51 +0500 Subject: [PATCH] feat(dashboard): add storageClass dropdown for all stateful apps Replace plain text input with an API-backed listInput dropdown for storageClass fields across all applications that expose them. The dropdown fetches available StorageClasses from the cluster via /api/clusters/{cluster}/k8s/apis/storage.k8s.io/v1/storageclasses, following the same pattern as the instanceType dropdown for VMInstance. Top-level spec.storageClass: ClickHouse, Harbor, HTTPCache, Kubernetes, MariaDB, MongoDB, NATS, OpenBAO, Postgres, Qdrant, RabbitMQ, Redis, VMDisk. Nested paths: FoundationDB (spec.storage.storageClass), Kafka (spec.kafka.storageClass and spec.zookeeper.storageClass). Co-Authored-By: Claude Signed-off-by: Kirill Ilin --- .../dashboard/customformsoverride.go | 28 ++++++++ .../dashboard/customformsoverride_test.go | 66 +++++++++++++++++++ 2 files changed, 94 insertions(+) diff --git a/internal/controller/dashboard/customformsoverride.go b/internal/controller/dashboard/customformsoverride.go index 67b131cf..97912fea 100644 --- a/internal/controller/dashboard/customformsoverride.go +++ b/internal/controller/dashboard/customformsoverride.go @@ -214,6 +214,34 @@ func applyListInputOverrides(schema map[string]any, kind string, openAPIProps ma "keysToLabel": []any{"metadata", "name"}, }, } + + case "ClickHouse", "Harbor", "HTTPCache", "Kubernetes", "MariaDB", "MongoDB", + "NATS", "OpenBAO", "Postgres", "Qdrant", "RabbitMQ", "Redis", "VMDisk": + specProps := ensureSchemaPath(schema, "spec") + specProps["storageClass"] = storageClassListInput() + + case "FoundationDB": + storageProps := ensureSchemaPath(schema, "spec", "storage") + storageProps["storageClass"] = storageClassListInput() + + case "Kafka": + kafkaProps := ensureSchemaPath(schema, "spec", "kafka") + kafkaProps["storageClass"] = storageClassListInput() + zkProps := ensureSchemaPath(schema, "spec", "zookeeper") + zkProps["storageClass"] = storageClassListInput() + } +} + +// storageClassListInput returns a listInput field config for a storageClass dropdown +// backed by the cluster's available StorageClasses. +func storageClassListInput() map[string]any { + return map[string]any{ + "type": "listInput", + "customProps": map[string]any{ + "valueUri": "/api/clusters/{cluster}/k8s/apis/storage.k8s.io/v1/storageclasses", + "keysToValue": []any{"metadata", "name"}, + "keysToLabel": []any{"metadata", "name"}, + }, } } diff --git a/internal/controller/dashboard/customformsoverride_test.go b/internal/controller/dashboard/customformsoverride_test.go index 37da1244..2677cfa8 100644 --- a/internal/controller/dashboard/customformsoverride_test.go +++ b/internal/controller/dashboard/customformsoverride_test.go @@ -232,6 +232,72 @@ func TestApplyListInputOverrides_VMInstance(t *testing.T) { } } +func TestApplyListInputOverrides_StorageClassSimple(t *testing.T) { + for _, kind := range []string{ + "ClickHouse", "Harbor", "HTTPCache", "Kubernetes", "MariaDB", "MongoDB", + "NATS", "OpenBAO", "Postgres", "Qdrant", "RabbitMQ", "Redis", "VMDisk", + } { + t.Run(kind, func(t *testing.T) { + schema := map[string]any{} + applyListInputOverrides(schema, kind, map[string]any{}) + + specProps := schema["properties"].(map[string]any)["spec"].(map[string]any)["properties"].(map[string]any) + sc, ok := specProps["storageClass"].(map[string]any) + if !ok { + t.Fatalf("storageClass not found in spec.properties for kind %s", kind) + } + assertStorageClassListInput(t, sc) + }) + } +} + +func TestApplyListInputOverrides_StorageClassFoundationDB(t *testing.T) { + schema := map[string]any{} + applyListInputOverrides(schema, "FoundationDB", map[string]any{}) + + storageProps := schema["properties"].(map[string]any)["spec"].(map[string]any)["properties"].(map[string]any)["storage"].(map[string]any)["properties"].(map[string]any) + sc, ok := storageProps["storageClass"].(map[string]any) + if !ok { + t.Fatal("storageClass not found in spec.storage.properties") + } + assertStorageClassListInput(t, sc) +} + +func TestApplyListInputOverrides_StorageClassKafka(t *testing.T) { + schema := map[string]any{} + applyListInputOverrides(schema, "Kafka", map[string]any{}) + + specProps := schema["properties"].(map[string]any)["spec"].(map[string]any)["properties"].(map[string]any) + + kafkaSC, ok := specProps["kafka"].(map[string]any)["properties"].(map[string]any)["storageClass"].(map[string]any) + if !ok { + t.Fatal("storageClass not found in spec.kafka.properties") + } + assertStorageClassListInput(t, kafkaSC) + + zkSC, ok := specProps["zookeeper"].(map[string]any)["properties"].(map[string]any)["storageClass"].(map[string]any) + if !ok { + t.Fatal("storageClass not found in spec.zookeeper.properties") + } + assertStorageClassListInput(t, zkSC) +} + +// assertStorageClassListInput verifies that a field is a correctly configured storageClass listInput. +func assertStorageClassListInput(t *testing.T, field map[string]any) { + t.Helper() + if field["type"] != "listInput" { + t.Errorf("expected type listInput, got %v", field["type"]) + } + customProps, ok := field["customProps"].(map[string]any) + if !ok { + t.Fatal("customProps not found") + } + expectedURI := "/api/clusters/{cluster}/k8s/apis/storage.k8s.io/v1/storageclasses" + if customProps["valueUri"] != expectedURI { + t.Errorf("expected valueUri %s, got %v", expectedURI, customProps["valueUri"]) + } +} + func TestApplyListInputOverrides_UnknownKind(t *testing.T) { schema := map[string]any{} applyListInputOverrides(schema, "SomeOtherKind", map[string]any{})