diff --git a/config/config.go b/config/config.go index 7a222380e..245c2edba 100644 --- a/config/config.go +++ b/config/config.go @@ -970,6 +970,7 @@ func SetServerDefaults(v *viper.Viper) error { v.SetDefault(param.Origin_DbLocation.GetName(), "/var/lib/pelican/origin.sqlite") v.SetDefault(param.Director_GeoIPLocation.GetName(), "/var/cache/pelican/maxmind/GeoLite2-City.mmdb") v.SetDefault(param.Registry_DbLocation.GetName(), "/var/lib/pelican/registry.sqlite") + v.SetDefault(param.Director_DbLocation.GetName(), "/var/lib/pelican/director.sqlite") // The lotman db will actually take this path and create the lot at /path/.lot/lotman_cpp.sqlite v.SetDefault(param.Lotman_DbLocation.GetName(), "/var/lib/pelican") v.SetDefault(param.Monitoring_DataLocation.GetName(), "/var/lib/pelican/monitoring/data") @@ -980,6 +981,7 @@ func SetServerDefaults(v *viper.Viper) error { v.SetDefault(param.Origin_DbLocation.GetName(), filepath.Join(configDir, "origin.sqlite")) v.SetDefault(param.Director_GeoIPLocation.GetName(), filepath.Join(configDir, "maxmind", "GeoLite2-City.mmdb")) v.SetDefault(param.Registry_DbLocation.GetName(), filepath.Join(configDir, "ns-registry.sqlite")) + v.SetDefault(param.Director_DbLocation.GetName(), filepath.Join(configDir, "director.sqlite")) // Lotdb will live at /.lot/lotman_cpp.sqlite v.SetDefault(param.Lotman_DbLocation.GetName(), configDir) v.SetDefault(param.Monitoring_DataLocation.GetName(), filepath.Join(configDir, "monitoring/data")) diff --git a/director/director.go b/director/director.go index 07dd24cb9..f8f8f6344 100644 --- a/director/director.go +++ b/director/director.go @@ -344,7 +344,6 @@ func redirectToCache(ginCtx *gin.Context) { collectDirectorRedirectionMetric(ginCtx, "cache") } }() - defer collectDirectorRedirectionMetric(ginCtx, "cache") err := versionCompatCheck(reqVer, service) if err != nil { log.Warningf("A version incompatibility was encountered while redirecting to a cache and no response was served: %v", err) 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/docs/pages/install/macos.mdx b/docs/pages/install/macos.mdx index 23e51d5c9..b6f7e5185 100644 --- a/docs/pages/install/macos.mdx +++ b/docs/pages/install/macos.mdx @@ -20,7 +20,7 @@ Pelican provides a binary executable file instead of a `DMG` installer for MacOS Example to install Pelican executable for an Apple Silicon Mac: ```bash - curl -LO https://github.com/PelicanPlatform/pelican/releases/download/v7.10.5/pelican_Darwin_x86_64.tar.gz + curl -LO https://github.com/PelicanPlatform/pelican/releases/download/v7.10.5/pelican_Darwin_arm64.tar.gz tar -zxvf pelican_Darwin_arm64.tar.gz ``` diff --git a/docs/parameters.yaml b/docs/parameters.yaml index 47e263d9c..028a661d2 100644 --- a/docs/parameters.yaml +++ b/docs/parameters.yaml @@ -44,7 +44,7 @@ description: |+ Subdirectories of the provided directories are not read. Only the root config file's `ConfigLocations` key is used, and any redefinitions are ignored. type: stringSlice -default: none +default: [] components: ["*"] --- name: Debug @@ -566,7 +566,7 @@ description: |+ This configuration is meant mostly to be used by passing the -v flag from the command line. Paths exported with this configuration will inherit the origin's abilities, so individual export configurations are not possible. type: stringSlice -default: none +default: [] components: ["origin"] --- name: Origin.EnablePublicReads @@ -765,7 +765,7 @@ name: Origin.ScitokensRestrictedPaths description: |+ Enable the built-in issuer daemon for the origin. type: stringSlice -default: none +default: [] components: ["origin"] --- name: Origin.ScitokensMapSubject @@ -1210,7 +1210,7 @@ description: |+ the cache is allowed to access any namespace that's advertised to the director. Otherwise, it will only be allowed to access the listed namespaces. type: stringSlice -default: none +default: [] components: ["cache"] --- name: Cache.SelfTest @@ -1285,7 +1285,7 @@ description: |+ If present, the hostname is taken from the X-Forwarded-Host header in the request. Otherwise, Host is used. type: stringSlice -default: none +default: [] components: ["director"] --- name: Director.CacheSortMethod @@ -1314,7 +1314,7 @@ description: |+ If present, the hostname is taken from the X-Forwarded-Host header in the request. Otherwise, Host is used. type: stringSlice -default: none +default: [] components: ["director"] --- name: Director.MaxMindKeyFile @@ -1447,7 +1447,7 @@ description: |+ A list of server resource names that the Director should consider in downtime, preventing the Director from issuing redirects to them. Additional downtimes are aggregated from Topology (when the Director is served in OSDF mode), and the Web UI. type: stringSlice -default: none +default: [] components: ["director"] --- name: Director.SupportContactEmail @@ -1489,7 +1489,7 @@ description: |+ This setting allows for compatibility with specific legacy OSDF origins and is not needed for new origins. type: stringSlice -default: none +default: [] components: ["director"] hidden: true --- @@ -1852,7 +1852,7 @@ description: |+ The "subject" claim should be the "CILogon User Identifier" from CILogon user page: https://cilogon.org/ type: stringSlice -default: none +default: [] components: ["registry","origin","cache"] --- name: Server.StartupTimeout @@ -2477,7 +2477,7 @@ name: Shoveler.OutputDestinations description: |+ A list of destinations to forward XRootD monitoring packet to. type: stringSlice -default: none +default: [] components: ["origin", "cache"] --- name: Shoveler.VerifyHeader diff --git a/launcher_utils/advertise.go b/launcher_utils/advertise.go index 6daddad55..4ca803ccd 100644 --- a/launcher_utils/advertise.go +++ b/launcher_utils/advertise.go @@ -68,9 +68,8 @@ func LaunchPeriodicAdvertise(ctx context.Context, egrp *errgroup.Group, servers doAdvertise(ctx, servers) ticker := time.NewTicker(1 * time.Minute) - defer ticker.Stop() egrp.Go(func() error { - + defer ticker.Stop() for { select { case <-ticker.C: 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 +} diff --git a/web_ui/frontend/app/(login)/initialization/code/page.tsx b/web_ui/frontend/app/(login)/initialization/code/page.tsx index 05f784bf6..904446ada 100644 --- a/web_ui/frontend/app/(login)/initialization/code/page.tsx +++ b/web_ui/frontend/app/(login)/initialization/code/page.tsx @@ -18,13 +18,15 @@ 'use client'; -import { Box, Typography, Grow } from '@mui/material'; +import { Box, Typography } from '@mui/material'; import { useRouter } from 'next/navigation'; -import { useState } from 'react'; +import { useContext, useState } from 'react'; import CodeInput, { Code } from '../../components/CodeInput'; import LoadingButton from '../../components/LoadingButton'; -import { getErrorMessage } from '@/helpers/util'; +import { initLogin } from '@/helpers/api'; +import { alertOnError } from '@/helpers/util'; +import { AlertDispatchContext } from '@/components/AlertProvider'; export default function Home() { const router = useRouter(); @@ -37,11 +39,11 @@ export default function Home() { undefined, ]); let [loading, setLoading] = useState(false); - let [error, setError] = useState(undefined); + + const dispatch = useContext(AlertDispatchContext); const setCode = (code: Code) => { _setCode(code); - setError(undefined); if (!code.includes(undefined)) { submit(code.map((x) => x!.toString()).join('')); @@ -51,26 +53,15 @@ export default function Home() { async function submit(code: string) { setLoading(true); - try { - let response = await fetch('/api/v1.0/auth/initLogin', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - code: code, - }), - }); - - if (response.ok) { - router.push('../password/'); - } else { - setLoading(false); - setError(await getErrorMessage(response)); - } - } catch { + let response = await alertOnError( + async () => await initLogin(code), + 'Could not login', + dispatch + ); + if (response) { + router.push('../password/'); + } else { setLoading(false); - setError('Could not connect to server'); } } @@ -97,16 +88,6 @@ export default function Home() {
- - - {error} - - (''); let [confirmPassword, _setConfirmPassword] = useState(''); let [loading, setLoading] = useState(false); - let [error, setError] = useState(undefined); async function submit(password: string) { setLoading(true); - try { - let response = await fetch('/api/v1.0/auth/resetLogin', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - password: password, - }), - }); - - if (response.ok) { - router.push('/'); - } else { - setLoading(false); - setError(await getErrorMessage(response)); - } - } catch { + let response = await alertOnError( + async () => await resetLogin(password), + 'Could not login', + dispatch + ); + if (response) { + router.push('/'); + } else { setLoading(false); - setError('Could not connect to server'); } } @@ -66,7 +58,16 @@ export default function Home() { if (password == confirmPassword) { submit(password); } else { - setError('Passwords do not match'); + dispatch({ + type: 'openAlert', + payload: { + alertProps: { + severity: 'warning', + }, + message: 'Passwords do not match', + onClose: () => dispatch({ type: 'closeAlert' }), + }, + }); } } @@ -89,7 +90,6 @@ export default function Home() { InputProps: { onChange: (e) => { _setPassword(e.target.value); - setError(undefined); }, }, }} @@ -102,7 +102,6 @@ export default function Home() { InputProps: { onChange: (e) => { _setConfirmPassword(e.target.value); - setError(undefined); }, }, error: password != confirmPassword, @@ -112,16 +111,6 @@ export default function Home() { /> - - - {error} - - { + const dispatch = useContext(AlertDispatchContext); + const router = useRouter(); const { mutate } = useSWR('getUser', getUser); let [password, setPassword] = useState(''); let [loading, setLoading] = useState(false); - let [error, setError] = useState(undefined); const [toggled, setToggled] = useState(false); - const { data: enabledServers } = useSWR( + const { data: enabledServers } = useSWR( 'getEnabledServers', - getEnabledServers + async () => + await alertOnError( + getEnabledServers, + 'Could not get enabled servers', + dispatch + ) ); - const { data: oauthServers } = useSWR( + const { data: oauthServers } = useSWR( 'getOauthEnabledServers', - getOauthEnabledServers, + async () => + await alertOnError( + getOauthEnabledServers, + 'Could not get oauth enabled servers', + dispatch + ), { fallbackData: [] } ); @@ -68,34 +82,20 @@ const AdminLogin = () => { async function submit(password: string) { setLoading(true); - let response; - try { - response = await fetch('/api/v1.0/auth/login', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - user: 'admin', - password: password, - }), - }); - - if (response.ok) { - await mutate(getUser); - - const url = new URL(window.location.href); - let returnUrl = url.searchParams.get('returnURL') || ''; - returnUrl = returnUrl.replace(`/view`, ''); - router.push(returnUrl ? returnUrl : '../'); - } else { - setLoading(false); - setError(await getErrorMessage(response)); - } - } catch (e) { - console.error(e); + const response = await alertOnError( + async () => await login(password), + 'Could not login', + dispatch + ); + if (response) { + await mutate(getUser); + + const url = new URL(window.location.href); + let returnUrl = url.searchParams.get('returnURL') || ''; + returnUrl = returnUrl.replace(`/view`, ''); + router.push(returnUrl ? returnUrl : '../'); + } else { setLoading(false); - setError('Could not connect to server'); } } @@ -116,23 +116,12 @@ const AdminLogin = () => { sx: { width: '50%' }, onChange: (e) => { setPassword(e.target.value); - setError(undefined); }, }, }} /> - - - {error} - - { }; export default function Home() { + const dispatch = useContext(AlertDispatchContext); + const [returnUrl, setReturnUrl] = useState(undefined); - const { data: enabledServers } = useSWR( + const { data: enabledServers } = useSWR( 'getEnabledServers', - getEnabledServers + async () => + await alertOnError( + getEnabledServers, + 'Could not get enabled servers', + dispatch + ) ); - const { data: oauthServers } = useSWR( + const { data: oauthServers } = useSWR( 'getOauthEnabledServers', - getOauthEnabledServers, + async () => + await alertOnError( + getOauthEnabledServers, + 'Could not determine if the active server had OAuth enabled', + dispatch + ), { fallbackData: [] } ); diff --git a/web_ui/frontend/app/config/Config.tsx b/web_ui/frontend/app/config/Config.tsx index 45039f54f..bfd42fbde 100644 --- a/web_ui/frontend/app/config/Config.tsx +++ b/web_ui/frontend/app/config/Config.tsx @@ -27,7 +27,14 @@ import { IconButton, Alert, } from '@mui/material'; -import React, { memo, useCallback, useEffect, useMemo, useState } from 'react'; +import React, { + memo, + useCallback, + useContext, + useEffect, + useMemo, + useState, +} from 'react'; import { AppRegistration, AssistantDirection, @@ -51,21 +58,26 @@ import StatusSnackBar, { StatusSnackBarProps, } from '@/components/StatusSnackBar'; import { ServerType } from '@/index'; -import { getEnabledServers } from '@/helpers/util'; +import { alertOnError, getEnabledServers } from '@/helpers/util'; import DownloadButton from '@/components/DownloadButton'; import { PaddedContent } from '@/components/layout'; import { ConfigDisplay, TableOfContents } from '@/app/config/components'; import AuthenticatedContent from '@/components/layout/AuthenticatedContent'; +import { getConfig } from '@/helpers/api'; +import { AlertDispatchContext } from '@/components/AlertProvider'; function Config({ metadata }: { metadata: ParameterMetadataRecord }) { + const dispatch = useContext(AlertDispatchContext); + const [status, setStatus] = useState( undefined ); const [patch, _setPatch] = useState({}); - const { data, mutate, error } = useSWR( + const { data, mutate, error } = useSWR( 'getConfig', - getConfig + async () => + await alertOnError(getConfigJson, 'Could not get config', dispatch) ); const { data: enabledServers } = useSWR( 'getEnabledServers', @@ -94,8 +106,6 @@ function Config({ metadata }: { metadata: ParameterMetadataRecord }) { ); }, [serverConfig, patch]); - console.error(error, data); - return ( <> @@ -215,17 +225,11 @@ function Config({ metadata }: { metadata: ParameterMetadataRecord }) { ); } -const getConfig = async (): Promise => { - let response = await fetch('/api/v1.0/config'); - - if (!response.ok) { - if (response.status == 401) { - throw new Error('You must be logged in to view and access the config'); - } - throw new Error('Failed to fetch config'); +const getConfigJson = async (): Promise => { + const response = await getConfig(); + if (response) { + return await response.json(); } - - return await response.json(); }; export default Config; diff --git a/web_ui/frontend/app/director/components/DirectorCard.tsx b/web_ui/frontend/app/director/components/DirectorCard.tsx index 72419fd18..07b496bee 100644 --- a/web_ui/frontend/app/director/components/DirectorCard.tsx +++ b/web_ui/frontend/app/director/components/DirectorCard.tsx @@ -1,5 +1,5 @@ import { Authenticated, secureFetch } from '@/helpers/login'; -import React, { useRef, useState } from 'react'; +import React, { useContext, useRef, useState } from 'react'; import { Avatar, Box, @@ -21,8 +21,10 @@ import { NamespaceIcon } from '@/components/Namespace/index'; import useSWR from 'swr'; import Link from 'next/link'; import { User } from '@/index'; -import { getErrorMessage } from '@/helpers/util'; +import { alertOnError, getErrorMessage } from '@/helpers/util'; import { DirectorDropdown } from '@/app/director/components/DirectorDropdown'; +import { allowServer, filterServer } from '@/helpers/api'; +import { AlertDispatchContext } from '@/components/AlertProvider'; export interface DirectorCardProps { server: Server; @@ -30,11 +32,11 @@ export interface DirectorCardProps { } export const DirectorCard = ({ server, authenticated }: DirectorCardProps) => { - const [filtered, setFiltered] = useState(server.filtered); - const [error, setError] = useState(undefined); const [disabled, setDisabled] = useState(false); const [dropdownOpen, setDropdownOpen] = useState(false); + const dispatch = useContext(AlertDispatchContext); + const { mutate } = useSWR('getServers'); return ( @@ -75,38 +77,34 @@ export const DirectorCard = ({ server, authenticated }: DirectorCardProps) => { { - x.stopPropagation(); + onClick={async (e) => { + e.stopPropagation(); // Disable the switch setDisabled(true); - // Provide optimistic feedback - setFiltered(!filtered); - // Update the server - let error; - if (filtered) { - error = await allowServer(server.name); - } else { - error = await filterServer(server.name); - } + await alertOnError( + async () => { + if (server.filtered) { + await allowServer(server.name); + } else { + await filterServer(server.name); + } + }, + 'Failed to toggle server status', + dispatch + ); - // Revert if we were too optimistic - if (error) { - setFiltered(!filtered); - setError(error); - } else { - mutate(); - } + mutate(); setDisabled(false); }} /> } - label={!filtered ? 'Active' : 'Disabled'} + label={server.filtered ? 'Disabled' : 'Active'} /> @@ -127,69 +125,8 @@ export const DirectorCard = ({ server, authenticated }: DirectorCardProps) => { - - setError(undefined)} - > - setError(undefined)} - severity='error' - variant='filled' - sx={{ width: '100%' }} - > - {error} -
- If this error persists on reload, please file a ticket via the (?) - in the bottom left. -
-
-
); }; -const filterServer = async (name: string): Promise => { - try { - const response = await secureFetch( - `/api/v1.0/director_ui/servers/filter/${name}`, - { - method: 'PATCH', - } - ); - if (response.ok) { - return; - } else { - return await getErrorMessage(response); - } - } catch (e) { - if (e instanceof Error) { - return e.message; - } - return 'Could not connect to server'; - } -}; - -const allowServer = async (name: string): Promise => { - try { - const response = await secureFetch( - `/api/v1.0/director_ui/servers/allow/${name}`, - { - method: 'PATCH', - } - ); - if (response.ok) { - return; - } else { - return await getErrorMessage(response); - } - } catch (e) { - if (e instanceof Error) { - return e.message; - } - return 'Could not connect to server'; - } -}; - export default DirectorCard; diff --git a/web_ui/frontend/app/director/components/DirectorDropdown.tsx b/web_ui/frontend/app/director/components/DirectorDropdown.tsx index 15d7bcb03..6a20d97b3 100644 --- a/web_ui/frontend/app/director/components/DirectorDropdown.tsx +++ b/web_ui/frontend/app/director/components/DirectorDropdown.tsx @@ -107,7 +107,10 @@ const directoryListToTreeHelper = ( tree[path[0]] = {}; } - tree[path[0]] = directoryListToTreeHelper(path.slice(1), tree[path[0]]); + tree[path[0]] = directoryListToTreeHelper( + path.slice(1), + tree[path[0]] as StringTree + ); return tree; }; diff --git a/web_ui/frontend/app/director/page.tsx b/web_ui/frontend/app/director/page.tsx index 3b1f64c1a..a07fb437c 100644 --- a/web_ui/frontend/app/director/page.tsx +++ b/web_ui/frontend/app/director/page.tsx @@ -19,23 +19,30 @@ 'use client'; import { Box, Grid, Skeleton, Typography } from '@mui/material'; -import { useMemo } from 'react'; +import { useContext, useMemo } from 'react'; import useSWR from 'swr'; import { Server } from '@/index'; -import { - DirectorCardList, - DirectorCard, - DirectorCardProps, -} from './components'; +import { DirectorCardList } from './components'; import { getUser } from '@/helpers/login'; import FederationOverview from '@/components/FederationOverview'; import AuthenticatedContent from '@/components/layout/AuthenticatedContent'; import { PaddedContent } from '@/components/layout'; +import { fetchApi } from '@/helpers/api'; +import { alertOnError } from '@/helpers/util'; +import { AlertDispatchContext } from '@/components/AlertProvider'; export default function Page() { - const { data } = useSWR('getServers', getServers); + const dispatch = useContext(AlertDispatchContext); - const { data: user, error } = useSWR('getUser', getUser); + const { data } = useSWR( + 'getServers', + async () => + await alertOnError(getServers, 'Failed to fetch servers', dispatch) + ); + + const { data: user, error } = useSWR('getUser', () => + alertOnError(getUser, 'Failed to fetch user', dispatch) + ); const cacheData = useMemo(() => { return data?.filter((server) => server.type === 'Cache'); @@ -97,7 +104,7 @@ export default function Page() { const getServers = async () => { const url = new URL('/api/v1.0/director_ui/servers', window.location.origin); - let response = await fetch(url); + let response = await fetchApi(async () => await fetch(url)); if (response.ok) { const responseData: Server[] = await response.json(); responseData.sort((a, b) => a.name.localeCompare(b.name)); diff --git a/web_ui/frontend/app/layout.tsx b/web_ui/frontend/app/layout.tsx index bc4da3fc9..10f5ca8b3 100644 --- a/web_ui/frontend/app/layout.tsx +++ b/web_ui/frontend/app/layout.tsx @@ -17,7 +17,8 @@ ***************************************************************/ import { LocalizationProvider } from '@/clientComponents'; -import { ThemeProviderClient } from '@/public/theme'; +import { ThemeProviderClient } from '@/components/ThemeProvider'; +import { AlertProvider } from '@/components/AlertProvider'; import './globals.css'; export const metadata = { @@ -34,7 +35,9 @@ export default function RootLayout({ - {children} + + {children} + diff --git a/web_ui/frontend/app/registry/cache/edit/page.tsx b/web_ui/frontend/app/registry/cache/edit/page.tsx index 66ce2d284..c9f9eaec3 100644 --- a/web_ui/frontend/app/registry/cache/edit/page.tsx +++ b/web_ui/frontend/app/registry/cache/edit/page.tsx @@ -19,13 +19,13 @@ 'use client'; import { PutPage } from '@/app/registry/components/PutPage'; -import { - namespaceToCache, - putGeneralNamespace, -} from '@/app/registry/components/util'; +import { namespaceToCache } from '@/app/registry/components/util'; import { Box, Grid, Typography } from '@mui/material'; import React from 'react'; +import { putGeneralNamespace } from '@/helpers/api'; +import AuthenticatedContent from '@/components/layout/AuthenticatedContent'; + export default function Page() { const putCache = async (data: any) => { const cache = namespaceToCache(structuredClone(data)); @@ -44,7 +44,9 @@ export default function Page() { - + + + diff --git a/web_ui/frontend/app/registry/cache/register/page.tsx b/web_ui/frontend/app/registry/cache/register/page.tsx index 6221ddb01..0364bc5f5 100644 --- a/web_ui/frontend/app/registry/cache/register/page.tsx +++ b/web_ui/frontend/app/registry/cache/register/page.tsx @@ -18,13 +18,12 @@ 'use client'; -import { - namespaceToCache, - postGeneralNamespace, -} from '@/app/registry/components/util'; +import { namespaceToCache } from '@/app/registry/components/util'; import { PostPage } from '@/app/registry/components/PostPage'; import { Box, Grid, Typography } from '@mui/material'; import React from 'react'; +import { postGeneralNamespace } from '@/helpers/api'; +import AuthenticatedContent from '@/components/layout/AuthenticatedContent'; export default function Page() { const postCache = async (data: any) => { @@ -44,7 +43,9 @@ export default function Page() { - + + + diff --git a/web_ui/frontend/app/registry/components/CustomRegistrationField/BooleanField.tsx b/web_ui/frontend/app/registry/components/CustomRegistrationField/BooleanField.tsx index f541024ff..b3bbfe61f 100644 --- a/web_ui/frontend/app/registry/components/CustomRegistrationField/BooleanField.tsx +++ b/web_ui/frontend/app/registry/components/CustomRegistrationField/BooleanField.tsx @@ -9,7 +9,7 @@ import React, { ChangeEvent, ReactNode, SyntheticEvent, useMemo } from 'react'; import { createId } from '@/components/configuration/util'; import FormHelperText from '@mui/material/FormHelperText'; -import type { CustomRegistrationFieldProps } from './index.d'; +import type { BaseCustomRegistrationFieldProps } from './index'; const BooleanField = ({ onChange, @@ -18,7 +18,7 @@ const BooleanField = ({ required, description, value, -}: CustomRegistrationFieldProps) => { +}: BaseCustomRegistrationFieldProps) => { const id = useMemo(() => createId(name), [name]); const labelId = useMemo(() => `${id}-label`, [id]); diff --git a/web_ui/frontend/app/registry/components/CustomRegistrationField/EnumerationField.tsx b/web_ui/frontend/app/registry/components/CustomRegistrationField/EnumerationField.tsx index d0f6b699b..ec43e79e1 100644 --- a/web_ui/frontend/app/registry/components/CustomRegistrationField/EnumerationField.tsx +++ b/web_ui/frontend/app/registry/components/CustomRegistrationField/EnumerationField.tsx @@ -1,7 +1,7 @@ import { Autocomplete, TextField } from '@mui/material'; import React, { useMemo } from 'react'; -import type { CustomRegistrationFieldProps } from './index.d'; +import type { BaseCustomRegistrationFieldProps } from './index'; const EnumerationField = ({ onChange, @@ -11,7 +11,7 @@ const EnumerationField = ({ description, value, options, -}: CustomRegistrationFieldProps) => { +}: BaseCustomRegistrationFieldProps) => { const textValue = useMemo( () => options?.find((option) => option.id === value), [value, options] diff --git a/web_ui/frontend/app/registry/components/CustomRegistrationField/EpochTimeField.tsx b/web_ui/frontend/app/registry/components/CustomRegistrationField/EpochTimeField.tsx index 38d8c7a22..141aefc42 100644 --- a/web_ui/frontend/app/registry/components/CustomRegistrationField/EpochTimeField.tsx +++ b/web_ui/frontend/app/registry/components/CustomRegistrationField/EpochTimeField.tsx @@ -6,7 +6,7 @@ import FormControl from '@mui/material/FormControl'; import FormHelperText from '@mui/material/FormHelperText'; import { DateTime } from 'luxon'; -import type { CustomRegistrationFieldProps } from './index.d'; +import type { BaseCustomRegistrationFieldProps } from './index'; const EpochTimeField = ({ onChange, @@ -15,7 +15,7 @@ const EpochTimeField = ({ required, description, value, -}: CustomRegistrationFieldProps) => { +}: BaseCustomRegistrationFieldProps) => { return ( { if (value && isNaN(Number(value))) { @@ -17,7 +17,7 @@ const IntegerField = ({ required, description, value, -}: CustomRegistrationFieldProps) => { +}: BaseCustomRegistrationFieldProps) => { const [error, setError] = React.useState(undefined); // Check that the value is a number or undefined throwing error if not diff --git a/web_ui/frontend/app/registry/components/CustomRegistrationField/PubkeyField.tsx b/web_ui/frontend/app/registry/components/CustomRegistrationField/PubkeyField.tsx index 703e0d15e..a69e3f107 100644 --- a/web_ui/frontend/app/registry/components/CustomRegistrationField/PubkeyField.tsx +++ b/web_ui/frontend/app/registry/components/CustomRegistrationField/PubkeyField.tsx @@ -1,7 +1,7 @@ import React from 'react'; import { StringField } from './StringField'; -import type { CustomRegistrationFieldProps } from './index.d'; +import type { BaseCustomRegistrationFieldProps } from './index'; const JWKPlaceholder = { keys: [ @@ -25,7 +25,9 @@ const pubkeyValidator = (value: string) => { } }; -const PubkeyField = ({ ...props }: CustomRegistrationFieldProps) => { +const PubkeyField = ({ + ...props +}: BaseCustomRegistrationFieldProps) => { return ( & - CustomRegistrationFieldProps; + BaseCustomRegistrationFieldProps; interface StringFieldProps extends TextFieldProps { validator?: (value: string) => string | undefined; diff --git a/web_ui/frontend/app/registry/components/CustomRegistrationField/index.d.ts b/web_ui/frontend/app/registry/components/CustomRegistrationField/index.d.ts deleted file mode 100644 index e00428efe..000000000 --- a/web_ui/frontend/app/registry/components/CustomRegistrationField/index.d.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { CustomRegistrationField } from '@/components/configuration'; -import { Alert as AlertType, Namespace } from '@/index'; - -export interface NamespaceFormPage { - update: (data: Partial) => Promise; -} - -export interface CustomRegistrationProps extends CustomRegistrationField { - displayed_name: string; -} - -export type CustomRegistrationPropsEnum = - | (CustomRegistrationProps & { type: 'int' }) - | (CustomRegistrationProps & { type: 'string' }) - | (CustomRegistrationProps & { type: 'bool' }) - | (CustomRegistrationProps & { type: 'datetime' }) - | (CustomRegistrationProps & { type: 'enum' }); - -export interface CustomRegistrationFieldProps - extends CustomRegistrationProps { - onChange: (value: T | null) => void; - value?: T; -} - -export type CustomRegistrationFieldPropsEnum = - CustomRegistrationFieldProps & { - type: 'int' | 'string' | 'bool' | 'datetime' | 'enum'; - }; diff --git a/web_ui/frontend/app/registry/components/CustomRegistrationField/index.tsx b/web_ui/frontend/app/registry/components/CustomRegistrationField/index.tsx index 7c64a464e..f9050acf1 100644 --- a/web_ui/frontend/app/registry/components/CustomRegistrationField/index.tsx +++ b/web_ui/frontend/app/registry/components/CustomRegistrationField/index.tsx @@ -1,17 +1,30 @@ -import type { CustomRegistrationField } from '@/components/configuration/index'; import { BooleanField } from './BooleanField'; -import { ErrorField } from './ErrorField'; import { StringField } from './StringField'; import { IntegerField } from './IntegerField'; import PubkeyField from './PubkeyField'; -import { CustomRegistrationFieldPropsEnum } from './index.d'; import EpochTimeField from '@/app/registry/components/CustomRegistrationField/EpochTimeField'; import EnumerationField from '@/app/registry/components/CustomRegistrationField/EnumerationField'; +import { CustomRegistrationField as CustomRegistrationFieldConfiguration } from '@/components/configuration'; +import type { CustomRegistrationField } from '@/components/configuration'; + +export type CustomRegistrationFieldProps = + | (BaseCustomRegistrationFieldProps & { type: 'int' }) + | (BaseCustomRegistrationFieldProps & { type: 'string' }) + | (BaseCustomRegistrationFieldProps & { type: 'bool' }) + | (BaseCustomRegistrationFieldProps & { type: 'datetime' }) + | (BaseCustomRegistrationFieldProps & { type: 'enum' }); + +export interface BaseCustomRegistrationFieldProps + extends CustomRegistrationFieldConfiguration { + onChange: (value: T | null) => void; + value?: T; + displayed_name: string; +} const CustomRegistrationField = ({ ...props -}: CustomRegistrationFieldPropsEnum) => { +}: CustomRegistrationFieldProps) => { // If the field is the pubkey field, render the pubkey field if (props.type == 'string' && props.name === 'pubkey') { return ; diff --git a/web_ui/frontend/app/registry/components/Form.tsx b/web_ui/frontend/app/registry/components/Form.tsx index 24f8fa128..971cf1e04 100644 --- a/web_ui/frontend/app/registry/components/Form.tsx +++ b/web_ui/frontend/app/registry/components/Form.tsx @@ -1,5 +1,11 @@ import { Box, Button, Alert } from '@mui/material'; -import React, { useEffect, useState, Dispatch, SetStateAction } from 'react'; +import React, { + useEffect, + useState, + Dispatch, + SetStateAction, + useContext, +} from 'react'; import useSWR from 'swr'; import { Namespace } from '@/index'; @@ -11,27 +17,16 @@ import { populateKey, submitNamespaceForm, } from '@/app/registry/components/util'; -import { CustomRegistrationPropsEnum } from './CustomRegistrationField/index.d'; -import { getErrorMessage } from '@/helpers/util'; +import { CustomRegistrationFieldProps } from './CustomRegistrationField'; +import { alertOnError, getErrorMessage } from '@/helpers/util'; +import { optionsNamespaceRegistrationFields } from '@/helpers/api'; +import { AlertDispatchContext } from '@/components/AlertProvider'; interface FormProps { namespace?: Namespace; onSubmit: (data: Partial) => Promise; } -const getRegistrationFields = async (): Promise< - CustomRegistrationPropsEnum[] -> => { - const response = await fetch('/api/v1.0/registry_ui/namespaces', { - method: 'OPTIONS', - }); - if (response.ok) { - return await response.json(); - } else { - throw new Error(await getErrorMessage(response)); - } -}; - const onChange = ( name: string, value: string | number | boolean | null, @@ -53,13 +48,26 @@ const onChange = ( }; const Form = ({ namespace, onSubmit }: FormProps) => { + const dispatch = useContext(AlertDispatchContext); + const [data, setData] = useState | undefined>( namespace || {} ); - const { data: fields, error } = useSWR( - 'getRegistrationFields', - getRegistrationFields, + const { data: fields, error } = useSWR< + Omit[] | undefined + >( + 'optionsNamespaceRegistrationFields', + async () => { + const response = await alertOnError( + optionsNamespaceRegistrationFields, + "Couldn't fetch registration fields", + dispatch + ); + if (response) { + return await response.json(); + } + }, { fallbackData: [] } ); diff --git a/web_ui/frontend/app/registry/components/PostPage.tsx b/web_ui/frontend/app/registry/components/PostPage.tsx index 6c8ad7be2..3e22a1631 100644 --- a/web_ui/frontend/app/registry/components/PostPage.tsx +++ b/web_ui/frontend/app/registry/components/PostPage.tsx @@ -19,44 +19,49 @@ 'use client'; import { Box, Grid, Collapse, Alert, Skeleton } from '@mui/material'; -import React, { useEffect, useState } from 'react'; - -import { Alert as AlertType, Namespace } from '@/index'; +import React, { useContext, useEffect, useState } from 'react'; import Form from '@/app/registry/components/Form'; import AuthenticatedContent from '@/components/layout/AuthenticatedContent'; import { submitNamespaceForm } from '@/app/registry/components/util'; -import type { NamespaceFormPage } from './CustomRegistrationField/index.d'; +import { NamespaceFormPage } from '@/app/registry/components'; +import { alertOnError } from '@/helpers/util'; +import { AlertDispatchContext } from '@/components/AlertProvider'; const PostPage = ({ update }: NamespaceFormPage) => { + const dispatch = useContext(AlertDispatchContext); + const [fromUrl, setFromUrl] = useState(undefined); - const [alert, setAlert] = useState(undefined); useEffect(() => { - const urlParams = new URLSearchParams(window.location.search); - const fromUrl = urlParams.get('fromUrl'); + (async () => { + const urlParams = new URLSearchParams(window.location.search); + const fromUrl = urlParams.get('fromUrl'); - try { if (fromUrl != undefined) { - const parsedUrl = new URL(fromUrl); - setFromUrl(parsedUrl); + const parsedUrl = await alertOnError( + () => new URL(fromUrl), + 'Failed to parse URL', + dispatch + ); + if (parsedUrl) { + setFromUrl(parsedUrl); + } } - } catch (e) { - setAlert({ severity: 'error', message: 'Invalid fromUrl provided' }); - } + })(); }, []); return ( - - - {alert?.message} - - { - setAlert(await submitNamespaceForm(data, fromUrl, update)); + onSubmit={async (namespace) => { + await alertOnError( + async () => + await submitNamespaceForm(namespace, fromUrl, update), + 'Failed to update namespace', + dispatch + ); }} /> diff --git a/web_ui/frontend/app/registry/components/PutPage.tsx b/web_ui/frontend/app/registry/components/PutPage.tsx index 06781796b..b8236e9d6 100644 --- a/web_ui/frontend/app/registry/components/PutPage.tsx +++ b/web_ui/frontend/app/registry/components/PutPage.tsx @@ -29,6 +29,7 @@ import { import React, { ReactNode, Suspense, + useContext, useEffect, useMemo, useState, @@ -37,17 +38,18 @@ import React, { import AuthenticatedContent from '@/components/layout/AuthenticatedContent'; import { Namespace, Alert as AlertType } from '@/index'; import Form from '@/app/registry/components/Form'; -import { - getNamespace, - submitNamespaceForm, -} from '@/app/registry/components/util'; -import type { NamespaceFormPage } from './CustomRegistrationField/index.d'; +import { submitNamespaceForm } from '@/app/registry/components/util'; +import { getNamespace } from '@/helpers/api'; +import { NamespaceFormPage } from '@/app/registry/components'; +import { AlertDispatchContext } from '@/components/AlertProvider'; +import { alertOnError } from '@/helpers/util'; const PutPage = ({ update }: NamespaceFormPage) => { const [id, setId] = useState(undefined); const [fromUrl, setFromUrl] = useState(undefined); const [namespace, setNamespace] = useState(undefined); - const [alert, setAlert] = useState(undefined); + + const dispatch = useContext(AlertDispatchContext); useEffect(() => { const urlParams = new URLSearchParams(window.location.search); @@ -56,7 +58,15 @@ const PutPage = ({ update }: NamespaceFormPage) => { const accessToken = urlParams.get('access_token'); if (id === null) { - setAlert({ severity: 'error', message: 'No Namespace ID Provided' }); + dispatch({ + type: 'openAlert', + payload: { + title: 'No Namespace ID Provided', + message: + "Your URL should contain a query parameter 'id' with the ID of the namespace you want to edit", + onClose: () => dispatch({ type: 'closeAlert' }), + }, + }); return; } @@ -66,24 +76,46 @@ const PutPage = ({ update }: NamespaceFormPage) => { setFromUrl(parsedUrl); } } catch (e) { - setAlert({ severity: 'error', message: 'Invalid fromUrl provided' }); + dispatch({ + type: 'openAlert', + payload: { + title: 'Invalid fromUrl provided', + message: + 'The `fromUrl` parameter provided is not a valid URL, this will only impact your redirection on completion of this form', + alertProps: { + severity: 'warning', + }, + onClose: () => dispatch({ type: 'closeAlert' }), + }, + }); } try { setId(parseInt(id)); } catch (e) { - setAlert({ severity: 'error', message: 'Invalid Namespace ID Provided' }); + dispatch({ + type: 'openAlert', + payload: { + title: 'Invalid Namespace ID provided', + message: + 'The Namespace Id provided is not a valid number. Please report this issue, as well as what link directed you here.', + alertProps: { + severity: 'error', + }, + onClose: () => dispatch({ type: 'closeAlert' }), + }, + }); } (async () => { if (id !== undefined) { - try { - setNamespace(await getNamespace(id, accessToken || undefined)); - } catch (e) { - if (e instanceof Error) { - setAlert({ severity: 'error', message: e.message }); - } - setAlert({ severity: 'error', message: 'Could not fetch namespace' }); + const response = await alertOnError( + async () => await getNamespace(id, accessToken || undefined), + "Couldn't get namespace", + dispatch + ); + if (response) { + setNamespace(await response.json()); } } })(); @@ -93,17 +125,17 @@ const PutPage = ({ update }: NamespaceFormPage) => { - - - {alert?.message} - - {namespace ? ( { let namespace = { ...data, id: id }; - setAlert(await submitNamespaceForm(namespace, fromUrl, update)); + await alertOnError( + async () => + await submitNamespaceForm(namespace, fromUrl, update), + 'Failed to update namespace', + dispatch + ); }} /> ) : ( diff --git a/web_ui/frontend/app/registry/components/index.ts b/web_ui/frontend/app/registry/components/index.ts new file mode 100644 index 000000000..30e5853be --- /dev/null +++ b/web_ui/frontend/app/registry/components/index.ts @@ -0,0 +1,5 @@ +import { Alert as AlertType, Namespace } from '@/index'; + +export interface NamespaceFormPage { + update: (data: Partial) => Promise; +} diff --git a/web_ui/frontend/app/registry/components/util.tsx b/web_ui/frontend/app/registry/components/util.tsx index cc4a5e0ab..718d0202b 100644 --- a/web_ui/frontend/app/registry/components/util.tsx +++ b/web_ui/frontend/app/registry/components/util.tsx @@ -26,7 +26,15 @@ export const calculateKeys = (key: string) => { return [key]; }; -export const getValue = (o: any, key: string[]): string | undefined => { +/** + * Get the value of a key in an object + * @param o Object to get the value from + * @param key List of keys to traverse + */ +export const getValue = ( + o: Record | undefined, + key: string[] +): any => { if (o === undefined) { return undefined; } @@ -49,30 +57,6 @@ export const deleteKey = (o: any, key: string[]) => { return o; }; -const handleRequestAlert = async ( - url: string, - options: any -): Promise => { - try { - const response = await secureFetch(url, options); - - if (!response.ok) { - let errorMessage = await getErrorMessage(response); - return { severity: 'error', message: errorMessage }; - } - } catch (e) { - return { severity: 'error', message: `Fetch error: ${e}` }; - } -}; - -const namespaceFormNodeToJSON = (formData: FormData) => { - let data: any = {}; - formData.forEach((value: any, name: any) => { - populateKey(data, calculateKeys(name), value); - }); - return data; -}; - export const namespaceToCache = (data: Namespace) => { // Build the cache prefix if (data.prefix.startsWith('/caches/')) { @@ -93,74 +77,15 @@ export const namespaceToOrigin = (data: Namespace) => { return data; }; -export const getNamespace = async ( - id: string | number, - accessToken?: string -): Promise => { - const url = new URL( - `/api/v1.0/registry_ui/namespaces/${id}`, - window.location.origin - ); - if (accessToken) { - url.searchParams.append('access_token', accessToken); - } - const response = await fetch(url); - if (response.ok) { - return await response.json(); - } else { - throw new Error(await getErrorMessage(response)); - } -}; - -export const postGeneralNamespace = async ( - data: Namespace -): Promise => { - return await handleRequestAlert('/api/v1.0/registry_ui/namespaces', { - body: JSON.stringify(data), - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - credentials: 'include', - }); -}; - -export const putGeneralNamespace = async ( - data: Namespace -): Promise => { - // If an access_token is in the URL, add it to the request - const url = new URL( - `/api/v1.0/registry_ui/namespaces/${data.id}`, - window.location.origin - ); - const accessToken = new URLSearchParams(window.location.search).get( - 'access_token' - ); - if (accessToken) { - url.searchParams.append('access_token', accessToken); - } - - return await handleRequestAlert(url.toString(), { - body: JSON.stringify(data), - method: 'PUT', - headers: { - 'Content-Type': 'application/json', - }, - credentials: 'include', - }); -}; - export const submitNamespaceForm = async ( data: Partial, toUrl: URL | undefined, - handleSubmit: (data: Partial) => Promise + handleSubmit: (data: Partial) => Promise ) => { - const submitAlert = await handleSubmit(data); + const response = await handleSubmit(data); // Clear the form on successful submit - if (submitAlert == undefined) { + if (response != undefined) { window.location.href = toUrl ? toUrl.toString() : '/view/registry/'; } - - return submitAlert; }; diff --git a/web_ui/frontend/app/registry/denied/page.tsx b/web_ui/frontend/app/registry/denied/page.tsx index f1bee0e5e..851fb6d03 100644 --- a/web_ui/frontend/app/registry/denied/page.tsx +++ b/web_ui/frontend/app/registry/denied/page.tsx @@ -18,71 +18,29 @@ 'use client'; -import { - Box, - Button, - Grid, - Typography, - Paper, - Alert, - Collapse, - IconButton, -} from '@mui/material'; -import React, { useEffect, useMemo, useState } from 'react'; +import { Box, Grid, Typography, Alert, Collapse } from '@mui/material'; +import React, { useContext, useMemo } from 'react'; -import { - PendingCard, - Card, - CardSkeleton, - CreateNamespaceCard, -} from '@/components/Namespace'; -import Link from 'next/link'; -import { Namespace, Alert as AlertType } from '@/index'; +import { CardSkeleton } from '@/components/Namespace'; import { getUser } from '@/helpers/login'; -import { Add } from '@mui/icons-material'; import NamespaceCardList from '@/components/Namespace/NamespaceCardList'; import useSWR from 'swr'; import { CardProps } from '@/components/Namespace/Card'; -import { PendingCardProps } from '@/components/Namespace/PendingCard'; import AuthenticatedContent from '@/components/layout/AuthenticatedContent'; import DeniedCard from '@/components/Namespace/DeniedCard'; +import { getExtendedNamespaces } from '@/helpers/get'; +import { AlertDispatchContext } from '@/components/AlertProvider'; +import { alertOnError } from '@/helpers/util'; -const getData = async () => { - let data: { namespace: Namespace }[] = []; +export default function Home() { + const dispatch = useContext(AlertDispatchContext); - const url = new URL( - '/api/v1.0/registry_ui/namespaces', - window.location.origin + const { data } = useSWR('getExtendedNamespaces', async () => + alertOnError(getExtendedNamespaces, "Couldn't fetch namespaces", dispatch) + ); + const { data: user, error } = useSWR('getUser', async () => + alertOnError(getUser, "Couldn't fetch user", dispatch) ); - - const response = await fetch(url); - if (response.ok) { - const responseData: Namespace[] = await response.json(); - responseData.sort((a, b) => (a.id > b.id ? 1 : -1)); - responseData.forEach((namespace) => { - if (namespace.prefix.startsWith('/caches/')) { - namespace.type = 'cache'; - namespace.prefix = namespace.prefix.replace('/caches/', ''); - } else if (namespace.prefix.startsWith('/origins/')) { - namespace.type = 'origin'; - namespace.prefix = namespace.prefix.replace('/origins/', ''); - } else { - namespace.type = 'namespace'; - } - }); - - // Convert data to Partial CardProps - data = responseData.map((d) => { - return { namespace: d }; - }); - } - - return data; -}; - -export default function Home() { - const { data } = useSWR('getNamespaces', getData); - const { data: user, error } = useSWR('getUser', getUser); const deniedNamespaces = useMemo( () => @@ -97,11 +55,6 @@ export default function Home() { Namespace Registry - - - {error?.toString()} - - diff --git a/web_ui/frontend/app/registry/namespace/edit/page.tsx b/web_ui/frontend/app/registry/namespace/edit/page.tsx index 2abb51461..de07c32fd 100644 --- a/web_ui/frontend/app/registry/namespace/edit/page.tsx +++ b/web_ui/frontend/app/registry/namespace/edit/page.tsx @@ -19,9 +19,10 @@ 'use client'; import { PutPage } from '@/app/registry/components/PutPage'; -import { putGeneralNamespace } from '@/app/registry/components/util'; +import { putGeneralNamespace } from '@/helpers/api'; import { Box, Grid, Typography } from '@mui/material'; import React from 'react'; +import AuthenticatedContent from '@/components/layout/AuthenticatedContent'; export default function Page() { const putCache = async (data: any) => { @@ -40,7 +41,9 @@ export default function Page() { - + + + diff --git a/web_ui/frontend/app/registry/namespace/register/page.tsx b/web_ui/frontend/app/registry/namespace/register/page.tsx index 12d60291f..be3aaeae2 100644 --- a/web_ui/frontend/app/registry/namespace/register/page.tsx +++ b/web_ui/frontend/app/registry/namespace/register/page.tsx @@ -18,10 +18,11 @@ 'use client'; -import { postGeneralNamespace } from '@/app/registry/components/util'; +import { postGeneralNamespace } from '@/helpers/api'; import { PostPage } from '@/app/registry/components/PostPage'; import { Box, Grid, Typography } from '@mui/material'; import React from 'react'; +import AuthenticatedContent from '@/components/layout/AuthenticatedContent'; export default function Page() { const postCache = async (data: any) => { @@ -40,7 +41,9 @@ export default function Page() { - + + + diff --git a/web_ui/frontend/app/registry/origin/edit/page.tsx b/web_ui/frontend/app/registry/origin/edit/page.tsx index 683381232..a40a7b81b 100644 --- a/web_ui/frontend/app/registry/origin/edit/page.tsx +++ b/web_ui/frontend/app/registry/origin/edit/page.tsx @@ -19,12 +19,11 @@ 'use client'; import { PutPage } from '@/app/registry/components/PutPage'; -import { - namespaceToOrigin, - putGeneralNamespace, -} from '@/app/registry/components/util'; +import { namespaceToOrigin } from '@/app/registry/components/util'; import { Box, Grid, Typography } from '@mui/material'; import React from 'react'; +import { putGeneralNamespace } from '@/helpers/api'; +import AuthenticatedContent from '@/components/layout/AuthenticatedContent'; export default function Page() { const putCache = async (data: any) => { @@ -44,7 +43,9 @@ export default function Page() { - + + + diff --git a/web_ui/frontend/app/registry/origin/register/page.tsx b/web_ui/frontend/app/registry/origin/register/page.tsx index a89e3f83d..dc61fc18b 100644 --- a/web_ui/frontend/app/registry/origin/register/page.tsx +++ b/web_ui/frontend/app/registry/origin/register/page.tsx @@ -18,13 +18,12 @@ 'use client'; -import { - namespaceToOrigin, - postGeneralNamespace, -} from '@/app/registry/components/util'; +import { namespaceToOrigin } from '@/app/registry/components/util'; import { PostPage } from '@/app/registry/components/PostPage'; import { Box, Grid, Typography } from '@mui/material'; import React from 'react'; +import { postGeneralNamespace } from '@/helpers/api'; +import AuthenticatedContent from '@/components/layout/AuthenticatedContent'; export default function Page() { const postCache = async (data: any) => { @@ -44,7 +43,9 @@ export default function Page() { - + + + diff --git a/web_ui/frontend/app/registry/page.tsx b/web_ui/frontend/app/registry/page.tsx index caf27b17a..0835c9fc9 100644 --- a/web_ui/frontend/app/registry/page.tsx +++ b/web_ui/frontend/app/registry/page.tsx @@ -28,7 +28,7 @@ import { Collapse, IconButton, } from '@mui/material'; -import React, { useEffect, useMemo, useState } from 'react'; +import React, { useEffect, useMemo, useState, useContext } from 'react'; import { PendingCard, @@ -44,19 +44,32 @@ import { Add } from '@mui/icons-material'; import useSWR from 'swr'; import { CardProps } from '@/components/Namespace/Card'; import { PendingCardProps } from '@/components/Namespace/PendingCard'; +import { AlertDispatchContext } from '@/components/AlertProvider'; +import { alertOnError } from '@/helpers/util'; +import { getExtendedNamespaces } from '@/helpers/get'; export default function Home() { - const [alert, setAlert] = useState(undefined); + const dispatch = useContext(AlertDispatchContext); - const { data, mutate: mutateNamespaces } = useSWR<{ namespace: Namespace }[]>( - 'getNamespaces', - getData, + const { data, mutate: mutateNamespaces } = useSWR< + { namespace: Namespace }[] | undefined + >( + 'getExtendedNamespaces', + () => + alertOnError( + getExtendedNamespaces, + 'Failed to fetch namespaces', + dispatch + ), { fallbackData: [], } ); - const { data: user, error } = useSWR('getUser', getUser); + const { data: user, error } = useSWR( + 'getUser', + async () => await alertOnError(getUser, 'Error Getting User', dispatch) + ); const pendingData = useMemo(() => { return data?.filter( @@ -97,33 +110,7 @@ export default function Home() { return ( - - Namespace Registry - - - {alert?.message} - - - - {user == undefined || - (!user.authenticated && ( - - - Login to register new namespaces. - - - - - - ))} {pendingData && pendingData.length > 0 && ( setAlert(a), onUpdate: () => mutateNamespaces(), }} /> @@ -263,36 +249,3 @@ export default function Home() { ); } - -const getData = async () => { - let data: { namespace: Namespace }[] = []; - - const url = new URL( - '/api/v1.0/registry_ui/namespaces', - window.location.origin - ); - - const response = await fetch(url); - if (response.ok) { - const responseData: Namespace[] = await response.json(); - responseData.sort((a, b) => (a.id > b.id ? 1 : -1)); - responseData.forEach((namespace) => { - if (namespace.prefix.startsWith('/caches/')) { - namespace.type = 'cache'; - namespace.prefix = namespace.prefix.replace('/caches/', ''); - } else if (namespace.prefix.startsWith('/origins/')) { - namespace.type = 'origin'; - namespace.prefix = namespace.prefix.replace('/origins/', ''); - } else { - namespace.type = 'namespace'; - } - }); - - // Convert data to Partial CardProps - data = responseData.map((d) => { - return { namespace: d }; - }); - } - - return data; -}; diff --git a/web_ui/frontend/app/test/page.tsx b/web_ui/frontend/app/test/page.tsx new file mode 100644 index 000000000..5f6e11e7e --- /dev/null +++ b/web_ui/frontend/app/test/page.tsx @@ -0,0 +1,53 @@ +'use client'; + +import { AlertDispatchContext } from '@/components/AlertProvider'; +import React, { useContext } from 'react'; +import { Box } from '@mui/material'; +import CodeBlock from '@/components/CodeBlock'; + +const Page = () => { + const dispatch = useContext(AlertDispatchContext); + + return ( +
+ + +
+ ); +}; + +export default Page; diff --git a/web_ui/frontend/components/AlertPortal.tsx b/web_ui/frontend/components/AlertPortal.tsx index 7fc32a48c..4c75a0574 100644 --- a/web_ui/frontend/components/AlertPortal.tsx +++ b/web_ui/frontend/components/AlertPortal.tsx @@ -1,35 +1,48 @@ import { Portal } from '@mui/base'; -import React from 'react'; -import { Alert, SnackbarProps, Snackbar } from '@mui/material'; - -import { Alert as AlertType } from '@/index'; +import React, { ReactNode } from 'react'; +import { + Alert, + AlertProps, + Snackbar, + SnackbarProps, + AlertTitle, +} from '@mui/material'; export interface AlertPortalProps { - alert?: AlertType; onClose: () => void; + title?: string; + autoHideDuration?: number; + message?: ReactNode | string; + alertProps?: Omit; snackBarProps?: SnackbarProps; } export const AlertPortal = ({ - alert, onClose, + title, + autoHideDuration, + message, + alertProps, snackBarProps, }: AlertPortalProps) => { + if (autoHideDuration) { + setTimeout(() => onClose(), autoHideDuration); + } + return ( - {alert?.message} + {title && {title}} + {message} diff --git a/web_ui/frontend/components/AlertProvider.tsx b/web_ui/frontend/components/AlertProvider.tsx new file mode 100644 index 000000000..11e356488 --- /dev/null +++ b/web_ui/frontend/components/AlertProvider.tsx @@ -0,0 +1,76 @@ +'use client'; + +import { createContext, Dispatch, useReducer } from 'react'; +import { AlertPortal, AlertPortalProps } from '@/components/AlertPortal'; +import CodeBlock from '@/components/CodeBlock'; + +const defaultAlertContext: AlertPortalProps | undefined = undefined; + +export const AlertContext = createContext( + defaultAlertContext +); + +export const AlertDispatchContext = createContext>( + () => {} +); + +export const AlertProvider = ({ children }: { children: React.ReactNode }) => { + const [state, dispatch] = useReducer(alertReducer, defaultAlertContext); + + return ( + + + {children} + {state && } + + + ); +}; + +const alertReducer = ( + state: AlertPortalProps | undefined, + action: AlertReducerAction +): AlertPortalProps | undefined => { + switch (action.type) { + case 'closeAlert': + return undefined; + case 'openErrorAlert': + const { title, error, onClose } = action.payload; + + return { + title, + onClose, + message: {error}, + alertProps: { + severity: 'error', + }, + }; + case 'openAlert': + return action.payload; + default: + return state; + } +}; + +export type AlertReducerAction = + | closeAlertAction + | openErrorAlertAction + | openAlertAction; + +type closeAlertAction = { + type: 'closeAlert'; +}; + +type openErrorAlertAction = { + type: 'openErrorAlert'; + payload: { + title: string; + error: string; + onClose: () => void; + }; +}; + +type openAlertAction = { + type: 'openAlert'; + payload: AlertPortalProps; +}; diff --git a/web_ui/frontend/components/CodeBlock.tsx b/web_ui/frontend/components/CodeBlock.tsx new file mode 100644 index 000000000..f742793e7 --- /dev/null +++ b/web_ui/frontend/components/CodeBlock.tsx @@ -0,0 +1,27 @@ +import { stackoverflowLight } from 'react-syntax-highlighter/dist/cjs/styles/hljs'; +import SyntaxHighlighter from 'react-syntax-highlighter'; +import { Box } from '@mui/material'; + +/** + * CodeBlock component + * Copy onClick and darken onHover + * @param children + * @constructor + */ +export const CodeBlock = ({ children }: { children: string | string[] }) => { + return ( + + { + navigator.clipboard.writeText(children.toString()); + }} + > + {children} + + + ); +}; + +export default CodeBlock; diff --git a/web_ui/frontend/components/FederationOverview.tsx b/web_ui/frontend/components/FederationOverview.tsx index 40895926d..f47d36841 100644 --- a/web_ui/frontend/components/FederationOverview.tsx +++ b/web_ui/frontend/components/FederationOverview.tsx @@ -7,6 +7,9 @@ import { Box, Typography } from '@mui/material'; import AuthenticatedContent from '@/components/layout/AuthenticatedContent'; import Link from 'next/link'; import { getErrorMessage, getObjectValue } from '@/helpers/util'; +import { getConfig } from '@/helpers/api'; +import { getFederationUrls } from '@/helpers/get'; +import useSWR from 'swr'; const LinkBox = ({ href, text }: { href: string; text: string }) => { return ( @@ -29,74 +32,26 @@ const LinkBox = ({ href, text }: { href: string; text: string }) => { ); }; -const UrlData = [ - { key: ['Federation', 'NamespaceUrl', 'Value'], text: 'Namespace Registry' }, - { key: ['Federation', 'DirectorUrl', 'Value'], text: 'Director' }, - { key: ['Federation', 'RegistryUrl', 'Value'], text: 'Registry' }, - { - key: ['Federation', 'TopologyNamespaceUrl', 'Value'], - text: 'Topology Namespace', - }, - { key: ['Federation', 'DiscoveryUrl', 'Value'], text: 'Discovery' }, - { key: ['Federation', 'JwkUrl', 'Value'], text: 'JWK' }, -]; - const FederationOverview = () => { - const [config, setConfig] = useState< - { text: string; url: string | undefined }[] - >([]); - - let getConfig = async () => { - let response = await fetch('/api/v1.0/config'); - if (response.ok) { - const responseData = (await response.json()) as Config; - - const federationUrls = UrlData.map(({ key, text }) => { - let url = getObjectValue(responseData, key); - if ( - url && - !url?.startsWith('http://') && - !url?.startsWith('https://') - ) { - url = 'https://' + url; - } - - return { - text, - url, - }; - }); - - setConfig(federationUrls); - } else { - console.error(await getErrorMessage(response)); - } - }; - - useEffect(() => { - getConfig(); - }, []); - - if (config === undefined) { - return; - } + const { data: federationUrls, error } = useSWR( + 'getFederationUrls', + getFederationUrls, + { fallbackData: [] } + ); return ( - u?.role == 'admin'} - > - {!Object.values(config).every((x) => x == undefined) ? ( + <> + {!Object.values(federationUrls).every((x) => x == undefined) ? ( Federation Overview ) : null} - {config.map(({ text, url }) => { + {federationUrls.map(({ text, url }) => { if (url) { return ; } })} - + ); }; diff --git a/web_ui/frontend/components/Namespace/Card.tsx b/web_ui/frontend/components/Namespace/Card.tsx index c873ec115..01d443243 100644 --- a/web_ui/frontend/components/Namespace/Card.tsx +++ b/web_ui/frontend/components/Namespace/Card.tsx @@ -1,5 +1,5 @@ import { Alert, Alert as AlertType, Namespace } from '@/index'; -import React, { useRef, useState } from 'react'; +import React, { useContext, useRef, useState } from 'react'; import { Avatar, Box, @@ -14,9 +14,11 @@ import Link from 'next/link'; import InformationDropdown from './InformationDropdown'; import { NamespaceIcon } from '@/components/Namespace/index'; import { User } from '@/index'; -import AlertPortal from '@/components/AlertPortal'; -import { deleteNamespace } from './DeniedCard'; +import { deleteNamespace } from '@/helpers/api'; import { useSWRConfig } from 'swr'; +import { AlertDispatchContext } from '@/components/AlertProvider'; +import CodeBlock from '@/components/CodeBlock'; +import { alertOnError } from '@/helpers/util'; export interface CardProps { namespace: Namespace; @@ -25,9 +27,9 @@ export interface CardProps { } export const Card = ({ namespace, authenticated, onUpdate }: CardProps) => { + const dispatch = useContext(AlertDispatchContext); const ref = useRef(null); const [transition, setTransition] = useState(false); - const [alert, setAlert] = useState(undefined); const { mutate } = useSWRConfig(); return ( <> @@ -102,20 +104,14 @@ export const Card = ({ namespace, authenticated, onUpdate }: CardProps) => { color={'error'} onClick={async (e) => { e.stopPropagation(); - try { - await deleteNamespace(namespace.id); - setAlert({ - severity: 'success', - message: 'Registration deleted', - }); - setTimeout(() => mutate('getNamespaces'), 600); - if (onUpdate) { - onUpdate(); - } - } catch (e) { - if (e instanceof Error) { - setAlert({ severity: 'error', message: e.message }); - } + await alertOnError( + async () => await deleteNamespace(namespace.id), + 'Could Not Delete Registration', + dispatch + ); + setTimeout(() => mutate('getExtendedNamespaces'), 600); + if (onUpdate) { + onUpdate(); } }} > @@ -135,7 +131,6 @@ export const Card = ({ namespace, authenticated, onUpdate }: CardProps) => { /> - setAlert(undefined)} /> ); }; diff --git a/web_ui/frontend/components/Namespace/DeniedCard.tsx b/web_ui/frontend/components/Namespace/DeniedCard.tsx index 348cb7a45..93617c531 100644 --- a/web_ui/frontend/components/Namespace/DeniedCard.tsx +++ b/web_ui/frontend/components/Namespace/DeniedCard.tsx @@ -1,14 +1,17 @@ -import React, { useMemo, useRef, useState } from 'react'; +import React, { useContext, useMemo, useRef, useState } from 'react'; import { green, red } from '@mui/material/colors'; import { Authenticated, secureFetch } from '@/helpers/login'; import { Avatar, Box, IconButton, Tooltip, Typography } from '@mui/material'; import { Block, Check, Delete, Edit, Person } from '@mui/icons-material'; -import { Alert as AlertType, Alert, Namespace } from '@/index'; +import { Alert, Namespace } from '@/index'; import InformationDropdown from './InformationDropdown'; import { getServerType, NamespaceIcon } from '@/components/Namespace/index'; +import { AlertContext, AlertDispatchContext } from '@/components/AlertProvider'; import { User } from '@/index'; -import AlertPortal from '@/components/AlertPortal'; import { useSWRConfig } from 'swr'; +import CodeBlock from '@/components/CodeBlock'; +import { approveNamespace, deleteNamespace } from '@/helpers/api'; +import { alertOnError } from '@/helpers/util'; export interface DeniedCardProps { namespace: Namespace; @@ -17,60 +20,11 @@ export interface DeniedCardProps { authenticated?: User; } -export const deleteNamespace = async (id: number) => { - const response = await secureFetch(`/api/v1.0/registry_ui/namespaces/${id}`, { - method: 'DELETE', - }); - - if (!response.ok) { - let alertMessage; - try { - let data = await response.json(); - if (data?.msg) { - alertMessage = data?.msg; - } - alertMessage = 'Details not provided'; - } catch (e) { - if (e instanceof Error) { - alertMessage = e.message; - } - } - - throw new Error('Failed to delete namespace: ' + alertMessage); - } -}; - -const approveNamespace = async (id: number) => { - const response = await secureFetch( - `/api/v1.0/registry_ui/namespaces/${id}/approve`, - { - method: 'PATCH', - } - ); - - if (!response.ok) { - let alertMessage; - try { - let data = await response.json(); - if (data?.msg) { - alertMessage = data?.msg; - } - alertMessage = 'Details not provided'; - } catch (e) { - if (e instanceof Error) { - alertMessage = e.message; - } - } - - throw new Error('Failed to approve registration: ' + alertMessage); - } -}; - export const DeniedCard = ({ namespace, authenticated }: DeniedCardProps) => { const ref = useRef(null); const [transition, setTransition] = useState(false); - const [alert, setAlert] = useState(undefined); - + const dispatch = useContext(AlertDispatchContext); + const alert = useContext(AlertContext); const { mutate } = useSWRConfig(); return ( @@ -86,9 +40,9 @@ export const DeniedCard = ({ namespace, authenticated }: DeniedCardProps) => { borderRadius: transition ? '10px 10px 0px 0px' : 2, transition: 'background-color .3s ease-out', bgcolor: - alert?.severity == 'success' + alert?.alertProps?.severity == 'success' ? green[100] - : alert?.severity == 'error' + : alert?.alertProps?.severity == 'error' ? red[100] : 'inherit', '&:hover': { @@ -130,18 +84,11 @@ export const DeniedCard = ({ namespace, authenticated }: DeniedCardProps) => { color={'error'} onClick={async (e) => { e.stopPropagation(); - try { - await deleteNamespace(namespace.id); - setAlert({ - severity: 'success', - message: 'Registration deleted', - }); - setTimeout(() => mutate('getNamespaces'), 600); - } catch (e) { - if (e instanceof Error) { - setAlert({ severity: 'error', message: e.message }); - } - } + await alertOnError( + () => deleteNamespace(namespace.id), + 'Could Not Delete Registration', + dispatch + ); }} > @@ -153,18 +100,12 @@ export const DeniedCard = ({ namespace, authenticated }: DeniedCardProps) => { color={'success'} onClick={async (e) => { e.stopPropagation(); - try { - await approveNamespace(namespace.id); - setAlert({ - severity: 'success', - message: 'Registration Approved', - }); - setTimeout(() => mutate('getNamespaces'), 600); - } catch (e) { - if (e instanceof Error) { - setAlert({ severity: 'error', message: e.message }); - } - } + await alertOnError( + () => approveNamespace(namespace.id), + 'Could Not Approve Registration', + dispatch + ); + setTimeout(() => mutate('getExtendedNamespaces'), 600); }} > @@ -183,13 +124,6 @@ export const DeniedCard = ({ namespace, authenticated }: DeniedCardProps) => { /> - {alert?.severity == 'error' && ( - setAlert(undefined)} - /> - )} ); }; diff --git a/web_ui/frontend/components/Namespace/InformationDropdown.tsx b/web_ui/frontend/components/Namespace/InformationDropdown.tsx index db87fc751..b83b5df00 100644 --- a/web_ui/frontend/components/Namespace/InformationDropdown.tsx +++ b/web_ui/frontend/components/Namespace/InformationDropdown.tsx @@ -1,7 +1,10 @@ import { Box, Tooltip, Collapse, Grid, Typography } from '@mui/material'; import React from 'react'; -import { NamespaceAdminMetadata } from './index.d'; -import { Dropdown, InformationSpan } from '@/components'; +import { + Dropdown, + InformationSpan, + NamespaceAdminMetadata, +} from '@/components'; interface InformationDropdownProps { adminMetadata: NamespaceAdminMetadata; diff --git a/web_ui/frontend/components/Namespace/PendingCard.tsx b/web_ui/frontend/components/Namespace/PendingCard.tsx index 4257ef09a..b661fe284 100644 --- a/web_ui/frontend/components/Namespace/PendingCard.tsx +++ b/web_ui/frontend/components/Namespace/PendingCard.tsx @@ -1,4 +1,4 @@ -import React, { useMemo, useRef, useState } from 'react'; +import React, { useContext, useMemo, useRef, useState } from 'react'; import { Authenticated, secureFetch } from '@/helpers/login'; import { Avatar, Box, IconButton, Tooltip, Typography } from '@mui/material'; import { Block, Check, Edit, Person } from '@mui/icons-material'; @@ -8,6 +8,9 @@ import { Alert, Namespace } from '@/index'; import InformationDropdown from './InformationDropdown'; import { getServerType, NamespaceIcon } from '@/components/Namespace/index'; import { User } from '@/index'; +import { alertOnError } from '@/helpers/util'; +import { AlertDispatchContext } from '@/components/AlertProvider'; +import { approveNamespace, denyNamespace } from '@/helpers/api'; export interface PendingCardProps { namespace: Namespace; @@ -25,61 +28,7 @@ export const PendingCard = ({ const ref = useRef(null); const [transition, setTransition] = useState(false); - const approveNamespace = async (e: React.MouseEvent) => { - e.stopPropagation(); - - try { - const response = await secureFetch( - `/api/v1.0/registry_ui/namespaces/${namespace.id}/approve`, - { - method: 'PATCH', - } - ); - - if (!response.ok) { - onAlert({ - severity: 'error', - message: `Failed to approve ${namespace.type} registration: ${namespace.prefix}`, - }); - } else { - onUpdate(); - onAlert({ - severity: 'success', - message: `Successfully approved ${namespace.type} registration: ${namespace.prefix}`, - }); - } - } catch (error) { - console.error(error); - } - }; - - const denyNamespace = async (e: React.MouseEvent) => { - e.stopPropagation(); - - try { - const response = await secureFetch( - `/api/v1.0/registry_ui/namespaces/${namespace.id}/deny`, - { - method: 'PATCH', - } - ); - - if (!response.ok) { - onAlert({ - severity: 'error', - message: `Failed to deny ${namespace.type} registration: ${namespace.prefix}`, - }); - } else { - onUpdate(); - onAlert({ - severity: 'success', - message: `Successfully denied ${namespace.type} registration: ${namespace.prefix}`, - }); - } - } catch (error) { - console.error(error); - } - }; + const dispatch = useContext(AlertDispatchContext); return ( @@ -123,7 +72,15 @@ export const PendingCard = ({ denyNamespace(e)} + onClick={async (e) => { + e.stopPropagation(); + await alertOnError( + () => denyNamespace(namespace.id), + "Couldn't deny namespace", + dispatch + ); + onUpdate(); + }} > @@ -132,7 +89,15 @@ export const PendingCard = ({ approveNamespace(e)} + onClick={async (e) => { + e.stopPropagation(); + await alertOnError( + () => approveNamespace(namespace.id), + "Couldn't approve namespace", + dispatch + ); + onUpdate(); + }} > diff --git a/web_ui/frontend/components/Namespace/index.d.tsx b/web_ui/frontend/components/Namespace/index.d.tsx deleted file mode 100644 index c7292df96..000000000 --- a/web_ui/frontend/components/Namespace/index.d.tsx +++ /dev/null @@ -1,21 +0,0 @@ -import { PendingCardProps } from './PendingCard'; -import { CardProps } from './Card'; - -export interface NamespaceAdminMetadata { - user_id: string; - description: string; - site_name: string; - institution: string; - security_contact_user_id: string; - status: 'Pending' | 'Approved' | 'Denied' | 'Unknown'; - approver_id: number; - approved_at: string; - created_at: string; - updated_at: string; -} - -export interface FlatObject { - [key: string]: Exclude; -} - -export type NamespaceCardProps = CardProps & PendingCardProps; diff --git a/web_ui/frontend/components/Namespace/index.tsx b/web_ui/frontend/components/Namespace/index.tsx index 77dbe881d..3a43facd7 100644 --- a/web_ui/frontend/components/Namespace/index.tsx +++ b/web_ui/frontend/components/Namespace/index.tsx @@ -15,6 +15,28 @@ export { NamespaceIcon, }; +import { PendingCardProps } from './PendingCard'; +import { CardProps } from './Card'; + +export interface NamespaceAdminMetadata { + user_id: string; + description: string; + site_name: string; + institution: string; + security_contact_user_id: string; + status: 'Pending' | 'Approved' | 'Denied' | 'Unknown'; + approver_id: number; + approved_at: string; + created_at: string; + updated_at: string; +} + +export interface FlatObject { + [key: string]: Exclude; +} + +export type NamespaceCardProps = CardProps & PendingCardProps; + export const getServerType = (namespace: Namespace) => { // If the namespace is empty the value is undefined if (namespace?.prefix == null || namespace.prefix == '') { diff --git a/web_ui/frontend/public/theme.tsx b/web_ui/frontend/components/ThemeProvider.tsx similarity index 92% rename from web_ui/frontend/public/theme.tsx rename to web_ui/frontend/components/ThemeProvider.tsx index 9d4e8cfcd..85aba2a81 100644 --- a/web_ui/frontend/public/theme.tsx +++ b/web_ui/frontend/components/ThemeProvider.tsx @@ -30,7 +30,7 @@ const poppins = Poppins({ display: 'swap', }); -let theme = createTheme({ +let themeProvider = createTheme({ palette: { primary: { main: '#0885ff', @@ -78,7 +78,7 @@ let theme = createTheme({ }, }); -theme = responsiveFontSizes(theme, { factor: 3 }); +themeProvider = responsiveFontSizes(themeProvider, { factor: 3 }); interface ThemeProviderClientProps { children: React.ReactNode; @@ -87,5 +87,5 @@ interface ThemeProviderClientProps { export const ThemeProviderClient: FC = ({ children, }) => { - return {children}; + return {children}; }; diff --git a/web_ui/frontend/components/layout/AuthenticatedContent.tsx b/web_ui/frontend/components/layout/AuthenticatedContent.tsx index 5465a7d57..f1382b828 100644 --- a/web_ui/frontend/components/layout/AuthenticatedContent.tsx +++ b/web_ui/frontend/components/layout/AuthenticatedContent.tsx @@ -84,9 +84,9 @@ const AuthenticatedContent = ({ // Redirect to login page if not authenticated and redirect is true useEffect(() => { if (!isValidating && !authenticated && redirect) { - router.push('/login/?returnURL=' + pageUrl); + router.replace('/login/?returnURL=' + pageUrl); } - }, [data, isValidating]); + }, [data, isValidating, authenticated]); // If there was a error then print it to the screen if (error) { diff --git a/web_ui/frontend/dev/image/nginx.conf b/web_ui/frontend/dev/image/nginx.conf index 97fa6bc7e..4c5f83fda 100644 --- a/web_ui/frontend/dev/image/nginx.conf +++ b/web_ui/frontend/dev/image/nginx.conf @@ -56,6 +56,8 @@ http { proxy_connect_timeout 10s; proxy_set_header X-Real-IP $remote_addr; proxy_pass http://host.docker.internal:3000; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; } gzip on; diff --git a/web_ui/frontend/helpers/api.ts b/web_ui/frontend/helpers/api.ts new file mode 100644 index 000000000..4bb2c3a0d --- /dev/null +++ b/web_ui/frontend/helpers/api.ts @@ -0,0 +1,258 @@ +/** + * API Helper Functions + * + * Strictly return the response from the API, throwing an error if the response is not ok + */ + +import { secureFetch } from '@/helpers/login'; +import { getErrorMessage } from '@/helpers/util'; +import { Namespace } from '@/index'; + +/** + * Wraps an api request with error handling for both the request and the response if error + * @param fetchRequest The request to make to the api + * @returns The response from the api + */ +export async function fetchApi( + fetchRequest: () => Promise +): Promise { + try { + const response = await fetchRequest(); + if (!response.ok) { + let alertMessage; + try { + alertMessage = await getErrorMessage(response); + } catch (e) { + if (e instanceof Error) { + alertMessage = e.message; + } + } + throw new Error(alertMessage); + } + return response; + } catch (e) { + if (e instanceof Error) { + throw Error('Fetch to API Failed', { cause: e }); + } else { + throw Error('Fetch to API Failed', { cause: e }); + } + } +} + +/** + * Get config + */ +export const getConfig = async (): Promise => { + return fetchApi(async () => await secureFetch('/api/v1.0/config')); +}; + +/** + * Deletes a namespace + * @param id Namespace ID + */ +export const deleteNamespace = async (id: number) => { + return fetchApi( + async () => + await secureFetch(`/api/v1.0/registry_ui/namespaces/${id}`, { + method: 'DELETE', + }) + ); +}; + +/** + * Approves a namespace + * @param id Namespace ID + */ +export const approveNamespace = async (id: number): Promise => { + return fetchApi( + async () => + await secureFetch(`/api/v1.0/registry_ui/namespaces/${id}/approve`, { + method: 'PATCH', + }) + ); +}; + +/** + * Denies a namespace + * @param id Namespace ID + */ +export const denyNamespace = async (id: number): Promise => { + return fetchApi( + async () => + await secureFetch(`/api/v1.0/registry_ui/namespaces/${id}/deny`, { + method: 'PATCH', + }) + ); +}; + +/** + * Enables a server on the director + * @param name Server name + */ +export const allowServer = async (name: string): Promise => { + return fetchApi( + async () => + await secureFetch(`/api/v1.0/director_ui/servers/allow/${name}`, { + method: 'PATCH', + }) + ); +}; + +/** + * Filters ( Disables ) a server on the director + * @param name Server name + */ +export const filterServer = async (name: string): Promise => { + return fetchApi( + async () => + await secureFetch(`/api/v1.0/director_ui/servers/filter/${name}`, { + method: 'PATCH', + }) + ); +}; + +/** + * Get namespaces + */ +export const getNamespaces = async (): Promise => { + const url = new URL( + '/api/v1.0/registry_ui/namespaces', + window.location.origin + ); + + return await fetchApi(async () => await fetch(url)); +}; + +/** + * Gets a namespace by ID + * @param id Namespace ID + * @param accessToken Access token + */ +export const getNamespace = async ( + id: string | number, + accessToken?: string +): Promise => { + const url = new URL( + `/api/v1.0/registry_ui/namespaces/${id}`, + window.location.origin + ); + if (accessToken) { + url.searchParams.append('access_token', accessToken); + } + return await fetchApi(async () => await fetch(url)); +}; + +export const postGeneralNamespace = async ( + data: Namespace +): Promise => { + return await fetchApi( + async () => + await secureFetch('/api/v1.0/registry_ui/namespaces', { + body: JSON.stringify(data), + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + credentials: 'include', + }) + ); +}; + +export const putGeneralNamespace = async ( + data: Namespace +): Promise => { + + // If an access_token is in the URL, add it to the request + const url = new URL( + `/api/v1.0/registry_ui/namespaces/${data.id}`, + window.location.origin + ); + const accessToken = new URLSearchParams(window.location.search).get( + 'access_token' + ); + if (accessToken) { + url.searchParams.append('access_token', accessToken); + } + + return await fetchApi(async () => { + return secureFetch(url.toString(), { + body: JSON.stringify(data), + method: 'PUT', + headers: { + 'Content-Type': 'application/json', + }, + credentials: 'include', + }); + }); +}; + +/** + * Get registration fields from options for namespace + */ +export const optionsNamespaceRegistrationFields = + async (): Promise => { + return await fetchApi( + async () => + await fetch('/api/v1.0/registry_ui/namespaces', { + method: 'OPTIONS', + }) + ); + }; + +/** + * Initializes a login via terminal code + */ +export const initLogin = async (code: string): Promise => { + return await fetchApi( + async () => + await fetch('/api/v1.0/auth/initLogin', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + code: code, + }), + }) + ); +}; + +/** + * Reset ( Do initial ) Login + */ +export const resetLogin = async (password: string): Promise => { + return await fetchApi( + async () => + await fetch('/api/v1.0/auth/resetLogin', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + password: password, + }), + }) + ); +}; + +/** + * Login + */ +export const login = async ( + password: string, + user: string = 'admin' +): Promise => { + return await fetchApi( + async () => + await fetch('/api/v1.0/auth/login', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + user: user, + password: password, + }), + }) + ); +}; diff --git a/web_ui/frontend/helpers/get.ts b/web_ui/frontend/helpers/get.ts index ca2be9954..629554330 100644 --- a/web_ui/frontend/helpers/get.ts +++ b/web_ui/frontend/helpers/get.ts @@ -1,9 +1,82 @@ +/** + * API wrappers for manipulating fetched data + * + * @module helpers/get + */ + import { Config, ParameterValueRecord } from '@/components/configuration'; +import { getConfig as getConfigResponse, getNamespaces } from '@/helpers/api'; import { flattenObject } from '@/app/config/util'; +import { Namespace } from '@/index'; +import { getObjectValue } from '@/helpers/util'; export const getConfig = async (): Promise => { - let response = await fetch('/api/v1.0/config'); + let response = await getConfigResponse(); let data = await response.json(); let flatData = flattenObject(data); return flatData; }; + +/** + * Get extended namespaces + */ +export const getExtendedNamespaces = async (): Promise< + { namespace: Namespace }[] +> => { + const response = await getNamespaces(); + const data: Namespace[] = await response.json(); + data.sort((a, b) => (a.id > b.id ? 1 : -1)); + data.forEach((namespace) => { + if (namespace.prefix.startsWith('/caches/')) { + namespace.type = 'cache'; + namespace.prefix = namespace.prefix.replace('/caches/', ''); + } else if (namespace.prefix.startsWith('/origins/')) { + namespace.type = 'origin'; + namespace.prefix = namespace.prefix.replace('/origins/', ''); + } else { + namespace.type = 'namespace'; + } + }); + + return data.map((d) => { + return { namespace: d }; + }); +}; + +/** + * Get federation URLs + */ +export const getFederationUrls = async () => { + try { + const response = await getConfigResponse(); + const responseData = (await response.json()) as Config; + + const federationUrls = UrlData.map(({ key, text }) => { + let url = getObjectValue(responseData, key); + if (url && !url?.startsWith('http://') && !url?.startsWith('https://')) { + url = 'https://' + url; + } + + return { + text, + url, + }; + }); + + return federationUrls; + } catch (e) { + console.error(e); + return []; + } +}; +const UrlData = [ + { key: ['Federation', 'NamespaceUrl'], text: 'Namespace Registry' }, + { key: ['Federation', 'DirectorUrl'], text: 'Director' }, + { key: ['Federation', 'RegistryUrl'], text: 'Registry' }, + { + key: ['Federation', 'TopologyNamespaceUrl'], + text: 'Topology Namespace', + }, + { key: ['Federation', 'DiscoveryUrl'], text: 'Discovery' }, + { key: ['Federation', 'JwkUrl'], text: 'JWK' }, +]; diff --git a/web_ui/frontend/helpers/login.tsx b/web_ui/frontend/helpers/login.ts similarity index 100% rename from web_ui/frontend/helpers/login.tsx rename to web_ui/frontend/helpers/login.ts diff --git a/web_ui/frontend/helpers/util.tsx b/web_ui/frontend/helpers/util.ts similarity index 50% rename from web_ui/frontend/helpers/util.tsx rename to web_ui/frontend/helpers/util.ts index e1c1e6be1..a79ad2196 100644 --- a/web_ui/frontend/helpers/util.tsx +++ b/web_ui/frontend/helpers/util.ts @@ -1,4 +1,6 @@ import { ServerType } from '@/index'; +import { Dispatch } from 'react'; +import { AlertReducerAction } from '@/components/AlertProvider'; const stringToTime = (time: string) => { return new Date(Date.parse(time)).toLocaleString(); @@ -36,6 +38,11 @@ export const getOauthEnabledServers = async () => { } }; +/** + * Extract the value from a object via a list of keys + * @param obj + * @param keys + */ export function getObjectValue(obj: any, keys: string[]): T | undefined { const currentValue = obj?.[keys[0]]; if (keys.length == 1) { @@ -44,21 +51,28 @@ export function getObjectValue(obj: any, keys: string[]): T | undefined { return getObjectValue(currentValue, keys.slice(1)); } +/** + * Get the error message from a response + * @param response + */ export const getErrorMessage = async (response: Response): Promise => { - let message; try { let data = await response.json(); - message = response.status + ': ' + data['msg']; + return response.status + ': ' + data['msg']; } catch (e) { - message = response.status + ': ' + response.statusText; + return response.status + ': ' + response.statusText; } - return message; }; export type TypeFunction = (x?: F) => T; export type TypeOrTypeFunction = T | TypeFunction; +/** + * Evaluate a function or return a value + * @param o Function or value + * @param functionProps Function properties + */ export function evaluateOrReturn( o: TypeOrTypeFunction, functionProps?: F @@ -70,6 +84,57 @@ export function evaluateOrReturn( return o as T; } +/** + * Get the average of an array of numbers + * @param arr Array of numbers + */ export const average = (arr: number[]) => { return arr.reduce((a, b) => a + b, 0) / arr.length; }; + +type ErrorWithCause = Error & { cause?: Error }; + +/** + * If an error is caught from f then display the error via an alert UI + */ +export async function alertOnError( + f: () => Promise | T | undefined, + title: string = 'Error', + dispatch: Dispatch +) { + try { + return await f(); + } catch (error) { + console.error(error); + if (error instanceof Error) { + dispatch({ + type: 'openErrorAlert', + payload: { + title, + error: errorToString(error as ErrorWithCause), + onClose: () => dispatch({ type: 'closeAlert' }), + }, + }); + } + } +} + +/** + * Convert a error into a string + * @param error + */ +export const errorToString = (error: ErrorWithCause): string => { + if (error?.cause) { + // Check that error is instance of Error + if (!(error?.cause instanceof Error)) { + console.error( + 'Malformed error, cause is not an instance of Error', + error + ); + } + + return `${error.message}\n↳ ${errorToString(error.cause as ErrorWithCause)}`; + } + + return `${error.message}`; +}; diff --git a/web_ui/frontend/index.d.ts b/web_ui/frontend/index.ts similarity index 89% rename from web_ui/frontend/index.d.ts rename to web_ui/frontend/index.ts index 7be168325..dfe6b1e9c 100644 --- a/web_ui/frontend/index.d.ts +++ b/web_ui/frontend/index.ts @@ -34,9 +34,11 @@ export interface Capabilities { DirectReads: boolean; } -export type StringTree = Record; +export interface StringTree { + [key: string]: StringTree | true; +} -interface Alert { +export interface Alert { severity: 'error' | 'warning' | 'info' | 'success'; message: string; } @@ -50,7 +52,7 @@ export interface Namespace { custom_fields?: Record; } -interface Institution { +export interface Institution { id: string; name: string; }