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/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/oa4mp/resources/policies.qdl b/oa4mp/resources/policies.qdl index 5f18ef046..6757a8d1f 100644 --- a/oa4mp/resources/policies.qdl +++ b/oa4mp/resources/policies.qdl @@ -22,7 +22,7 @@ group_list. := claims.groups; remove(claims.groups); {{ if .GroupRequirements }} -if [0 == size(|^group_list. \/ { {{- range $idx, $grp := .GroupRequirements -}}{{- if eq $idx 0 -}}'{{- $grp -}}'{{else}}, '{{- $grp -}}'{{- end -}}{{- end -}} })] then +if [0 == size(|^group_list. /\ { {{- range $idx, $grp := .GroupRequirements -}}{{- if eq $idx 0 -}}'{{- $grp -}}'{{else}}, '{{- $grp -}}'{{- end -}}{{- end -}} })] then [ sys_err.ok := false; sys_err.message := 'Authenticated user is not in any of the following groups: {{ range $idx, $grp := .GroupRequirements -}}{{- if eq $idx 0 -}}"{{- $grp -}}"{{else}}, "{{- $grp -}}"{{- end -}}{{- end -}}'; 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 57329a6b5..479f2a4d1 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, { useEffect, useRef, useState } from 'react'; +import React, { useContext, useEffect, useRef, useState } from 'react'; import { Avatar, Box, @@ -21,9 +21,11 @@ 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 { ServerDetailed, ServerGeneral } from '@/types'; +import { allowServer, filterServer, getDirectorServer } from '@/helpers/api'; +import { AlertDispatchContext } from '@/components/AlertProvider'; export interface DirectorCardProps { server: ServerGeneral; @@ -31,14 +33,14 @@ 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 [detailedServer, setDetailedServer] = useState< ServerDetailed | undefined >(); + const dispatch = useContext(AlertDispatchContext); + const { mutate } = useSWR('getServers'); return ( @@ -63,7 +65,14 @@ export const DirectorCard = ({ server, authenticated }: DirectorCardProps) => { onClick={async () => { setDropdownOpen(!dropdownOpen); if (detailedServer === undefined) { - setDetailedServer(await getServer(server.name)); + alertOnError( + async () => { + const response = await getDirectorServer(server.name) + setDetailedServer(await response.json()); + }, + "Failed to fetch server details", + dispatch + ) } }} > @@ -84,38 +93,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'} /> @@ -139,82 +144,8 @@ export const DirectorCard = ({ server, authenticated }: DirectorCardProps) => { server={detailedServer || server} transition={dropdownOpen} /> - - 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'; - } -}; - -const getServer = async (name: string): Promise => { - try { - const response = await secureFetch(`/api/v1.0/director_ui/servers/${name}`); - if (response.ok) { - return await response.json(); - } else { - return undefined; - } - } catch (e) { - return undefined; - } -}; - export default DirectorCard; diff --git a/web_ui/frontend/app/director/components/DirectorDropdown.tsx b/web_ui/frontend/app/director/components/DirectorDropdown.tsx index 8a5bc0e67..ee3250c8c 100644 --- a/web_ui/frontend/app/director/components/DirectorDropdown.tsx +++ b/web_ui/frontend/app/director/components/DirectorDropdown.tsx @@ -3,7 +3,6 @@ import { Box, Grid, Typography } from '@mui/material'; import DirectoryTree from '@/components/DirectoryTree'; import React from 'react'; import { SinglePointMap } from '@/components/Map'; -import { directoryListToTree } from '@/app/director/components/index'; import { ServerCapabilitiesTable } from '@/components/ServerCapabilitiesTable'; import { Capabilities, ServerDetailed, ServerGeneral } from '@/types'; import { Capability } from '@/components/configuration'; diff --git a/web_ui/frontend/app/director/components/NamespaceCard.tsx b/web_ui/frontend/app/director/components/NamespaceCard.tsx index 7e8215f43..35ecd8e76 100644 --- a/web_ui/frontend/app/director/components/NamespaceCard.tsx +++ b/web_ui/frontend/app/director/components/NamespaceCard.tsx @@ -1,15 +1,20 @@ import { secureFetch } from '@/helpers/login'; -import React, { useState } from 'react'; +import React, { useContext, useState } from 'react'; import { Box, Paper, Typography } from '@mui/material'; import { NamespaceIcon } from '@/components/Namespace/index'; import { NamespaceDropdown } from './NamespaceDropdown'; -import { Namespace, ServerDetailed, ServerGeneral } from '@/types'; +import { DirectorNamespace, ServerDetailed, ServerGeneral } from '@/types'; +import { getDirectorServer } from '@/helpers/api'; +import { alertOnError } from '@/helpers/util'; +import { AlertDispatchContext } from '@/components/AlertProvider'; export interface NamespaceCardProps { - namespace: Namespace; + namespace: DirectorNamespace; } export const NamespaceCard = ({ namespace }: NamespaceCardProps) => { + + const dispatch = useContext(AlertDispatchContext); const [dropdownOpen, setDropdownOpen] = useState(false); const [servers, setServers] = useState( undefined @@ -32,7 +37,11 @@ export const NamespaceCard = ({ namespace }: NamespaceCardProps) => { onClick={async () => { setDropdownOpen(!dropdownOpen); if (servers === undefined) { - setServers(await getAssociatedServers(namespace)); + alertOnError( + async () => setServers(await getAssociatedServers(namespace)), + 'Failed to fetch servers', + dispatch + ); } }} > @@ -51,9 +60,9 @@ export const NamespaceCard = ({ namespace }: NamespaceCardProps) => { ); }; -const getAssociatedServers = async (namespace: Namespace) => { +const getAssociatedServers = async (namespace: DirectorNamespace) => { const servers = await Promise.all( - [...namespace.origins, ...namespace.caches].map(getServer) + [...namespace.origins, ...namespace.caches].map(async (name) => (await getDirectorServer(name)).json()) ); // Alert the console if any servers are undefined, as this is unlikely to happen naturally @@ -64,18 +73,4 @@ const getAssociatedServers = async (namespace: Namespace) => { return servers.filter((s) => s !== undefined) as ServerDetailed[]; }; -// TODO: Consolidate this when https://github.com/PelicanPlatform/pelican/pull/1687 is merged -const getServer = async (name: string): Promise => { - try { - const response = await secureFetch(`/api/v1.0/director_ui/servers/${name}`); - if (response.ok) { - return await response.json(); - } else { - return undefined; - } - } catch (e) { - return undefined; - } -}; - export default NamespaceCard; diff --git a/web_ui/frontend/app/director/components/NamespaceDropdown.tsx b/web_ui/frontend/app/director/components/NamespaceDropdown.tsx index 7ebad9872..c8e0779b0 100644 --- a/web_ui/frontend/app/director/components/NamespaceDropdown.tsx +++ b/web_ui/frontend/app/director/components/NamespaceDropdown.tsx @@ -1,11 +1,11 @@ import { Dropdown, InformationSpan, InformationSpanHeader } from '@/components'; import { Box, Grid } from '@mui/material'; import React, { Fragment } from 'react'; -import { Namespace, ServerDetailed } from '@/types'; +import { DirectorNamespace, ServerDetailed } from '@/types'; import { NamespaceCapabilitiesTable } from '@/components/NamespaceCapabilitiesTable'; interface NamespaceDropdownProps { - namespace: Namespace; + namespace: DirectorNamespace; servers?: ServerDetailed[]; transition: boolean; } diff --git a/web_ui/frontend/app/director/components/index.tsx b/web_ui/frontend/app/director/components/index.tsx index 8c4152f3f..034cde56c 100644 --- a/web_ui/frontend/app/director/components/index.tsx +++ b/web_ui/frontend/app/director/components/index.tsx @@ -1,35 +1,3 @@ -import { StringTree } from '@/index'; - export * from './DirectorCard'; export * from './DirectorCardList'; export * from './NamespaceCard'; - -export const directoryListToTree = (directoryList: string[]): StringTree => { - let tree = {}; - directoryList.forEach((directory) => { - const path = directory - .split('/') - .filter((x) => x != '') - .map((x) => '/' + x); - tree = directoryListToTreeHelper(path, tree); - }); - - return tree; -}; - -export const directoryListToTreeHelper = ( - path: string[], - tree: StringTree -): true | StringTree => { - if (path.length == 0) { - return true; - } - - if (!tree[path[0]] || tree[path[0]] === true) { - tree[path[0]] = {}; - } - - tree[path[0]] = directoryListToTreeHelper(path.slice(1), tree[path[0]]); - - return tree; -}; diff --git a/web_ui/frontend/app/director/page.tsx b/web_ui/frontend/app/director/page.tsx index 7d88242b4..60124fc64 100644 --- a/web_ui/frontend/app/director/page.tsx +++ b/web_ui/frontend/app/director/page.tsx @@ -19,24 +19,36 @@ 'use client'; import { Box, Grid, Skeleton, Typography } from '@mui/material'; -import { useMemo } from 'react'; +import { useContext, useMemo } from 'react'; import useSWR from 'swr'; 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 { Namespace, ServerGeneral } from '@/types'; +import { DirectorNamespace, ServerGeneral } from '@/types'; import { NamespaceCardList } from './components/NamespaceCardList'; +import { getDirectorServers, getDirectorNamespaces } from '@/helpers/get'; +import { alertOnError } from '@/helpers/util'; +import { AlertDispatchContext } from '@/components/AlertProvider'; export default function Page() { - const { data } = useSWR('getServers', getServers); - const { data: namespaces } = useSWR( - 'getNamespaces', - getNamespaces + const dispatch = useContext(AlertDispatchContext); + + const { data } = useSWR( + 'getDirectorServers', + async () => + await alertOnError(getDirectorServers, 'Failed to fetch servers', dispatch) + ); + + const { data: namespaces } = useSWR( + 'getDirectorNamespaces', + async () => await alertOnError(getDirectorNamespaces, "Faild to fetch Namespaces", dispatch) ); - const { data: user, error } = useSWR('getUser', getUser); + const { data: user, error } = useSWR('getUser', () => + alertOnError(getUser, 'Failed to fetch user', dispatch) + ); const cacheData = useMemo(() => { return data?.filter((server) => server.type === 'Cache'); @@ -112,32 +124,3 @@ 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); - if (response.ok) { - const responseData: ServerGeneral[] = await response.json(); - responseData.sort((a, b) => a.name.localeCompare(b.name)); - return responseData; - } - - throw new Error('Failed to fetch servers'); -}; - -const getNamespaces = async () => { - const url = new URL( - '/api/v1.0/director_ui/namespaces', - window.location.origin - ); - - let response = await fetch(url); - if (response.ok) { - const responseData: Namespace[] = await response.json(); - responseData.sort((a, b) => a.path.localeCompare(b.path)); - return responseData; - } - - throw new Error('Failed to fetch servers'); -}; 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..9a071e5a4 100644 --- a/web_ui/frontend/app/registry/components/Form.tsx +++ b/web_ui/frontend/app/registry/components/Form.tsx @@ -1,8 +1,14 @@ 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'; +import { RegistryNamespace } from '@/index'; import CustomRegistrationField from '@/app/registry/components/CustomRegistrationField/index'; import { calculateKeys, @@ -11,31 +17,20 @@ 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; + namespace?: RegistryNamespace; + 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, - setData: Dispatch>> + setData: Dispatch>> ) => { setData((prevData) => { // If the value is undefined delete this key from the data dictionary @@ -53,13 +48,26 @@ const onChange = ( }; const Form = ({ namespace, onSubmit }: FormProps) => { - const [data, setData] = useState | undefined>( + 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 b5e1ebce1..ca7cd07f8 100644 --- a/web_ui/frontend/app/registry/components/PutPage.tsx +++ b/web_ui/frontend/app/registry/components/PutPage.tsx @@ -29,33 +29,44 @@ import { import React, { ReactNode, Suspense, + useContext, useEffect, useMemo, useState, } from 'react'; import AuthenticatedContent from '@/components/layout/AuthenticatedContent'; -import { Namespace, Alert as AlertType } from '@/index'; +import { RegistryNamespace, 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 [namespace, setNamespace] = useState(undefined); + + const dispatch = useContext(AlertDispatchContext); useEffect(() => { const urlParams = new URLSearchParams(window.location.search); const id = urlParams.get('id'); const fromUrl = urlParams.get('fromUrl'); + 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; } @@ -65,43 +76,66 @@ 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' }), + }, + }); } - }, []); - useEffect(() => { (async () => { if (id !== undefined) { - try { - setNamespace(await getNamespace(id)); - } catch (e) { - setAlert({ severity: 'error', message: e as string }); + const response = await alertOnError( + async () => await getNamespace(id, accessToken || undefined), + "Couldn't get namespace", + dispatch + ); + if (response) { + setNamespace(await response.json()); } } })(); - }, [id]); + }, []); return ( - - - {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..c33ba9860 --- /dev/null +++ b/web_ui/frontend/app/registry/components/index.ts @@ -0,0 +1,5 @@ +import { Alert as AlertType, RegistryNamespace } 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 0eb3398ae..473d912b3 100644 --- a/web_ui/frontend/app/registry/components/util.tsx +++ b/web_ui/frontend/app/registry/components/util.tsx @@ -1,6 +1,6 @@ import React from 'react'; import { secureFetch } from '@/helpers/login'; -import { Alert, Namespace } from '@/index'; +import { Alert, RegistryNamespace } from '@/index'; import { getErrorMessage } from '@/helpers/util'; export const populateKey = (o: any, key: string[], value: any) => { @@ -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,31 +57,7 @@ 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) => { +export const namespaceToCache = (data: RegistryNamespace) => { // Build the cache prefix if (data.prefix.startsWith('/caches/')) { return data; @@ -83,7 +67,7 @@ export const namespaceToCache = (data: Namespace) => { return data; }; -export const namespaceToOrigin = (data: Namespace) => { +export const namespaceToOrigin = (data: RegistryNamespace) => { // Build the cache prefix if (data.prefix.startsWith('/origins/')) { return data; @@ -93,61 +77,15 @@ export const namespaceToOrigin = (data: Namespace) => { return data; }; -export const getNamespace = async ( - id: string | number -): Promise => { - const url = new URL( - `/api/v1.0/registry_ui/namespaces/${id}`, - window.location.origin - ); - 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 => { - return await handleRequestAlert( - `/api/v1.0/registry_ui/namespaces/${data.id}`, - { - body: JSON.stringify(data), - method: 'PUT', - headers: { - 'Content-Type': 'application/json', - }, - credentials: 'include', - } - ); -}; - export const submitNamespaceForm = async ( - data: Partial, + 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..d509751d7 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, @@ -38,25 +38,38 @@ import { NamespaceCardList, } from '@/components'; import Link from 'next/link'; -import { Namespace, Alert as AlertType } from '@/index'; +import { RegistryNamespace, Alert as AlertType } from '@/index'; import { getUser } from '@/helpers/login'; 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: RegistryNamespace }[] | 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..f6fa4c377 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 { Alert, Alert as AlertType, RegistryNamespace } from '@/index'; +import React, { useContext, useRef, useState } from 'react'; import { Avatar, Box, @@ -14,20 +14,22 @@ 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; + namespace: RegistryNamespace; onUpdate?: () => void; authenticated?: User; } 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..689055b87 100644 --- a/web_ui/frontend/components/Namespace/DeniedCard.tsx +++ b/web_ui/frontend/components/Namespace/DeniedCard.tsx @@ -1,76 +1,30 @@ -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, RegistryNamespace } 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; + namespace: RegistryNamespace; onUpdate: () => void; onAlert: (alert: Alert) => void; 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..1b55f43d9 100644 --- a/web_ui/frontend/components/Namespace/PendingCard.tsx +++ b/web_ui/frontend/components/Namespace/PendingCard.tsx @@ -1,16 +1,19 @@ -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'; import Link from 'next/link'; -import { Alert, Namespace } from '@/index'; +import { Alert, RegistryNamespace } 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; + namespace: RegistryNamespace; onUpdate: () => void; onAlert: (alert: Alert) => void; authenticated?: User; @@ -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..4edb5c8d7 100644 --- a/web_ui/frontend/components/Namespace/index.tsx +++ b/web_ui/frontend/components/Namespace/index.tsx @@ -1,4 +1,4 @@ -import { Namespace } from '@/index'; +import { RegistryNamespace } from '@/index'; import Card from './Card'; import CreateNamespaceCard from './CreateNamespaceCard'; import CardSkeleton from './CardSkeleton'; @@ -15,7 +15,29 @@ export { NamespaceIcon, }; -export const getServerType = (namespace: Namespace) => { +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: RegistryNamespace) => { // If the namespace is empty the value is undefined if (namespace?.prefix == null || namespace.prefix == '') { return ''; diff --git a/web_ui/frontend/components/NamespaceCapabilitiesTable.tsx b/web_ui/frontend/components/NamespaceCapabilitiesTable.tsx index 66b1dc43a..0ba7ae659 100644 --- a/web_ui/frontend/components/NamespaceCapabilitiesTable.tsx +++ b/web_ui/frontend/components/NamespaceCapabilitiesTable.tsx @@ -6,14 +6,14 @@ * Table to display the server capabilities with its namespaces */ -import { Namespace, ServerDetailed, ServerGeneral } from '@/types'; +import { DirectorNamespace, ServerDetailed, ServerGeneral } from '@/types'; import { Box, Grid, Typography, useTheme } from '@mui/material'; import { CapabilitiesRow } from '@/app/director/components/DirectorDropdown'; import { grey } from '@mui/material/colors'; import { NamespaceIcon } from '@/components/Namespace'; interface NamespaceCapabilitiesTableProps { - namespace: Namespace; + namespace: DirectorNamespace; servers?: ServerDetailed[]; } 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 5eef49bcc..f1382b828 100644 --- a/web_ui/frontend/components/layout/AuthenticatedContent.tsx +++ b/web_ui/frontend/components/layout/AuthenticatedContent.tsx @@ -69,7 +69,7 @@ const AuthenticatedContent = ({ if (data && checkAuthentication) { return checkAuthentication(data); } else { - return data?.authenticated !== undefined; + return !!data?.authenticated; } }, [data, checkAuthentication]); @@ -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..f61e90f9a --- /dev/null +++ b/web_ui/frontend/helpers/api.ts @@ -0,0 +1,294 @@ +/** + * 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 { RegistryNamespace } from '@/index'; +import { ServerGeneral } from '@/types'; + +/** + * 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 director servers + * + */ +export const getDirectorServers = async () => { + const url = new URL('/api/v1.0/director_ui/servers', window.location.origin); + + return await fetchApi(async () => await fetch(url)); +}; + +/** + * Get a director server by name + * @param name Server name + */ +export const getDirectorServer = async (name: string): Promise => { + const url = new URL( + `/api/v1.0/director_ui/servers/${name}`, + window.location.origin + ); + + return await fetchApi(async () => await fetch(url)); +} + +/** + * Get namespaces from director + */ +export const getDirectorNamespaces = async () => { + const url = new URL( + '/api/v1.0/director_ui/namespaces', + window.location.origin + ); + + return await fetchApi(async () => await fetch(url)); +}; + + +/** + * 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: RegistryNamespace +): 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: RegistryNamespace +): 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..1e2f95687 100644 --- a/web_ui/frontend/helpers/get.ts +++ b/web_ui/frontend/helpers/get.ts @@ -1,9 +1,114 @@ +/** + * API wrappers for manipulating fetched data + * + * @module helpers/get + */ + import { Config, ParameterValueRecord } from '@/components/configuration'; +import { + getDirectorNamespaces as getDirectorNamespacesResponse, + getDirectorServers as getDirectorServersResponse, + getConfig as getConfigResponse, + getNamespaces +} from '@/helpers/api'; import { flattenObject } from '@/app/config/util'; +import { DirectorNamespace } from '@/types'; +import { RegistryNamespace } from '@/index'; +import { getObjectValue } from '@/helpers/util'; +import { ServerGeneral } from '@/types'; + +/** + * Director Getters + */ + +/** + * Get and sort director servers + */ +export const getDirectorServers = async () => { + const response = await getDirectorServersResponse() + const responseData: ServerGeneral[] = await response.json(); + responseData.sort((a, b) => a.name.localeCompare(b.name)); + return responseData; +} + +/** + * Get and sort director namespaces + */ +export const getDirectorNamespaces = async () => { + const response = await getDirectorNamespacesResponse() + const responseData: DirectorNamespace[] = await response.json(); + responseData.sort((a, b) => a.path.localeCompare(b.path)); + return responseData; +} + 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: RegistryNamespace }[] +> => { + const response = await getNamespaces(); + const data: RegistryNamespace[] = 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 85% rename from web_ui/frontend/index.d.ts rename to web_ui/frontend/index.ts index 5ddf21d9a..ab47845da 100644 --- a/web_ui/frontend/index.d.ts +++ b/web_ui/frontend/index.ts @@ -27,14 +27,15 @@ export interface Server { namespacePrefixes: string[]; } -export type StringTree = Record; -interface Alert { +export type StringTree = { [key: string]: StringTree | true }; + +export interface Alert { severity: 'error' | 'warning' | 'info' | 'success'; message: string; } -export interface Namespace { +export interface RegistryNamespace { id: number; prefix: string; pubkey: string; @@ -43,7 +44,7 @@ export interface Namespace { custom_fields?: Record; } -interface Institution { +export interface Institution { id: string; name: string; } diff --git a/web_ui/frontend/types.ts b/web_ui/frontend/types.ts index f1b461b26..dd64aa694 100644 --- a/web_ui/frontend/types.ts +++ b/web_ui/frontend/types.ts @@ -19,7 +19,7 @@ export interface TokenIssuer { issuer: string; } -export interface Namespace { +export interface DirectorNamespace { path: string; capabilities: Capabilities; tokenGeneration: TokenGeneration[] | null; @@ -49,7 +49,7 @@ interface ServerBase { } export interface ServerDetailed extends ServerBase { - namespaces: Namespace[]; + namespaces: DirectorNamespace[]; } export interface ServerGeneral extends ServerBase {