The Enterprise Side of JavaFX: Part Two

by Adam Bien

Learn how to implement the LightView dashboard with JavaFX 2.

Published June 2012

Downloads:

Download: Java FX   

In Part One of this three-part series, we discussed the service and model layer of a JavaFX application called LightView and focused on the conversion of REST services into a bindable set of properties. The entire communication layer, including Comet-style long polling, was encapsulated behind a boundary interface: org.lightview.presenter.DashboardPresenterBindings. The back-end services were made accessible through a set of bindable properties.

Here, in Part Two, I discuss the implementation of the LightView UI dashboard with JavaFX 2.

Web View Integration

The RESTful back end of the LightView application comes with a rudimentary HTML page that is used to start/stop the monitoring service, set the snapshot interval, and activate/deactivate the GlassFish monitoring capabilities, as shown in Figure 1. ("GlassFish" in this article refers to either Oracle GlassFish Server or GlassFish Server Open Source Edition.)

Figure 1. LightFish Configuration View

Figure 1. LightFish Configuration View

There is no additional benefit to re-implementing the JavaServer Faces (JSF) configuration view in native JavaFX controls. For configuration purposes, the JSF view is good enough to be integrated directly in a screen-scraping manner. Instead of accessing the REST services behind the scenes and rendering them into JavaFX components, the whole HTML page is rendered with a browser component inside the dashboard. The class org.lightview.view.Browser (see Listing 1) encapsulates the javafx.scene.web.WebView and only exposes the URI as a bindable StringProperty.

public class Browser extends Collapsible {
    private StringProperty uri = new SimpleStringProperty();
    private WebEngine engine;
    private WebView webView;
    private final static int HEIGHT = 280;
    Browser() {}
    
    Node view() {
        if(webView == null)
            initialize();
        return webView;
    }
    private void initialize() {
        this.webView = new WebView();
        this.webView.setPrefHeight(HEIGHT);
        this.engine = webView.getEngine();
        this.prefHeight = this.webView.getPrefHeight();
        this.registerListeners();
    }


    public StringProperty getURI() {
        return uri;
    }

    private void registerListeners() {
        uri.addListener(new ChangeListener<String>() {
            public void changed(ObservableValue<? extends String> observable, 
String old, String newValue) {
                if (newValue != null) {
                    engine.load(skipLastSlash(newValue));
                }
            }
        });
    }
    String skipLastSlash(String uri) {
        if(!uri.endsWith("/"))
            return uri;
        return uri.substring(0, uri.lastIndexOf("/"));
    }

    @Override
    public DoubleProperty getMaxHeightProperty() {
        return this.webView.maxHeightProperty();
    }
}

Listing 1. Encapsulation of the WebView Component

The URI StringProperty getURI() is directly bound to a text field:

        TextField txtUri = TextFieldBuilder.
                create().
                editable(true).
                text("http://localhost:8080/lightfish/live").
                prefColumnCount(40).
                minHeight(20).
                build();
		//...
        this.browser.getURI().bind(txtUri.textProperty());

Any change to the URI StringProperty reloads and refreshes the contents of the javafx.scene.web.WebView without further intervention.

The URI property from the org.lightview.view.Browser component is directly bound to the textProperty of the TextField. This makes the interface of the org.lightview.view.Browser interface both narrow and convenient at the same time. The user of the Browser component has to deal only with the URI StringProperty, not with the javafx.scene.web.WebView or WebEngine functionality.

Some Eye Candy

The configuration view implemented in the org.lightview.view.Browser component is needed only to start or stop the monitoring process or set the monitoring interval. After the configuration, it can be minimized to provide more space for the monitoring widgets. Animated Browser hiding and recovery to the original size was extracted into a generic class org.lightview.view.Collapsible (see Listing 2).

public abstract class Collapsible {
    protected double prefHeight;
    private boolean minimized = false;

    public boolean toggleMinimize() {
        if (minimized) {
            maximize();
            minimized = false;
        } else {
            minimize();
            minimized = true;
        }
        return minimized;

    }

    void minimize() {
        animate(0);
    }

    void maximize() {
        animate(this.prefHeight);
    }

    void animate(double toValue){         Timeline timeline = new Timeline();
        timeline.getKeyFrames().add(
                new KeyFrame(Duration.seconds(1),
                        new KeyValue(getMaxHeightProperty(), toValue)));
        timeline.playFromStart();
    }

    protected abstract DoubleProperty getMaxHeightProperty();
}

Listing 2. Extraction of Minimalization into Collapsible Class

In the method Collapsible#animate in Listing 2, a DoubleProperty is increased or decreased to the double toValue value that is passed as a method parameter with the duration set to one second. The DoubleProperty is provided by implementing the method getMaxHeightProperty() in the subclass.

Interestingly, the class Collapsible has no reference to the actual javafx.scene.Node that is going to be collapsed. Only a DoubleProperty is "animated." The Browser subclass exposes the WebView#maxHeightProperty(), which is already bound to the Node internally. Without binding, the Collapsible class would have to get a direct reference to a Node class to be able to animate the size by serial invocation of a resize method.

Encapsulation for Simplicity

The main LightView responsibility is visualization of discrete numbers representing distinct application server subsystems, such as the pool size, the number of connections, the number of threads, or the number of failed transactions. Fortunately, these numbers are only loosely related to each other and can be visualized independently. Each monitorable value is going to be visualized by an org.lightview.view.Snapshot instance (see Figure 2).

Figure 2. An org.lightview.view.Snapshot Instance

Figure 2. An org.lightview.view.Snapshot Instance

The Snapshot visual component entirely encapsulates a javafx.scene.chart.XYChart component (see Listing 3). To change the value of the chart, you only have to change the state of the DoubleProperty value.

public class Snapshot {
    private String title;
    private String yAxisTitle;
    private String yUnit;
    private XYChart.Series<String, Number> series;
    private static final int MAX_SIZE = 10;
    private XYChart<String, Number> chart;
    private static final double FADE_VALUE = 0.3;
    private DoubleProperty currentValue;
    private boolean activated;
    private ReadOnlyLongProperty idProvider;

    public Snapshot(ReadOnlyLongProperty idProvider, String title, String 
yAxisTitle, String yUnit) {
        this.title = title;
        this.yAxisTitle = yAxisTitle;
        this.yUnit = yUnit;
        this.currentValue = new SimpleDoubleProperty();
        this.idProvider = idProvider;
        this.initialize();
        this.registerListeners();
    }

    public Snapshot(ReadOnlyLongProperty idProvider, String title, String yAxisTitle) {
        this(idProvider,title,yAxisTitle,null);
    }

    private void initialize() {
           final CategoryAxis xAxis = new CategoryAxis();
           final NumberAxis yAxis = new NumberAxis();
           yAxis.setTickLabelFormatter(new 
NumberAxis.DefaultFormatter(yAxis,yUnit,null));
           final LineChart chart = new LineChart(xAxis,yAxis);
           chart.setLegendVisible(false);
           chart.setTitle(title);
           yAxis.setLabel(yAxisTitle);
           yAxis.setForceZeroInRange(true);
           this.series = new XYChart.Series<String,Number>();
           chart.getData().add(series);
           this.chart = chart;
           this.chart.setId("snapshotChart");
           deactivate();
       }

    private void registerListeners(){
        this.currentValue.addListener(new ChangeListener<Number>() {
            public void changed(ObservableValue<? extends Number> 

observableValue, Number oldValue, Number newValue) {
                onNewEntry(newValue);
            }
        });
    }

    public DoubleProperty value() {         return currentValue;     }      public Node view(){         return this.chart;     }

    void deactivate(){
        FadeTransition fadeAway = FadeTransitionBuilder.create().fromValue(1.0).toValue(FADE_VALUE).duration
(Duration.seconds(1)).node(this.chart).build();
        fadeAway.play();
        activated = false;
    }

    void activate(){
        FadeTransition fadeAway = FadeTransitionBuilder.create().fromValue(FADE_VALUE).toValue(1.0).duration
(Duration.seconds(1)).node(this.chart).build();
        fadeAway.play();
        activated = true;
    }

    void onNewEntry(Number value) {
        String id = "-";
        if(idProvider != null)
            id = String.valueOf(idProvider.get());
        if(value.intValue() != 0){
            this.series.getData().add(new XYChart.Data<String,Number>(id,value));
            if(this.series.getData().size() > MAX_SIZE)
                this.series.getData().remove(0);

            if(!activated)
                activate();
        }
    }
}

Listing 3. Encapsulation of XYChart in a Snapshot Component

An org.lightview.view.Snapshot exposes only a bindable value() DoubleProperty and the XYChart as an ordinary javafx.scene.Node to the user. Because javafx.scene.Node is a mostly generic visual component, the internal visualization can be changed without affecting the user. The Snapshot can be directly connected with the monitoring data using JavaFX binding in a one-liner:

public interface DashboardPresenterBindings {
    ReadOnlyLongProperty getThreadCount();
  //...other properties omitted
}
//connecting the presenter with the view
 this.threadCount.value().bind(this.dashboardPresenter.getThreadCount());

The chart updates are implemented in the method onNewEntry (Listing 3). The onNewEntry method, however, is not intended to be used directly and was, therefore, set to have package-private visibility. The Snapshot registers itself as javafx.beans.value.ChangeListener to the exposed value() DoubleProperty and converts all state changes into onNewEntry method invocations. A DoubleProperty in JavaFX context is easier to use than custom Java methods, and it also makes the interface more generic without inhibiting usability. The ReadOnlyLongProperty idProvider provides the value for the x-axis. The value for the x-axis is fetched on each new arrival of monitoring data. The value is useful for the correlation of all visualization widgets.

The main driver behind the encapsulation is not reuse or extensibility, but rather simplicity. It is far easier to test an encapsulated piece of functionality than to test presentation logic that is held tightly together with binding and scattered across multiple classes. Pragmatic encapsulation makes a visual component easier to maintain, because concepts and metaphors are directly materialized in code.

Both methods DoubleProperty value() and Node view() could be extracted into a dedicated interface to decouple the user from the Snapshot class and make the API more explicit. Since both methods are the only methods with public visibility and the introduction of another Snapshot class is rather unlikely, a dedicated Java interface would only increase the code complexity without providing a concrete benefit.

Composition for Flexibility

The Snapshot views are integrated into a composite org.lightview.view.Dashboard view. The Dashboard instantiates, lays out, and organizes related Snapshot views into tabs (see Listing 4).

package org.lightview.view;
import javafx.beans.property.ReadOnlyLongProperty;
import javafx.collections.MapChangeListener;
import javafx.event.ActionEvent;
import javafx.event.EventHandler;
import javafx.scene.Node;
import javafx.scene.Scene;
import javafx.scene.control.*;
import javafx.scene.layout.HBox;
import javafx.scene.layout.HBoxBuilder;
import javafx.scene.layout.VBox;
import javafx.stage.Stage;
import org.lightview.presenter.ConnectionPoolBindings;
import org.lightview.presenter.DashboardPresenterBindings;

public class Dashboard {

    DashboardPresenterBindings dashboardPresenter;
    Stage stage;
    private TextField txtUri;
    private Browser browser;
    private Snapshot heap;
    private Snapshot threadCount;
	//...other Snapshots omitted
    private Grid grid;
    private TabPane tabPane;
    
    private Node uriInputView;

    public Dashboard(Stage stage, DashboardPresenterBindings dashboardPresenter) {
        this.dashboardPresenter = dashboardPresenter;
        this.stage = stage;
        this.tabPane = new TabPane();
        this.createViews();
        this.bind();
        this.open();
    }
	
  private void createViews() {
        this.vertical = new VBox();
        HBox threadsAndMemory = new HBox();
        HBox paranormal = new HBox();
        HBox transactions = new HBox();
        HBox web = new HBox();

        String hBoxClass = "boxSpacing";
        this.vertical.getStyleClass().add(hBoxClass);
        threadsAndMemory.getStyleClass().add(hBoxClass);
        paranormal.getStyleClass().add(hBoxClass);
        transactions.getStyleClass().add(hBoxClass);
        web.getStyleClass().add(hBoxClass);

        instantiateViews();

        threadsAndMemory.getChildren().addAll(this.heap.view(), 
this.threadCount.view(), this.peakThreadCount.view());
        transactions.getChildren().addAll(this.commitCount.view(), 
this.rollbackCount.view());
        paranormal.getChildren().addAll(this.queuedConnections.view(), 
this.totalErrors.view(), this.busyThread.view());
        web.getChildren().addAll(this.activeSessions.view());
        web.getChildren().addAll(this.expiredSessions.view());

        Tab threadsAndMemoryTab = createTab(threadsAndMemory, "Threads And 
Memory");
        Tab transactionsTab = createTab(transactions, "Transactions");
        Tab paranormalTab = createTab(paranormal, "Paranormal Activity");
        Tab webTab = createTab(web, "Web");
        this.tabPane.getTabs().addAll(threadsAndMemoryTab, transactionsTab, 
paranormalTab, webTab);

        this.vertical.getChildren().addAll(uriInputView, this.browser.view(), 
this.tabPane, this.grid.createTable());
    }

    private void instantiateViews() {
        this.uriInputView = createURIInputView();
        this.browser = new Browser();
        ReadOnlyLongProperty id = this.dashboardPresenter.getId();
        this.heap = new Snapshot(id, "Heap Size", "Used Heap");
        this.threadCount = new Snapshot(id, "Thread Count", "Threads");
		//...other Snapshots omitted
	    this.grid = new Grid(this.dashboardPresenter.getSnapshots());
    }

    private void bind() {
        this.dashboardPresenter.getUriProperty().bind(txtUri.textProperty());
        this.browser.getURI().bind(txtUri.textProperty());
        this.heap.value().bind(this.dashboardPresenter.getUsedHeapSizeInMB());

        this.threadCount.value().bind(this.dashboardPresenter.getThreadCount());
		//... other bindings omitted
        this.dashboardPresenter.getPools().addListener(new 
MapChangeListener<String, ConnectionPoolBindings>() {
            public void onChanged(Change<? extends String, ? extends 
ConnectionPoolBindings> change) {
                ConnectionPoolBindings valueAdded = change.getValueAdded();
                if (valueAdded != null)
                    createPoolTab(valueAdded);
            }
        });
    }
    public void open() {
        Scene scene = new Scene(this.vertical);
        scene.getStylesheets().add(this.getClass().getResource("lightfish.css").toExternalForm
());
        stage.setFullScreen(false);
        stage.setScene(scene);
        stage.show();
    }


    private Tab createTab(Node content, String caption) {
        Tab tab = new Tab();
        tab.setContent(content);
        tab.setText(caption);
        return tab;
    }

    void createPoolTab(ConnectionPoolBindings valueAdded) {
        ReadOnlyLongProperty id = this.dashboardPresenter.getId();
        String jndiName = valueAdded.getJndiName().get();
        ConnectionPool connectionPool = new ConnectionPool(id, valueAdded);
        Node view = connectionPool.view();
        Tab tab = createTab(view, "Resource: " + jndiName);
        this.tabPane.getTabs().add(tab);
    }

    private Node createURIInputView() {
        final Button button = new Button();
        button.setText("-");
        button.setOnAction(new EventHandler<ActionEvent>() {
            public void handle(ActionEvent actionEvent) {
                toggleBrowserSize(button);
            }
        });
        HBox hBox = HBoxBuilder.create().spacing(10).build();
        this.txtUri = TextFieldBuilder.
                create().
                editable(true).
                text("http://localhost:8080/lightfish/live").
                prefColumnCount(40).
                minHeight(20).
                build();
        Label uri = LabelBuilder.create().labelFor(txtUri).text("LightFish 
location:").build();
        hBox.getChildren().addAll(uri, txtUri, button);
        return hBox;
    }

    private void toggleBrowserSize(Button button) {
        boolean minimized = this.browser.toggleMinimize();
        if (minimized) {
            button.setText("+");
        } else {
            button.setText("-");
        }
    }

}

Listing 4. Organizing Snapshots into a Dashboard

Because Snapshot instances are already self-contained, the Dashboard only has to instantiate them and connect them with the underlying monitoring data. Each Snapshot is instantiated with a title and an ID provider that is needed to synchronize the x-axis of all the widgets (see the constructor in Listing 3 and method Dashboard#instantiateViews in Listing 4).

The Snapshot views (javafx.scene.Node instances) are wrapped with javafx.scene.layout.HBox for layout purposes. Sets of the Snapshot widgets visualize related monitoring data and are organized into javafx.scene.control.Tab instances (see the second half of the method createViews()).

In the method Dashboard#bind(), each Snapshot is connected with the corresponding value from the DashboardPresenterBindings. To introduce a new widget you will have to do the following:

  1. Make a javafx.beans.property.ReadOnlyProperty available from DashboardPresenterBindings.
  2. Instantiate a Snapshot in the Dashboard view.
  3. Assign the Snapshot to a Tab.
  4. Bind the Snapshot with a corresponding value from the presenter interface.

The interface DashboardPresenterBinding defines the binding API of the org.lightview.presenter.DashboardPresenter and was discussed in the previous article. Communication with the RESTful back end and conversion of XML data into bindable properties are the main responsibilities of the service layer exposed by the DashboardPresenter.

During LightView startup (see Listing 5), the DashboardPresenter is instantiated and passed together with the Stage to the Dashboard view's constructor.

import javafx.application.Application;
import javafx.stage.Stage;
import org.lightview.presenter.DashboardPresenter;
import org.lightview.view.Dashboard;
public class App extends Application {
    @Override
    public void start(Stage primaryStage) {
        DashboardPresenter dashboardPresenter = new DashboardPresenter();
        new Dashboard(primaryStage,dashboardPresenter);
    }
    public static void main(String[] args) throws MalformedURLException {
        launch(args);
    }
}

Listing 5. Starting LightView

The method main is simple. The command-line arguments are passed to the method Application#launch. The JavaFX runtime invokes the method Application#start, which instantiates the Dashboard after instantiating the DashboardPresenter.

LightView comes with a minimal set of CSS rules externalized in the lightview.css file. The CSS file is loaded as a resource and added to the javafx.scene.Scene (see method Dashboard#open()in Listing 4):

scene.getStylesheets().add(this.getClass().getResource("lightview.css").toExternalForm()).

The CSS file contains only the spacing for the widgets and styling for the chart title:

.boxSpacing{
    -fx-padding: 10 10 10 10;
}
.chart-title {
    -fx-font-size: 1.0em;
}

All JavaFX components are stylable via CSS. The lightview.css file can be used to change significantly the look and feel of the entire application without changing the source code.

On-the-Fly Composites

The idea of the separation of view and bindings was also applied to monitor a JDBC connection pool. Each connection pool comes with several monitoring values, such as the number of free connections, the number of connections in use, the length of the wait queue, and the number of mistakenly unclosed connections. These independent but related values are visualized together with the org.lightview.view.ConnectionPool class (see Listing 6).

public class ConnectionPool {
    private HBox box;
    private Snapshot freeConnections;
    private Snapshot usedConnections;
    private Snapshot waitQueueLength;
    private Snapshot connectionLeaks;
    private ConnectionPoolBindings bindings;
    private ReadOnlyLongProperty idProvider;

    public ConnectionPool(ReadOnlyLongProperty idProvider, ConnectionPoolBindings 
connectionPoolBindings) {
        this.bindings = connectionPoolBindings;
        this.idProvider = idProvider;
        this.createSnapshotViews();
        this.bind();
    }

    private void createSnapshotViews(){
        this.freeConnections = new Snapshot(idProvider,"Free Connections", 
"Connections", "");
		//...other Snapshot instantiations omitted
        this.box = new HBox();
        box.getChildren().add(freeConnections.view());
		//other additions omitted

    }
    private void bind() {
        this.freeConnections.value().bind(this.bindings.getNumconnfree());
		//other Snapshot binding omitted
    }
    public Node view() {
        return box;
    }
}

Listing 6. Encapsulation of Connection Pool Monitoring

ConnectionPool is a composite of Snapshot views. From the outside, it looks like a Snapshot, but it actually manages several Snapshot instances internally. Similarly to the Dashboard view, a ConnectionPool expects a ConnectionPoolBindings instance and binds the Snapshot view to the monitoring data internally.

In contrast to "usual" Snapshot views, which represent always existent monitoring data, the number of installed connection pools depends heavily on application configuration. For this reason, the ConnectionPool views are integrated on-the-fly in the method Dashboard#createPoolTab (see Listing 4).

The number of created connections is dependent on the contents of the ObservableMap<String, ConnectionPoolBindings> returned by the method DashboardPresenterBindings#getPools(). The Dashboard registers itself as a listener (see the end of the method Dashboard#bind in Listing 4) and gets notified upon each change to the contents of the ObservableMap. Every addition results in the creation of a new ConnectionPool view with the corresponding DashboardPresenterBindings instance. Removal of undeployed connection pools during monitoring was not implemented for simplicity reasons. In the unlikely case of connection pool undeployment during monitoring, you would have to restart the application to get rid of the superfluous monitoring tab.

Data Binding and Tables

In addition to the visual representation with charts, all the monitoring values are displayed in a TableView (see Listing 7).

import javafx.collections.ObservableList;
import javafx.scene.Node;
import javafx.scene.control.TableColumn;
import javafx.scene.control.TableView;
import javafx.scene.control.cell.PropertyValueFactory;
import org.lightview.model.Snapshot;

public class Grid {

    private ObservableList<Snapshot> snapshots;

    public Grid(ObservableList<Snapshot> snapshots) {
        this.snapshots = snapshots;
    }

    public Node createTable(){
        TableView tableView = new TableView();
        ObservableList columns = tableView.getColumns();
        columns.add(createColumn("monitoringTime","Monitoring Time"));
        columns.add(createColumn("usedHeapSizeInMB","Heap Size"));
        columns.add(createColumn("threadCount","Thread Count"));
	     //...other columns omitted
        tableView.setItems(this.snapshots);
        return tableView;
    }

    private TableColumn createColumn(String name,String caption) {
      TableColumn column = new TableColumn(caption);
      column.setCellValueFactory(new PropertyValueFactory<Snapshot,String>(name));
      return column;
    }
}

Listing 7. The TableView Wrapped in the Grid

The org.lightview.view.Grid widget expects a javafx.collections.ObservableList instance that contains Snapshot domain objects in its constructor. The bindable ObservableList is passed in the TableView#setItem method as a parameter. New entries in the ObservableLists are directly reflected as rows in the TableView.

The columns of the TableView are created with the javafx.scene.control.cell.PropertyValueFactory with the name of the property to bind and the visible header text. The name of the property has to correspond to the attribute of the model object. A column with the name "threadCount" : columns.add(createColumn("threadCount","Thread Count")) has to correspond to the name of the attribute in the model class:


public class Snapshot {
	private int threadCount;
}  

The PropertyValueFactory shrinks the amount of code that is needed to implement a grid view. You can bind an attribute of a domain object to a column with a single line of code. With reflection and annotations, you could even automate the whole process.

Structuring Non-Trivial JavaFX Applications

JavaFX encourages encapsulation without forcing you to build models for each visual component. With the availability of bindable properties, the boundary between the view and the model can be reduced to an expressive set of bindable properties. Wrapping JavaFX components with ordinary Java classes further reduces the complexity. Instead of dealing with low-level JavaFX mechanics all the time, you can build simple components and break down the complexity of the presentation logic into understandable pieces. CSS skinning further helps with the separation of the code that is needed for the implementation of the presentation logic and the visual appearance of the application on the screen. You can adjust significant portions of an application's look and feel directly in CSS files without touching the actual source code.

See Also

About the Author

Consultant and author Adam Bien is an Expert Group member for the Java EE 6 and 7, EJB 3.X, JAX-RS, CDI, and JPA 2.X JSRs. He has worked with Java technology since JDK 1.0 and with Servlets/EJB 1.0 in several large-scale projects, and he is now an architect and developer for Java SE and Java EE projects. He has edited several books about JavaFX, J2EE, and Java EE, and he is the author of Real World Java EE Patterns—Rethinking Best Practices and Real World Java EE Night Hacks—Dissecting the Business Tier. Adam is also a Java Champion and JavaOne 2009 Rock Star.

Follow us on Facebook, Twitter, and the Oracle Java Blog.