diff --git a/director/director_ui.go b/director/director_ui.go index 2930a217a..79e343d83 100644 --- a/director/director_ui.go +++ b/director/director_ui.go @@ -29,6 +29,7 @@ import ( "github.com/pelicanplatform/pelican/param" "github.com/pelicanplatform/pelican/server_structs" + "github.com/pelicanplatform/pelican/utils" "github.com/pelicanplatform/pelican/web_ui" ) @@ -37,6 +38,7 @@ type ( ServerType string `form:"server_type"` // "cache" or "origin" } + // A response struct for a server Ad that provides a minimal view into the servers data listServerResponse struct { Name string `json:"name"` StorageType server_structs.OriginStorageType `json:"storageType"` @@ -60,6 +62,65 @@ type ( NamespacePrefixes []string `json:"namespacePrefixes"` } + // A response struct for a server Ad that provides a detailed view into the servers data + serverResponse struct { + Name string `json:"name"` + StorageType server_structs.OriginStorageType `json:"storageType"` + DisableDirectorTest bool `json:"disableDirectorTest"` + // AuthURL is Deprecated. For Pelican severs, URL is used as the base URL for object access. + // This is to maintain compatibility with the topology servers, where it uses AuthURL for + // accessing protected objects and URL for public objects. + AuthURL string `json:"authUrl"` + BrokerURL string `json:"brokerUrl"` + URL string `json:"url"` // This is server's XRootD URL for file transfer + WebURL string `json:"webUrl"` // This is server's Web interface and API + Type string `json:"type"` + Latitude float64 `json:"latitude"` + Longitude float64 `json:"longitude"` + Caps server_structs.Capabilities `json:"capabilities"` + Filtered bool `json:"filtered"` + FilteredType string `json:"filteredType"` + FromTopology bool `json:"fromTopology"` + HealthStatus HealthTestStatus `json:"healthStatus"` + IOLoad float64 `json:"ioLoad"` + Namespaces []NamespaceAdV2Response `json:"namespaces"` + } + + // TokenIssuerResponse creates a response struct for TokenIssuer + TokenIssuerResponse struct { + BasePaths []string `json:"basePaths"` + RestrictedPaths []string `json:"restrictedPaths"` + IssuerUrl string `json:"issuer"` + } + + // TokenGenResponse creates a response struct for TokenGen + TokenGenResponse struct { + Strategy server_structs.StrategyType `json:"strategy"` + VaultServer string `json:"vaultServer"` + MaxScopeDepth uint `json:"maxScopeDepth"` + CredentialIssuer string `json:"issuer"` + } + + // NamespaceAdV2Response creates a response struct for NamespaceAdV2 + NamespaceAdV2Response struct { + Path string `json:"path"` + Caps server_structs.Capabilities `json:"capabilities"` + Generation []TokenGenResponse `json:"tokenGeneration"` + Issuer []TokenIssuerResponse `json:"tokenIssuer"` + FromTopology bool `json:"fromTopology"` + } + + // NamespaceAdV2MappedResponse creates a response struct for NamespaceAdV2 with mapped origins and caches + NamespaceAdV2MappedResponse struct { + Path string `json:"path"` + Caps server_structs.Capabilities `json:"capabilities"` + Generation []TokenGenResponse `json:"tokenGeneration"` + Issuer []TokenIssuerResponse `json:"tokenIssuer"` + FromTopology bool `json:"fromTopology"` + Origins []string `json:"origins"` + Caches []string `json:"caches"` + } + statRequest struct { MinResponses int `form:"min_responses"` MaxResponses int `form:"max_responses"` @@ -107,46 +168,207 @@ func listServers(ctx *gin.Context) { defer healthTestUtilsMutex.RUnlock() resList := make([]listServerResponse, 0) for _, server := range servers { - healthStatus := HealthStatusUnknown - healthUtil, ok := healthTestUtils[server.URL.String()] - if ok { - healthStatus = healthUtil.Status + res := advertisementToServerResponse(server) + listRes := serverResponseToListServerResponse(res) + resList = append(resList, listRes) + } + ctx.JSON(http.StatusOK, resList) +} + +// Convert NamespaceAdV2 to namespaceResponse +func namespaceAdV2ToResponse(ns *server_structs.NamespaceAdV2) NamespaceAdV2Response { + res := NamespaceAdV2Response{ + Path: ns.Path, + Caps: ns.Caps, + FromTopology: ns.FromTopology, + } + for _, gen := range ns.Generation { + res.Generation = append(res.Generation, TokenGenResponse{ + Strategy: gen.Strategy, + VaultServer: gen.VaultServer, + MaxScopeDepth: gen.MaxScopeDepth, + CredentialIssuer: gen.CredentialIssuer.String(), + }) + } + for _, issuer := range ns.Issuer { + res.Issuer = append(res.Issuer, TokenIssuerResponse{ + BasePaths: issuer.BasePaths, + RestrictedPaths: issuer.RestrictedPaths, + IssuerUrl: issuer.IssuerUrl.String(), + }) + } + return res +} + +// namespaceAdV2ToMappedResponse converts a NamespaceAdV2 to a NamespaceAdV2MappedResponse +func namespaceAdV2ToMappedResponse(ns *server_structs.NamespaceAdV2) NamespaceAdV2MappedResponse { + nsRes := namespaceAdV2ToResponse(ns) + return NamespaceAdV2MappedResponse{ + Path: nsRes.Path, + Caps: nsRes.Caps, + Generation: nsRes.Generation, + Issuer: nsRes.Issuer, + Origins: []string{}, + Caches: []string{}, + } +} + +// Convert Advertisement to serverResponse +func advertisementToServerResponse(ad *server_structs.Advertisement) serverResponse { + healthStatus := HealthStatusUnknown + healthUtil, ok := healthTestUtils[ad.URL.String()] + if ok { + healthStatus = healthUtil.Status + } else { + if ad.DisableDirectorTest { + healthStatus = HealthStatusDisabled } else { - if server.DisableDirectorTest { - healthStatus = HealthStatusDisabled - } else { - if !server.FromTopology { - log.Debugf("listServers: healthTestUtils not found for server at %s", server.URL.String()) - } + if !ad.FromTopology { + log.Debugf("advertisementToServerResponse: healthTestUtils not found for server at %s", ad.URL.String()) } } - filtered, ft := checkFilter(server.Name) - - res := listServerResponse{ - Name: server.Name, - StorageType: server.StorageType, - DisableDirectorTest: server.DisableDirectorTest, - BrokerURL: server.BrokerURL.String(), - // For web UI, if authURL is not set, we don't want to confuse user by copying server URL as authURL - AuthURL: server.AuthURL.String(), - URL: server.URL.String(), - WebURL: server.WebURL.String(), - Type: server.Type, - Latitude: server.Latitude, - Longitude: server.Longitude, - Caps: server.Caps, - Filtered: filtered, - FilteredType: ft.String(), - FromTopology: server.FromTopology, - HealthStatus: healthStatus, - IOLoad: server.GetIOLoad(), + } + filtered, ft := checkFilter(ad.Name) + res := serverResponse{ + Name: ad.Name, + StorageType: ad.StorageType, + DisableDirectorTest: ad.DisableDirectorTest, + BrokerURL: ad.BrokerURL.String(), + AuthURL: ad.AuthURL.String(), + URL: ad.URL.String(), + WebURL: ad.WebURL.String(), + Type: ad.Type, + Latitude: ad.Latitude, + Longitude: ad.Longitude, + Caps: ad.Caps, + Filtered: filtered, + FilteredType: ft.String(), + FromTopology: ad.FromTopology, + HealthStatus: healthStatus, + IOLoad: ad.GetIOLoad(), + } + for _, ns := range ad.NamespaceAds { + nsRes := namespaceAdV2ToResponse(&ns) + res.Namespaces = append(res.Namespaces, nsRes) + } + return res +} + +// Convert serverResponse to a listServerResponse +func serverResponseToListServerResponse(res serverResponse) listServerResponse { + listRes := listServerResponse{ + Name: res.Name, + StorageType: res.StorageType, + DisableDirectorTest: res.DisableDirectorTest, + BrokerURL: res.BrokerURL, + AuthURL: res.AuthURL, + URL: res.URL, + WebURL: res.WebURL, + Type: res.Type, + Latitude: res.Latitude, + Longitude: res.Longitude, + Caps: res.Caps, + Filtered: res.Filtered, + FilteredType: res.FilteredType, + FromTopology: res.FromTopology, + HealthStatus: res.HealthStatus, + IOLoad: res.IOLoad, + } + for _, ns := range res.Namespaces { + listRes.NamespacePrefixes = append(listRes.NamespacePrefixes, ns.Path) + } + return listRes +} + +// Given a server name returns the server advertisement +func getServer(serverName string) *server_structs.Advertisement { + servers := listAdvertisement([]server_structs.ServerType{server_structs.OriginType, server_structs.CacheType}) + for _, server := range servers { + if server.Name == serverName { + return server } - for _, ns := range server.NamespaceAds { - res.NamespacePrefixes = append(res.NamespacePrefixes, ns.Path) + } + return nil +} + +// API wrapper around getServer to return a serverResponse +func getServerHandler(ctx *gin.Context) { + serverName := ctx.Param("name") + if serverName == "" { + ctx.JSON(http.StatusBadRequest, server_structs.SimpleApiResp{ + Status: server_structs.RespFailed, + Msg: "Server name is required", + }) + return + } + server := getServer(serverName) + if server == nil { + ctx.JSON(http.StatusNotFound, server_structs.SimpleApiResp{ + Status: server_structs.RespFailed, + Msg: "Server not found", + }) + return + } + serverResponse := advertisementToServerResponse(server) + ctx.JSON(http.StatusOK, serverResponse) +} + +// Get all namespaces for a server +func listServerNamespaces(ctx *gin.Context) { + serverName := ctx.Param("name") + if serverName == "" { + ctx.JSON(http.StatusBadRequest, server_structs.SimpleApiResp{ + Status: server_structs.RespFailed, + Msg: "Server name is required", + }) + return + } + server := getServer(serverName) + if server == nil { + ctx.JSON(http.StatusNotFound, server_structs.SimpleApiResp{ + Status: server_structs.RespFailed, + Msg: "Server not found", + }) + return + } + var nsRes []NamespaceAdV2Response + for _, n := range server.NamespaceAds { + nsRes = append(nsRes, namespaceAdV2ToResponse(&n)) + } + ctx.JSON(http.StatusOK, nsRes) +} + +// Get list of all namespaces as a response +func listNamespaceResponses() []NamespaceAdV2MappedResponse { + + namespaceMap := make(map[string]NamespaceAdV2MappedResponse) + + for _, a := range listAdvertisement([]server_structs.ServerType{server_structs.OriginType, server_structs.CacheType}) { + s := a.ServerAd + for _, ns := range a.NamespaceAds { + + // If the namespace is not in the map, add it + if _, ok := namespaceMap[ns.Path]; !ok { + namespaceMap[ns.Path] = namespaceAdV2ToMappedResponse(&ns) + } + + // Add the server name to its type + nsRes := namespaceMap[ns.Path] + if s.Type == server_structs.OriginType.String() { + nsRes.Origins = append(nsRes.Origins, s.Name) + } else if s.Type == server_structs.CacheType.String() { + nsRes.Caches = append(nsRes.Caches, s.Name) + } + namespaceMap[ns.Path] = nsRes } - resList = append(resList, res) } - ctx.JSON(http.StatusOK, resList) + + return utils.MapToSlice(namespaceMap) +} + +// Get list of all namespaces +func listNamespacesHandler(ctx *gin.Context) { + ctx.JSON(http.StatusOK, listNamespaceResponses()) } // Issue a stat query to origins for an object and return which origins serve the object @@ -366,10 +588,13 @@ func RegisterDirectorWebAPI(router *gin.RouterGroup) { // Follow RESTful schema { directorWebAPI.GET("/servers", listServers) + directorWebAPI.GET("/servers/:name", getServerHandler) + directorWebAPI.GET("/servers/:name/namespaces", listServerNamespaces) directorWebAPI.PATCH("/servers/filter/*name", web_ui.AuthHandler, web_ui.AdminAuthHandler, handleFilterServer) directorWebAPI.PATCH("/servers/allow/*name", web_ui.AuthHandler, web_ui.AdminAuthHandler, handleAllowServer) directorWebAPI.GET("/servers/origins/stat/*path", web_ui.AuthHandler, queryOrigins) directorWebAPI.HEAD("/servers/origins/stat/*path", web_ui.AuthHandler, queryOrigins) + directorWebAPI.GET("/namespaces", listNamespacesHandler) directorWebAPI.GET("/contact", handleDirectorContact) } } diff --git a/director/director_ui_test.go b/director/director_ui_test.go index 853daa260..2102bede3 100644 --- a/director/director_ui_test.go +++ b/director/director_ui_test.go @@ -169,3 +169,241 @@ func TestListServers(t *testing.T) { require.Equal(t, 400, w.Code) }) } + +func TestGetServer(t *testing.T) { + router := gin.Default() + + router.GET("/servers/:name", getServerHandler) + router.GET("/servers/:name/namespaces", listServerNamespaces) + + serverAds.DeleteAll() + mockOriginNamespace := mockNamespaceAds(5, "origin1") + mockCacheNamespace := mockNamespaceAds(4, "cache1") + serverAds.Set(mockOriginServerAd.URL.String(), + &server_structs.Advertisement{ + ServerAd: mockOriginServerAd, + NamespaceAds: mockOriginNamespace, + }, ttlcache.DefaultTTL) + serverAds.Set(mockCacheServerAd.URL.String(), + &server_structs.Advertisement{ + ServerAd: mockCacheServerAd, + NamespaceAds: mockCacheNamespace, + }, ttlcache.DefaultTTL) + + require.True(t, serverAds.Has(mockOriginServerAd.URL.String())) + require.True(t, serverAds.Has(mockCacheServerAd.URL.String())) + + expectedListOriginResNss := []NamespaceAdV2Response{} + for _, ns := range mockOriginNamespace { + expectedListOriginResNss = append(expectedListOriginResNss, namespaceAdV2ToResponse(&ns)) + } + + expectedListCacheResNss := []NamespaceAdV2Response{} + for _, ns := range mockCacheNamespace { + expectedListCacheResNss = append(expectedListCacheResNss, namespaceAdV2ToResponse(&ns)) + } + + expectedlistOriginRes := serverResponse{ + Name: mockOriginServerAd.Name, + BrokerURL: mockOriginServerAd.BrokerURL.String(), + AuthURL: "", + URL: mockOriginServerAd.URL.String(), + WebURL: mockOriginServerAd.WebURL.String(), + Type: mockOriginServerAd.Type, + Latitude: mockOriginServerAd.Latitude, + Longitude: mockOriginServerAd.Longitude, + Caps: mockOriginServerAd.Caps, + FromTopology: mockOriginServerAd.FromTopology, + HealthStatus: HealthStatusUnknown, + Namespaces: expectedListOriginResNss, + } + + expectedlistCacheRes := serverResponse{ + Name: mockCacheServerAd.Name, + BrokerURL: mockCacheServerAd.BrokerURL.String(), + AuthURL: "", + URL: mockCacheServerAd.URL.String(), + WebURL: mockCacheServerAd.WebURL.String(), + Type: mockCacheServerAd.Type, + Latitude: mockCacheServerAd.Latitude, + Longitude: mockCacheServerAd.Longitude, + Caps: mockCacheServerAd.Caps, + FromTopology: mockCacheServerAd.FromTopology, + HealthStatus: HealthStatusUnknown, + Namespaces: expectedListCacheResNss, + } + + t.Run("get-origin", func(t *testing.T) { + // Create a request to the endpoint + w := httptest.NewRecorder() + req, _ := http.NewRequest("GET", "/servers/"+mockOriginServerAd.Name, nil) + router.ServeHTTP(w, req) + + // Check the response + require.Equal(t, 200, w.Code) + + var got serverResponse + err := json.Unmarshal(w.Body.Bytes(), &got) + require.NoError(t, err) + assert.Equal(t, expectedlistOriginRes, got, "Response data does not match expected") + }) + + t.Run("get-cache", func(t *testing.T) { + // Create a request to the endpoint + w := httptest.NewRecorder() + req, _ := http.NewRequest("GET", "/servers/"+mockCacheServerAd.Name, nil) + router.ServeHTTP(w, req) + + // Check the response + require.Equal(t, 200, w.Code) + + var got serverResponse + err := json.Unmarshal(w.Body.Bytes(), &got) + require.NoError(t, err) + assert.Equal(t, expectedlistCacheRes, got, "Response data does not match expected") + }) + + t.Run("get-non-existent-server", func(t *testing.T) { + // Create a request to the endpoint + w := httptest.NewRecorder() + req, _ := http.NewRequest("GET", "/servers/non-existent-server", nil) + router.ServeHTTP(w, req) + + // Check the response + require.Equal(t, 404, w.Code) + }) + + t.Run("get-namespaces-of-server", func(t *testing.T) { + // Create a request to the endpoint + w := httptest.NewRecorder() + req, _ := http.NewRequest("GET", "/servers/"+mockOriginServerAd.Name+"/namespaces", nil) + router.ServeHTTP(w, req) + + // Check the response + require.Equal(t, 200, w.Code) + + // Check the data + var got []NamespaceAdV2Response + err := json.Unmarshal(w.Body.Bytes(), &got) + require.NoError(t, err) + require.Equal(t, len(mockOriginNamespace), len(got)) + for i := range got { + assert.Equal(t, namespaceAdV2ToResponse(&mockOriginNamespace[i]), got[i], "Response data does not match expected") + } + }) + + t.Run("get-namespaces-of-non-existent-server", func(t *testing.T) { + // Create a request to the endpoint + w := httptest.NewRecorder() + req, _ := http.NewRequest("GET", "/servers/non-existent-server/namespaces", nil) + router.ServeHTTP(w, req) + + // Check the response + require.Equal(t, 404, w.Code) + }) +} + +func TestGetNamespaces(t *testing.T) { + router := gin.Default() + + router.GET("/namespaces", listNamespacesHandler) + + serverAds.DeleteAll() + mockOriginNamespace := mockNamespaceAds(5, "origin1") + mockCacheNamespace := mockNamespaceAds(4, "cache1") + serverAds.Set(mockOriginServerAd.URL.String(), + &server_structs.Advertisement{ + ServerAd: mockOriginServerAd, + NamespaceAds: mockOriginNamespace, + }, ttlcache.DefaultTTL) + serverAds.Set(mockCacheServerAd.URL.String(), + &server_structs.Advertisement{ + ServerAd: mockCacheServerAd, + NamespaceAds: mockCacheNamespace, + }, ttlcache.DefaultTTL) + + t.Run("get-all-namespaces", func(t *testing.T) { + // Create a request to the endpoint + w := httptest.NewRecorder() + req, _ := http.NewRequest("GET", "/namespaces", nil) + router.ServeHTTP(w, req) + + // Check the response + require.Equal(t, 200, w.Code) + + // Check the data + var got []NamespaceAdV2MappedResponse + err := json.Unmarshal(w.Body.Bytes(), &got) + require.NoError(t, err) + require.Equal(t, len(mockOriginNamespace)+len(mockCacheNamespace), len(got)) + + // Create the list of expected responses we should see by adding origin/cache names + var expected []NamespaceAdV2MappedResponse + for _, ns := range mockOriginNamespace { + nsRes := namespaceAdV2ToMappedResponse(&ns) + nsRes.Origins = append(nsRes.Origins, mockOriginServerAd.Name) + expected = append(expected, nsRes) + } + for _, ns := range mockCacheNamespace { + nsRes := namespaceAdV2ToMappedResponse(&ns) + nsRes.Caches = append(nsRes.Caches, mockCacheServerAd.Name) + expected = append(expected, nsRes) + } + + // Check that the namespaces are as expected + for _, ns := range expected { + assert.Contains(t, got, ns, "Response data does not match expected") + } + }) + + t.Run("get-all-namespaces-crossover", func(t *testing.T) { + + // Set things up with namespaces that cross over between origin and cache + serverAds.DeleteAll() + mockNamespaceSet0 := mockNamespaceAds(5, "origin1") + mockNamespaceSet1 := mockNamespaceAds(4, "cache1") + serverAds.Set(mockOriginServerAd.URL.String(), + &server_structs.Advertisement{ + ServerAd: mockOriginServerAd, + NamespaceAds: append(mockNamespaceSet0, mockNamespaceSet1...), + }, ttlcache.DefaultTTL) + serverAds.Set(mockCacheServerAd.URL.String(), + &server_structs.Advertisement{ + ServerAd: mockCacheServerAd, + NamespaceAds: mockNamespaceSet0, + }, ttlcache.DefaultTTL) + + // Create a request to the endpoint + w := httptest.NewRecorder() + req, _ := http.NewRequest("GET", "/namespaces", nil) + router.ServeHTTP(w, req) + + // Check the response + require.Equal(t, 200, w.Code) + + // Check the data + var got []NamespaceAdV2MappedResponse + err := json.Unmarshal(w.Body.Bytes(), &got) + require.NoError(t, err) + require.Equal(t, len(mockNamespaceSet0)+len(mockNamespaceSet1), len(got)) + + // Create the list of expected responses we should see by adding origin/cache names + expected := make(map[string]NamespaceAdV2MappedResponse) + for _, ns := range append(mockNamespaceSet1, mockNamespaceSet0...) { + nsRes := namespaceAdV2ToMappedResponse(&ns) + nsRes.Origins = append(nsRes.Origins, mockOriginServerAd.Name) + expected[nsRes.Path] = nsRes + } + // Going to cheat a bit here and use that fact that I know origins superset cache namespaces + for _, ns := range mockNamespaceSet0 { + nsMappedRes := expected[ns.Path] + nsMappedRes.Caches = append(nsMappedRes.Caches, mockCacheServerAd.Name) + expected[ns.Path] = nsMappedRes + } + + // Check that the namespaces are as expected + for _, ns := range expected { + assert.Contains(t, got, ns, "Response data does not match expected") + } + }) +} diff --git a/swagger/pelican-swagger.yaml b/swagger/pelican-swagger.yaml index 56f37f3cb..55dc1a258 100644 --- a/swagger/pelican-swagger.yaml +++ b/swagger/pelican-swagger.yaml @@ -235,6 +235,70 @@ definitions: custom_fields: type: object description: The custom fields user registered, configurable by setting Registry.CustomRegistrationFields. + TokenGeneration: + type: object + properties: + strategy: + type: string + example: "OAuth2" + vaultServer: + type: string + maxScopeDepth: + type: integer + issuer: + type: string + format: uri + TokenIssuer: + type: object + properties: + issuer: + type: string + format: uri + basePaths: + type: array + items: + type: string + restrictedPaths: + type: array + items: + type: string + NamespaceAdV2: + type: object + properties: + Caps: + type: object + $ref: "#/definitions/OriginExportCapabilities" + path: + type: string + example: "/barten/time" + tokenGeneration: + type: array + items: + type: object + $ref: "#/definitions/TokenGeneration" + tokenIssuer: + type: array + items: + type: object + $ref: "#/definitions/TokenIssuer" + fromTopology: + type: boolean + example: false + NamespaceAdV2Mapped: + allOf: + - $ref: "#/definitions/NamespaceAdV2" + - type: object + properties: + origins: + type: array + items: + type: string + example: ["example-origin.com"] + caches: + type: array + items: + type: string + example: ["example-cache.com"] NamespaceForRegistration: type: object required: @@ -398,7 +462,7 @@ definitions: type: string description: Type of the server. Origin|Cache example: Origin - latitute: + latitude: type: number description: The latitute of the server based on its IP address default: 0 @@ -1639,6 +1703,78 @@ paths: schema: type: object $ref: "#/definitions/ErrorModelV2" + /director_ui/servers/{name}: + get: + tags: + - "director_ui" + summary: Get details of a specific server + parameters: + - name: name + in: path + required: true + type: string + description: The name of the server + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: "#/definitions/DirectorServerResponse" + "400": + description: Bad request, invalid server name + schema: + $ref: "#/definitions/ErrorModelV2" + "404": + description: Server not found + schema: + $ref: "#/definitions/ErrorModelV2" + /director_ui/servers/{name}/namespaces: + get: + tags: + - "director_ui" + summary: Get a list of namespaces for a specific server + parameters: + - name: name + in: path + required: true + type: string + description: The name of the server + produces: + - application/json + responses: + "200": + description: OK + schema: + type: array + items: + $ref: "#/definitions/NamespaceAdV2" + "400": + description: Bad request, invalid server name + schema: + $ref: "#/definitions/ErrorModelV2" + "404": + description: Server not found + schema: + $ref: "#/definitions/ErrorModelV2" + /director_ui/namespaces: + get: + tags: + - "director_ui" + summary: Get a list of namespaces advertised to the director + produces: + - application/json + responses: + "200": + description: OK + schema: + type: array + items: + $ref: "#/definitions/NamespaceAdV2Mapped" + "400": + description: Bad request + schema: + $ref: "#/definitions/ErrorModelV2" /director_ui/servers/filter/{name}: patch: summary: Filter a server from director redirecting diff --git a/utils/utils.go b/utils/utils.go index 87b589ab4..3915f47f4 100644 --- a/utils/utils.go +++ b/utils/utils.go @@ -144,3 +144,12 @@ func ExtractProjectFromUserAgent(userAgents []string) string { return "" } + +// Convert map to slice of values +func MapToSlice[K comparable, V any](m map[K]V) []V { + s := make([]V, 0, len(m)) + for _, v := range m { + s = append(s, v) + } + return s +}