Vast difference in performance between on prem user macros and Forge + REST

We’ve recently migrated our Confluence to the cloud and are rewriting our trusty old Velocity user macros into Forge. I’ll admit that I’m a React novice but I’m dismayed to see how slow the Forge apps are vs. the old templates. Pages are painful to load, sometimes time out, and the PDF generation (which we must do often because these are official policy docs) doesn’t render the macros at all.

Am I missing something obvious?

Old macro that was very quick

## @param ExcludeLabels:title=Exclude pages with these labels|type=string|desc=Pages with these labels will be excluded from the table.  Separate multiple labels with comma
## @param FeaturedLabels:title=Labels matching this regex will be displayed in the table column|type=string|desc=Enter labels to be displayed in the column.|default=.*
#set ( $excludedLabelsString = $paramExcludeLabels + "," )
#set ( $excludedLabelsString = $excludedLabelsString.replace(" ","") )
##set ( $descendantPages = $pageManager.getDescendents($content) )
#set ( $descendantPages = $pageManager.getPage($content.id).getChildren() )
#set ( $compareDate = $action.dateFormatter.getCalendar() )
#set ( $spaceKey = $space.getKey() )

$compareDate.setTime($content.getCurrentDate())
$compareDate.add(6, -365)
<table>
  <tr>
    <th>Page</th>
    <th>Author</th>
    <th>Last Modifier</th>
    <th>Modified Date</th>
    <th>Modified < 365d?</th>
    <th>Labels</th>
  </tr>
  #foreach ( $page in $descendantPages )
    #set ( $notContainsExcludedLabel = true )
    #if ( $excludedLabelsString != ",")
      #foreach ( $labelling in $page.getLabellings() )
        #set ( $label = $labelling.getLabel() + "," )
        #if ( $excludedLabelsString.contains($label) )
          #set ( $notContainsExcludedLabel = false )
        #end
      #end
    #end
    #if ( $notContainsExcludedLabel )
      <tr>
        <td><a href="${req.contextPath}$page.urlPath"> $page.title </a></td>
        <td> $page.Creator.fullName </td>
        <td> $page.lastModifier.fullName </td>
        <td> 
            <div style="text-align: center;">
            $action.dateFormatter.format($page.lastModificationDate)
            </div>
        </td>
        <td>
        <div style="text-align: center;">
           #set ($pageDate = $action.dateFormatter.getCalendar())
           $pageDate.setTime($page.getLastModificationDate())
          #if ( $pageDate.before($compareDate) )
            <span class="aui-lozenge aui-lozenge-error">NO</span>
          #else
            <span class="aui-lozenge aui-lozenge-success">YES</span>
          #end
        </div>
        </td>
        <td>
            #foreach ( $labelling in $page.getLabellings()  )
                #set( $label = $labelling.getLabel().getName())
				#if( $label.matches($paramFeaturedLabels) )
					<a href="/label/$spaceKey/$label">$label</a>
				#end
            #end
        </td>
      </tr>
    #end
  #end
</table>

Forge

import ForgeUI, { render, Macro, MacroConfig, TextField, Fragment, Text, DateLozenge, Badge, User, Table, Head, Row, Cell, Link, Strong, Heading, useProductContext, useAction, useConfig } from "@forge/ui";
import api, { route } from "@forge/api"

const getPageChildren = async (contentId) =>{
  const response = await api.asUser().requestConfluence(route`/wiki/api/v2/pages/${contentId}/children?limit=100`)
  if(!response.ok){
    const err = `Error while /wiki/api/v2/pages/${contentId}/children: ${response.status} ${response.statusText}`;
    console.error(err);
    throw new Error(err);
  }

  const result = await response.json();
  return result;
};

const getPageLabels = async (contentId) =>{
  const response = await api.asUser().requestConfluence(route`/wiki/api/v2/pages/${contentId}/labels`)
  if(!response.ok){
    const err = `Error while /wiki/api/v2/pages/${contentId}/labels: ${response.status} ${response.statusText}`;
    console.error(err);
    throw new Error(err);
  }

  const result = await response.json();
  return result;
};

const getPageDetails = async (contentId) =>{
  const response = await api.asUser().requestConfluence(route`/wiki/api/v2/pages/${contentId}`)
  if(!response.ok){
    const err = `Error while /wiki/api/v2/pages/${contentId}: ${response.status} ${response.statusText}`;
    console.error(err);
    throw new Error(err);
  }

  const result = await response.json();
  return result;
};

const defaultConfig = {
    pageListRegex: ".+"  ,
    labelLinksRegex: ".+"  ,
    maxDaysSinceEdit: "365"
};

const App = () => {
  const localConfig = useConfig() || defaultConfig;
  const pageContext= useProductContext();

  let pageListFilter = null;
  if(localConfig.pageListRegex.length > 0) {
    pageListFilter = new RegExp(localConfig.pageListRegex, "i");
  } 
  const labelLinksFilter = new RegExp(localConfig.labelLinksRegex, "i");
  const maxDaysPageAge = parseInt(localConfig.maxDaysSinceEdit);
  let minPageEditDate = new Date()
  minPageEditDate.setDate(minPageEditDate.getDate() - maxDaysPageAge)
  
  const [children]= useAction(
    () => null,
    getPageChildren(pageContext.contentId)
  );
  const {results} = children;
  
  // Build a map of child page details and labels that can be accessed synchronously during renderTableRows
  let pageMap = new Map();
  for (let i = 0; i < results.length; i++) {
    const [labels] = useAction(
      () => null,
      getPageLabels(results[i].id)
    );

    let labelNames = [];
    let includePage = false;
    if(pageListFilter == null) {
      includePage = true;
    }
    // Process labels
    for(let j=0; j < labels.results.length; j++){
      let labelName = labels.results[j].name
      // Test the label to determine whether we should include this page in the list
      if(pageListFilter && pageListFilter.test(labelName))  {
          includePage = true;
      }

      // Test the label to determine whether we should display the label in the table column
      if(labelLinksFilter.test(labelName)) {
        labelNames.push(labelName)
      }
    }

    if(includePage) {
      const details = useAction(
        () => null,
        getPageDetails(results[i].id)
      );

      let pageInfo = {labels: labelNames, details: details[0], lastUpdated: (new Date(details[0].version.createdAt))}
      pageMap.set(results[i].id, pageInfo);      
    }
  }

  // generate the table rows listing the child pages
  const renderTableRows = (results) => {
    return(
      results.map((entry) => (
        <Row>
        <Cell>
          <Text><Link appearance="link" href={`/wiki${entry.details._links.webui}`}>{entry.details.title}</Link></Text>
        </Cell>
        <Cell>
          <Text><User accountId={`${entry.details.authorId}`}/></Text>
        </Cell>
        <Cell>
          <Text><User accountId={`${entry.details.version.authorId}`}/></Text>
        </Cell>
        <Cell>
          <Text>
          {entry.lastUpdated.toISOString().substring(0, 10)} 
          </Text>
        </Cell>
        <Cell>
          <Text>
            {entry.lastUpdated >= minPageEditDate && <Badge appearance="added" text="Y" />  }
            {entry.lastUpdated < minPageEditDate && <Badge appearance="primary" text="N" />  }
          </Text>
        </Cell>      
        <Cell>
          <Text>
            {entry.labels.map((labelName) => (
              <Link appearance="link" href={`/wiki/label/${pageContext.spaceKey}/${labelName}`}>{labelName}{" "}</Link>
              ))
            }
          </Text>
        </Cell>
        </Row>
      ))
    )
  };

  return (
    <Fragment>
      <Table>
        <Head>
          <Cell><Text>Page</Text></Cell>
          <Cell><Text>Author</Text></Cell>
          <Cell><Text>Last Updated By</Text></Cell>
          <Cell><Text>Last Updated</Text></Cell>
          <Cell><Text>Updated &lt; {maxDaysPageAge}d</Text></Cell>
          <Cell><Text>Labels</Text></Cell>
        </Head>
        {renderTableRows(Array.from(pageMap.values()))}
      </Table> 
    </Fragment>
  );

};

export const run = render(
  <Macro
    app={<App />}
  />
);

const Config = () => {
  return (
    <MacroConfig>
      <TextField name="pageListRegex" label="Regex of page labels to match for inclusion in a table row.  Leave blank for all pages, even those without labels." defaultValue={defaultConfig.pageListRegex} />
      <TextField name="labelLinksRegex" label="Regex of page labels to show in-row as hyperlinks" defaultValue={defaultConfig.labelLinksRegex} />
      <TextField name="maxDaysSinceEdit" label="Max days since edit (to be considered current) " defaultValue={defaultConfig.maxDaysSinceEdit} />
    </MacroConfig>
  );
};

export const config = render(<Config />);

2 Likes

Hi @HughKelley

Building a Forge app is definitely a more complex undertaking than writing a user macro in velocity! There’s a lot of things you can do with a Forge app that are just impossible with a user macro, but you do have to say goodbye to some of the nice things about user macros.

The biggest difference in performance you are seeing is most likely that user macros run within Confluence while Forge apps run externally to it - when you call $pageManager from within your user macro, you are calling the Confluence database directly. When you call requestConfluence from your Forge app, you are making a HTTP call that will add to your execution time.

In particular, because you macro is doing an O(n) operation where you retrieve a list of pages and then request data for each page, this will quickly balloon the time it takes because the app has to make a REST API Call for every page.

An alternative approach would be to see if you can get all the data you need in a single API call. For example, the Search API might let you search using CQL for all pages with a particular parent, and then expand the responses to include the labels.

Regarding PDF export, there was an issue last year where Forge macros did not get exported but it should now be fixed - https://jira.atlassian.com/browse/CONFCLOUD-74768 - If this is still broken for you please let me know as we could consider this a regression that needs to be fixed.

2 Likes

Thanks for the suggestion about the CQL alternative. I will try that.

Regarding the PDF export, I opened ECOHELP-27638 Forge macro export failing

Yes, this single REST search is much faster than all of the individual fetches.

 const response = await api.asUser().requestConfluence(route`/wiki/rest/api/content/search?cql=parent=${contentId}&expand=metadata.labels,history.lastUpdated`)

My only issue now is that the order of the page results doesn’t match the order in the tree view. Is there a sort hint I can use with that API?

1 Like

Likely you’ll have to do 2 calls.

  1. /wiki/api/v2/pages/${contentId}/children?... to get the order of the pages
  2. /wiki/rest/api/content/search?cql=parent=${contentId}&expand=metadata.labels,history.lastUpdated to get the details of the children

Then do a filter on the 2nd result set based on the order in the 1st result set.

Yeah, hacky.

3 Likes

Fortunately that’s still only two API calls (instead of the n I had before) so the PDF engine renders it correctly. Thanks.