Getting Started with Custom Reports

The Morpheus plugin architecture is a library which allows users to extend functionality in several categories, including new Cloud providers, Task types, UI views, custom reports, and more. In this guide, we will take a look at developing a custom report and adding it to Morpheus UI as a report selection for users to consume. Complete developer documentation including the full API documentation and links to Github repositories containing complete code examples are available in the Developer Portal.

Custom plugin development requires programming experience but this guide is designed to break down the required steps into a digestible process that users can quickly run with. Morpheus plugins are written in Java or Groovy, our example here will be written in Groovy. Support for additional languages is planned but not yet available at the time of this writing. If you’re not an experienced Java or Groovy developer, it may help to clone an example code repository which we link to in our developer portal. An additional example, which this guide is based on, is here. You can read the example code and tweak it to suit your needs using the guidance in this document.

Before you begin, ensure you have the following installed in your development environment:

  • Gradle 6.5 or later

  • Java 8 or 11

In this example, I’ll create a custom report that shows the total number of Cypher items in Morpheus with a breakdown of the total number by type. Below that will be a table which shows each Cypher item individually and sorted alphabetically along with their create date and the date they were last accessed.

Identify Target Data

To gather data for a custom report, you’ll directly query the Morpheus database. To identify the relevant tables and columns, you can connect to the appliance node and use the MySQL client or remotely use a GUI tool (such as MySQL Workbench) to browse for the data you need. Using a GUI tool can be helpful if you aren’t familiar with the Morpheus database layout since we can quickly view any table and toggle sorting to identify the targeted columns.

But first we’ll need access credentials for the Morpheus database. We can get these from a secrets file which is created during Morpheus installation. Start an SSH session with your appliance node and cat the morpheus-secrets.json file:

cat /etc/morpheus/morpheus-secrets.json

You should receive an output like the one shown below. In the place of the redacted values, you will see the actual secret values.

{
  "mysql": {
    "root_password": "<REDACTED>",
    "morpheus_password": "<REDACTED>",
    "ops_password": "<REDACTED>"
  },
  "rabbitmq": {
    "morpheus_password": "<REDACTED>",
    "queue_user_password": "<REDACTED>",
    "cookie": "<REDACTED>"
  },
  "ui": {
    "ajp_secret": "<REDACTED>"
  }
}

Copy the morpheus-password value as you’ll need it to create a new connection to the Morpheus database in MySQL Workbench. Open MySQL Workbench and configure a new connection. Once the connection is created, open the list of all database tables in the left-hand column of the application. In this case, I’m interested in the cypher_item table. I’m targeting the Cypher Item objects and planning to surface the item_key, last_updated, lease_timeout, and last_accessed values.

../../_images/database.png

We’ll structure our SQL query to target these selected columns and sort the table objects in the way we wish to display them in our report. Later on, this will become part of the Groovy code that makes up the custom report plugin.

SELECT item_key,last_updated,last_accessed,lease_timeout from cypher_item order by item_key asc;

Developing the Plugin

To begin, create a new directory to house the project. You’ll ultimately end up with a file structure typical of Java or Groovy projects, looking something like this:

./
.gitignore
build.gradle
src/main/groovy/
src/main/resources/renderer/hbs/
src/test/groovy/
src/assets/images/
src/assets/javascript/
src/assets/stylesheets/

Configure the .gitignore file to ignore the build/ directory which will appear after performing the first build. Project packages live within src/main/groovy and contain source files ending in .groovy. View resources are stored in the src/main/resources subfolder and vary depending on the view renderer of choice. Static assets, like icons or custom javascript, live within the src/assets folder. Consult the table below for key files, their purpose, and their locations. Example code and further discussion of relevant files is included in the following sections.

File Structure

File Name

Description

File Path

build.gradle

The Gradle build file

build.gradle

gradle.properties

The properties file for the Gradle build tool

gradle.properties

CustomReportProvider.groovy

Most of the custom code to fetch report data, determine how the data is aggregated, and categorize the report is written here

src/main/groovy/com/morpheusdata/reports/CustomReportProvider.groovy

ReportsPlugin.groovy

Create a new report plugin class which extends the plugin class here to register, name and describe the new plugin

src/main/groovy/com/morpheusdata/reports/ReportsPlugin.groovy

cypherReport.hbs

Handles creation of the new UI view which is displayed when the report is viewed

src/mainresources/renderer/hbs/cypherReport.hbs

Creating the build.gradle File

Gradle is the build tool used to compile Morpheus plugins so build.gradle is required. An example build file is given below but some useful values to call out are as follows:

  • Group: The package group in Java, typically your reverse DNS name

  • Version: The version number for your plugin. This will be displayed in the Plugins section of Morpheus UI for reference when later versions of your plugin are developed

  • Plugin-class: This will vary based on the plugin type being developed but for a custom report, use com.morpheusdata.reports.ReportsPlugin

plugins {
    id "com.bertramlabs.asset-pipeline" version "3.3.2"
    id "com.github.johnrengelman.plugin-shadow" version "2.0.3"
}

apply plugin: 'java'
apply plugin: 'groovy'
apply plugin: 'maven-publish'

group = ${'com.example'}
version = ${'1.2.2'}

sourceCompatibility = '1.8'
targetCompatibility = '1.8'

ext.isReleaseVersion = !version.endsWith("SNAPSHOT")

repositories {
    mavenCentral()
}

dependencies {
    compileOnly 'com.morpheusdata:morpheus-plugin-api:0.8.0'
    compileOnly 'org.codehaus.groovy:groovy-all:2.5.6'
    compileOnly 'io.reactivex.rxjava2:rxjava:2.2.0'
    compileOnly "org.slf4j:slf4j-api:1.7.26"
    compileOnly "org.slf4j:slf4j-parent:1.7.26"
}

jar {
    manifest {
        attributes(
            'Plugin-Class': 'com.morpheusdata.reports.ReportsPlugin', //Reference to Plugin class
            'Plugin-Version': archiveVersion.get() // Get version defined in gradle
        )
    }
}

tasks.assemble.dependsOn tasks.shadowJar

Creating the Plugin Class

Next, create a plugin class which handles registration of the new report, sets a name and description, and targets the appropriate report provider class which we’ll go over in the next section.

package com.morpheusdata.reports

import com.morpheusdata.core.Plugin

class ReportsPlugin extends Plugin {

  @Override
  void initialize() {
    CustomReportProvider customReportProvider = new CustomReportProvider(this, morpheus)
    this.pluginProviders.put(customReportProvider.code, customReportProvider)
    this.setName("Custom Cypher Report")
    this.setDescription("A custom report plugin for cypher items")
  }

  @Override
  void onDestroy() {
  }
}

Creating the Report Provider Class

The report provider class contains the code which will fetch and compile the targeted data so it can be rendered in the report view. An example report provider is reproduced below with comments to increase readability of the code.

package com.morpheusdata.reports

import com.morpheusdata.core.AbstractReportProvider
import com.morpheusdata.core.MorpheusContext
import com.morpheusdata.core.Plugin
import com.morpheusdata.model.OptionType
import com.morpheusdata.model.ReportResult
import com.morpheusdata.model.ReportType
import com.morpheusdata.model.ReportResultRow
import com.morpheusdata.model.ContentSecurityPolicy
import com.morpheusdata.views.HTMLResponse
import com.morpheusdata.views.ViewModel
import com.morpheusdata.response.ServiceResponse
import groovy.sql.GroovyRowResult
import groovy.sql.Sql
import groovy.util.logging.Slf4j
import io.reactivex.Observable;
import java.util.Date

import java.sql.Connection

@Slf4j
class CustomReportProvider extends AbstractReportProvider {
  Plugin plugin
  MorpheusContext morpheusContext

  CustomReportProvider(Plugin plugin, MorpheusContext context) {
    this.plugin = plugin
    this.morpheusContext = context
  }

  @Override
  MorpheusContext getMorpheus() {
    morpheusContext
  }

  @Override
  Plugin getPlugin() {
    plugin
  }

  // Define the Morpheus code associated with the plugin
  @Override
  String getCode() {
    'custom-report-cypher'
  }

  // Define the name of the report displayed on the reports page
  @Override
  String getName() {
    'Cypher Summary'
  }

   ServiceResponse validateOptions(Map opts) {
     return ServiceResponse.success()
   }

  @Override
  HTMLResponse renderTemplate(ReportResult reportResult, Map<String, List<ReportResultRow>> reportRowsBySection) {
    ViewModel<String> model = new ViewModel<String>()
    model.object = reportRowsBySection
    getRenderer().renderTemplate("hbs/instanceReport", model)
  }

  void process(ReportResult reportResult) {
    // Update the status of the report (generating) - https://developer.morpheusdata.com/api/com/morpheusdata/model/ReportResult.Status.html
    morpheus.report.updateReportResultStatus(reportResult,ReportResult.Status.generating).blockingGet();
    Long displayOrder = 0
    List<GroovyRowResult> results = []
    Connection dbConnection
    Long passwordResults = 0
    Long tfvarsResults = 0
    Long secretResults = 0
    Long uuidResults = 0
    Long keyResults = 0
    Long randomResults = 0
    Long totalItems = 0

    try {
      // Create a read-only database connection
      dbConnection = morpheus.report.getReadOnlyDatabaseConnection().blockingGet()
      // Evaluate if a search filter or phrase has been defined
        results = new Sql(dbConnection).rows("SELECT item_key,last_updated,last_accessed,lease_timeout from cypher_item order by item_key asc;")
      // Close the database connection
    } finally {
      morpheus.report.releaseDatabaseConnection(dbConnection)
    }
    log.info("Results: ${results}")
    Observable<GroovyRowResult> observable = Observable.fromIterable(results) as Observable<GroovyRowResult>
    observable.map{ resultRow ->
      log.info("Mapping resultRow ${resultRow}")
      Map<String,Object> data = [key: resultRow.item_key, last_updated: resultRow.last_updated.toString(), last_accessed: resultRow.last_accessed.toString(), lease_timeout: resultRow.lease_timeout ]
      ReportResultRow resultRowRecord = new ReportResultRow(section: ReportResultRow.SECTION_MAIN, displayOrder: displayOrder++, dataMap: data)
      log.info("resultRowRecord: ${resultRowRecord.dump()}")
      totalItems++
      // Evaluate if the cypher item starts with password
      if (resultRow.item_key.startsWith('password')) {
        passwordResults++
      }
      // Evaluate if the cypher item starts with tfvars
      if (resultRow.item_key.startsWith('tfvars')) {
        tfvarsResults++
      }
      // Evaluate if the cypher item starts with secret
      if (resultRow.item_key.startsWith('secret')) {
        secretResults++
      }
      // Evaluate if the cypher item starts with uuid
      if (resultRow.item_key.startsWith('uuid')) {
        uuidResults++
      }
      // Evaluate if the cypher item starts with key
      if (resultRow.item_key.startsWith('key')) {
        keyResults++
      }
      // Evaluate if the cypher item starts with random
      if (resultRow.item_key.startsWith('random')) {
        randomResults++
      }
      return resultRowRecord
    }.buffer(50).doOnComplete {
      morpheus.report.updateReportResultStatus(reportResult,ReportResult.Status.ready).blockingGet();
    }.doOnError { Throwable t ->
      morpheus.report.updateReportResultStatus(reportResult,ReportResult.Status.failed).blockingGet();
    }.subscribe {resultRows ->
      morpheus.report.appendResultRows(reportResult,resultRows).blockingGet()
    }
    Map<String,Object> data = [total_items: totalItems, password_items: passwordResults, tfvars_items: tfvarsResults, secret_items: secretResults, uuid_items: uuidResults, key_items: keyResults, random_items: randomResults]
    ReportResultRow resultRowRecord = new ReportResultRow(section: ReportResultRow.SECTION_HEADER, displayOrder: displayOrder++, dataMap: data)
        morpheus.report.appendResultRows(reportResult,[resultRowRecord]).blockingGet()
  }

  // https://developer.morpheusdata.com/api/com/morpheusdata/core/ReportProvider.html#method.summary
  // The description associated with the custom report
   @Override
   String getDescription() {
     return "View an inventory of Cypher items"
   }

   // The category of the custom report
   @Override
   String getCategory() {
     return 'inventory'
   }

   @Override
   Boolean getOwnerOnly() {
     return false
   }

   @Override
   Boolean getMasterOnly() {
     return true
   }

   @Override
   Boolean getSupportsAllZoneTypes() {
     return true
   }
  }

Create the Custom Report View

By default, custom plugin views are handled by a Handlebars template provider to populate HTML sections with your own content. Though it can be overridden, we’ll use the default template provider for this example. There is more information on view rendering in the Morpheus Developer Portal.

<div id="hypervisor-inventory-report">
   <div class="intro-stats">
      <h2>Overview</h2>
      <div class="count-stats">
         <div class="stats-container">
            <span class="big-stat">{{ header.0.dataMap.total_items }}</span>
            <span class="stat-label">Items</span>
         </div>
         <div class="stats-container">
            <span class="big-stat">{{ header.0.dataMap.password_items }}</span>
            <div class="stat-label">Password</div>
         </div>
         <div class="stats-container">
            <span class="big-stat">{{ header.0.dataMap.tfvars_items }}</span>
            <div class="stat-label">TF Vars</div>
         </div>
         <div class="stats-container">
            <span class="big-stat">{{ header.0.dataMap.secret_items }}</span>
            <div class="stat-label">Secret</div>
         </div>
         <div class="stats-container">
            <span class="big-stat">{{ header.0.dataMap.uuid_items }}</span>
            <div class="stat-label">UUID</div>
         </div>
         <div class="stats-container">
            <span class="big-stat">{{ header.0.dataMap.key_items }}</span>
            <div class="stat-label">Key</div>
         </div>
         <div class="stats-container">
            <span class="big-stat">{{ header.0.dataMap.random_items }}</span>
            <div class="stat-label">Random</div>
         </div>
      </div>
   </div>
   <h2>Cypher Items</h2>
   <table>
      <thead>
         <th>Key</th>
         <th>Last Updated</th>
         <th>Last Accessed</th>
         <th>Lease Timeout</th>
      </thead>
      <tbody>
         {{#each main}}
         <tr>
            <td>{{dataMap.key}}</td>
            <td>{{dataMap.last_updated}}</td>
            <td>{{dataMap.last_accessed}}</td>
            <td>{{dataMap.lease_timeout}}</td>
         </tr>
         {{/each}}
      </tbody>
   </table>
</div>

Build the JAR

With the code written, use gradle to build the JAR which we can upload to Morpheus so the report can be viewed. To do so, change directory into the location of the directory created earlier to hold your custom plugin code.

cd path/to/your/directory

Build your new plugin.

gradle shadowJar

Once the build process has completed, locate the JAR in the build/libs directory

Upload the Custom Report Plugin to Morpheus UI

Custom plugins are added to Morpheus through the Plugins tab in the Integrations section (Administration > Integrations > Plugins). Navigate to this section and click Choose File. Browse for your JAR file and upload it to Morpheus. The new plugin will be added next to any other custom plugins that may have been developed for your appliance.

Once uploaded, navigate to the Reports section (Operations > Reports). You new report will appear correctly categorized, labeled, and described according to your code. Just like any other report, it can be run now or scheduled for future runs.