/*
* Autopsy Forensic Browser
*
* Copyright 2011-2016 Basis Technology Corp.
* Contact: carrier <at> sleuthkit <dot> org
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.sleuthkit.autopsy.timeline.ui.detailview;
import com.google.common.collect.ImmutableList;
import java.util.List;
import java.util.Objects;
import java.util.function.Function;
import java.util.function.Predicate;
import java.util.stream.Collectors;
import javafx.application.Platform;
import javafx.beans.InvalidationListener;
import javafx.beans.Observable;
import javafx.collections.ObservableList;
import javafx.concurrent.Task;
import javafx.fxml.FXML;
import javafx.scene.Node;
import javafx.scene.chart.Axis;
import javafx.scene.control.Alert;
import javafx.scene.control.ButtonBar;
import javafx.scene.control.ButtonType;
import javafx.scene.control.CheckBox;
import javafx.scene.control.Label;
import javafx.scene.control.MenuButton;
import javafx.scene.control.RadioButton;
import javafx.scene.control.Slider;
import javafx.scene.control.ToggleButton;
import javafx.scene.control.ToggleGroup;
import javafx.scene.layout.HBox;
import javafx.stage.Modality;
import org.apache.commons.lang3.StringUtils;
import org.controlsfx.control.action.Action;
import org.joda.time.DateTime;
import org.joda.time.Interval;
import org.openide.util.NbBundle;
import org.sleuthkit.autopsy.coreutils.Logger;
import org.sleuthkit.autopsy.coreutils.ThreadConfined;
import org.sleuthkit.autopsy.timeline.FXMLConstructor;
import org.sleuthkit.autopsy.timeline.TimeLineController;
import org.sleuthkit.autopsy.timeline.ViewMode;
import org.sleuthkit.autopsy.timeline.datamodel.EventStripe;
import org.sleuthkit.autopsy.timeline.datamodel.FilteredEventsModel;
import org.sleuthkit.autopsy.timeline.datamodel.TimeLineEvent;
import org.sleuthkit.autopsy.timeline.ui.AbstractTimelineChart;
import org.sleuthkit.autopsy.timeline.utils.MappedList;
import org.sleuthkit.autopsy.timeline.zooming.DescriptionLoD;
import org.sleuthkit.autopsy.timeline.zooming.ZoomParams;
/**
* Controller class for a DetailsChart based implementation of a timeline view.
*
* This class listens to changes in the assigned FilteredEventsModel and updates
* the internal DetailsChart to reflect the currently requested view settings.
*
* Conceptually this view visualizes trees of events grouped by type and
* description as a set of nested rectangles with their positions along the
* x-axis tied to their times, and their vertical positions arbitrary but
* constrained by the heirarchical relationships of the tree.The root of the
* trees are EventStripes, which contain EventCluster, which contain more finely
* grouped EventStripes, etc, etc. The leaves of the trees are EventClusters or
* SingleEvents.
*/
public class DetailViewPane extends AbstractTimelineChart<DateTime, EventStripe, EventNodeBase<?>, DetailsChart> {
private final static Logger LOGGER = Logger.getLogger(DetailViewPane.class.getName());
private final DateAxis detailsChartDateAxis = new DateAxis();
private final DateAxis pinnedDateAxis = new DateAxis();
@NbBundle.Messages("DetailViewPane.primaryLaneLabel.text=All Events (Filtered)")
private final Axis<EventStripe> verticalAxis = new EventAxis<>(Bundle.DetailViewPane_primaryLaneLabel_text());
/**
* ObservableList of events selected in this detail view. It is
* automatically mapped from the list of nodes selected in this view.
*/
private final MappedList<TimeLineEvent, EventNodeBase<?>> selectedEvents;
/**
* Local copy of the zoomParams. Used to backout of a zoomParam change
* without needing to requery/redraw the view.
*/
private ZoomParams currentZoomParams;
/**
* Constructor for a DetailViewPane
*
* @param controller the Controller to use
*/
public DetailViewPane(TimeLineController controller) {
super(controller);
this.selectedEvents = new MappedList<>(getSelectedNodes(), EventNodeBase<?>::getEvent);
//initialize chart;
setChart(new DetailsChart(controller, detailsChartDateAxis, pinnedDateAxis, verticalAxis, getSelectedNodes()));
//bind layout fo axes and spacers
detailsChartDateAxis.getTickMarks().addListener((Observable observable) -> layoutDateLabels());
detailsChartDateAxis.getTickSpacing().addListener(observable -> layoutDateLabels());
verticalAxis.setAutoRanging(false); //prevent XYChart.updateAxisRange() from accessing dataSeries on JFX thread causing ConcurrentModificationException
getSelectedNodes().addListener((Observable observable) -> {
//update selected nodes highlight
getChart().setHighlightPredicate(getSelectedNodes()::contains);
//update controllers list of selected event ids when view's selection changes.
getController().selectEventIDs(getSelectedNodes().stream()
.flatMap(detailNode -> detailNode.getEventIDs().stream())
.collect(Collectors.toList()));
});
}
/*
* Get all the trees of events flattened into a single list, but only
* including EventStripes and any leaf SingleEvents, since, EventClusters
* contain no interesting non-time related information.
*/
public ObservableList<TimeLineEvent> getAllNestedEvents() {
return getChart().getAllNestedEvents();
}
/*
* Get a list of the events that are selected in thes view.
*/
public ObservableList<TimeLineEvent> getSelectedEvents() {
return selectedEvents;
}
/**
* Observe the list of events that should be highlighted in this view.
*
*
* @param highlightedEvents the ObservableList of events that should be
* highlighted in this view.
*/
public void setHighLightedEvents(ObservableList<TimeLineEvent> highlightedEvents) {
highlightedEvents.addListener((Observable observable) -> {
/*
* build a predicate that matches events with the same description
* as any of the events in highlightedEvents or which are selected
*/
Predicate<EventNodeBase<?>> highlightPredicate =
highlightedEvents.stream() // => events
.map(TimeLineEvent::getDescription)// => event descriptions
.map(new Function<String, Predicate<EventNodeBase<?>>>() {
@Override
public Predicate<EventNodeBase<?>> apply(String description) {
return eventNode -> StringUtils.equalsIgnoreCase(eventNode.getDescription(), description);
}
})// => predicates that match strings agains the descriptions of the events in highlightedEvents
.reduce(getSelectedNodes()::contains, Predicate::or); // => predicate that matches an of the descriptions or selected nodes
getChart().setHighlightPredicate(highlightPredicate); //use this predicate to highlight nodes
});
}
@Override
final protected DateAxis getXAxis() {
return detailsChartDateAxis;
}
/**
* Get a new Action that will unhide events with the given description.
*
* @param description the description to unhide
* @param descriptionLoD the description level of detail to match
*
* @return a new Action that will unhide events with the given description.
*/
public Action newUnhideDescriptionAction(String description, DescriptionLoD descriptionLoD) {
return new UnhideDescriptionAction(description, descriptionLoD, getChart());
}
/**
* Get a new Action that will hide events with the given description.
*
* @param description the description to hide
* @param descriptionLoD the description level of detail to match
*
* @return a new Action that will hide events with the given description.
*/
public Action newHideDescriptionAction(String description, DescriptionLoD descriptionLoD) {
return new HideDescriptionAction(description, descriptionLoD, getChart());
}
@ThreadConfined(type = ThreadConfined.ThreadType.JFX)
@Override
protected void clearData() {
getChart().reset();
}
@Override
protected Boolean isTickBold(DateTime value) {
return false;
}
@Override
final protected Axis<EventStripe> getYAxis() {
return verticalAxis;
}
@Override
protected double getTickSpacing() {
return detailsChartDateAxis.getTickSpacing().get();
}
@Override
protected String getTickMarkLabel(DateTime value) {
return detailsChartDateAxis.getTickMarkLabel(value);
}
@Override
protected Task<Boolean> getNewUpdateTask() {
return new DetailsUpdateTask();
}
@Override
protected void applySelectionEffect(EventNodeBase<?> c1, Boolean selected) {
c1.applySelectionEffect(selected);
}
@Override
protected double getAxisMargin() {
return 0;
}
@Override
final protected ViewMode getViewMode() {
return ViewMode.DETAIL;
}
@Override
protected ImmutableList<Node> getSettingsControls() {
return ImmutableList.copyOf(new DetailViewSettingsPane(getChart().getLayoutSettings()).getChildrenUnmodifiable());
}
@Override
protected boolean hasCustomTimeNavigationControls() {
return false;
}
@Override
protected ImmutableList<Node> getTimeNavigationControls() {
return ImmutableList.of();
}
/**
* A Pane that contains widgets to adjust settings specific to a
* DetailViewPane
*/
static private class DetailViewSettingsPane extends HBox {
@FXML
private RadioButton hiddenRadio;
@FXML
private RadioButton showRadio;
@FXML
private ToggleGroup descrVisibility;
@FXML
private RadioButton countsRadio;
@FXML
private CheckBox bandByTypeBox;
@FXML
private CheckBox oneEventPerRowBox;
@FXML
private CheckBox truncateAllBox;
@FXML
private Slider truncateWidthSlider;
@FXML
private Label truncateSliderLabel;
@FXML
private MenuButton advancedLayoutOptionsButtonLabel;
@FXML
private ToggleButton pinnedEventsToggle;
private final DetailsChartLayoutSettings layoutSettings;
DetailViewSettingsPane(DetailsChartLayoutSettings layoutSettings) {
this.layoutSettings = layoutSettings;
FXMLConstructor.construct(DetailViewSettingsPane.this, "DetailViewSettingsPane.fxml"); //NON-NLS
}
@NbBundle.Messages({
"DetailViewPane.truncateSliderLabel.text=max description width (px):",
"DetailViewPane.advancedLayoutOptionsButtonLabel.text=Advanced Layout Options",
"DetailViewPane.bandByTypeBox.text=Band by Type",
"DetailViewPane.oneEventPerRowBox.text=One Per Row",
"DetailViewPane.truncateAllBox.text=Truncate Descriptions",
"DetailViewPane.showRadio.text=Show Full Description",
"DetailViewPane.countsRadio.text=Show Counts Only",
"DetailViewPane.hiddenRadio.text=Hide Description"})
@FXML
void initialize() {
assert bandByTypeBox != null : "fx:id=\"bandByTypeBox\" was not injected: check your FXML file 'DetailViewSettings.fxml'."; //NON-NLS
assert oneEventPerRowBox != null : "fx:id=\"oneEventPerRowBox\" was not injected: check your FXML file 'DetailViewSettings.fxml'."; //NON-NLS
assert truncateAllBox != null : "fx:id=\"truncateAllBox\" was not injected: check your FXML file 'DetailViewSettings.fxml'."; //NON-NLS
assert truncateWidthSlider != null : "fx:id=\"truncateAllSlider\" was not injected: check your FXML file 'DetailViewSettings.fxml'."; //NON-NLS
assert pinnedEventsToggle != null : "fx:id=\"pinnedEventsToggle\" was not injected: check your FXML file 'DetailViewSettings.fxml'."; //NON-NLS
//bind widgets to settings object properties
bandByTypeBox.selectedProperty().bindBidirectional(layoutSettings.bandByTypeProperty());
oneEventPerRowBox.selectedProperty().bindBidirectional(layoutSettings.oneEventPerRowProperty());
truncateAllBox.selectedProperty().bindBidirectional(layoutSettings.truncateAllProperty());
truncateSliderLabel.disableProperty().bind(truncateAllBox.selectedProperty().not());
pinnedEventsToggle.selectedProperty().bindBidirectional(layoutSettings.pinnedLaneShowing());
final InvalidationListener sliderListener = observable -> {
if (truncateWidthSlider.isValueChanging() == false) {
layoutSettings.truncateWidthProperty().set(truncateWidthSlider.getValue());
}
};
truncateWidthSlider.valueProperty().addListener(sliderListener);
truncateWidthSlider.valueChangingProperty().addListener(sliderListener);
descrVisibility.selectedToggleProperty().addListener((observable, oldToggle, newToggle) -> {
if (newToggle == countsRadio) {
layoutSettings.descrVisibilityProperty().set(DescriptionVisibility.COUNT_ONLY);
} else if (newToggle == showRadio) {
layoutSettings.descrVisibilityProperty().set(DescriptionVisibility.SHOWN);
} else if (newToggle == hiddenRadio) {
layoutSettings.descrVisibilityProperty().set(DescriptionVisibility.HIDDEN);
}
});
//Assign localized labels
truncateSliderLabel.setText(Bundle.DetailViewPane_truncateSliderLabel_text());
advancedLayoutOptionsButtonLabel.setText(Bundle.DetailViewPane_advancedLayoutOptionsButtonLabel_text());
bandByTypeBox.setText(Bundle.DetailViewPane_bandByTypeBox_text());
oneEventPerRowBox.setText(Bundle.DetailViewPane_oneEventPerRowBox_text());
truncateAllBox.setText(Bundle.DetailViewPane_truncateAllBox_text());
showRadio.setText(Bundle.DetailViewPane_showRadio_text());
countsRadio.setText(Bundle.DetailViewPane_countsRadio_text());
hiddenRadio.setText(Bundle.DetailViewPane_hiddenRadio_text());
}
}
@NbBundle.Messages({
"DetailViewPane.loggedTask.queryDb=Retrieving event data",
"DetailViewPane.loggedTask.name=Updating Details View",
"DetailViewPane.loggedTask.updateUI=Populating view",
"DetailViewPane.loggedTask.continueButton=Continue",
"DetailViewPane.loggedTask.backButton=Back (Cancel)",
"# {0} - number of events",
"DetailViewPane.loggedTask.prompt=You are about to show details for {0} events. This might be very slow and could exhaust available memory.\n\nDo you want to continue?"})
private class DetailsUpdateTask extends ViewRefreshTask<Interval> {
DetailsUpdateTask() {
super(Bundle.DetailViewPane_loggedTask_name(), true);
}
@Override
protected Boolean call() throws Exception {
super.call();
if (isCancelled()) {
return null;
}
FilteredEventsModel eventsModel = getEventsModel();
ZoomParams newZoomParams = eventsModel.getZoomParamaters();
//if the zoomParams haven't actually changed, just bail
if (Objects.equals(currentZoomParams, newZoomParams)) {
return true;
}
updateMessage(Bundle.DetailViewPane_loggedTask_queryDb());
//get the event stripes to be displayed
List<EventStripe> eventStripes = eventsModel.getEventStripes();
final int size = eventStripes.size();
//if there are too many stipes show a confirmation dialog
if (size > 2000) {
Task<ButtonType> task = new Task<ButtonType>() {
@Override
protected ButtonType call() throws Exception {
ButtonType ContinueButtonType = new ButtonType(Bundle.DetailViewPane_loggedTask_continueButton(), ButtonBar.ButtonData.OK_DONE);
ButtonType back = new ButtonType(Bundle.DetailViewPane_loggedTask_backButton(), ButtonBar.ButtonData.CANCEL_CLOSE);
Alert alert = new Alert(Alert.AlertType.WARNING, Bundle.DetailViewPane_loggedTask_prompt(size), ContinueButtonType, back);
alert.setHeaderText("");
alert.initModality(Modality.APPLICATION_MODAL);
alert.initOwner(getScene().getWindow());
ButtonType userResponse = alert.showAndWait().orElse(back);
if (userResponse == back) {
DetailsUpdateTask.this.cancel();
}
return userResponse;
}
};
//show dialog on JFX thread and block this thread until the dialog is dismissed.
Platform.runLater(task);
task.get();
}
if (isCancelled()) {
return null;
}
//we are going to accept the new zoomParams
currentZoomParams = newZoomParams;
//clear the chart and set the horixontal axis
resetView(eventsModel.getTimeRange());
updateMessage(Bundle.DetailViewPane_loggedTask_updateUI());
//add all the stripes
for (int i = 0; i < size; i++) {
if (isCancelled()) {
return null;
}
updateProgress(i, size);
final EventStripe stripe = eventStripes.get(i);
Platform.runLater(() -> getChart().addStripe(stripe));
}
return eventStripes.isEmpty() == false;
}
@Override
protected void cancelled() {
super.cancelled();
getController().retreat();
}
@Override
protected void setDateValues(Interval timeRange) {
detailsChartDateAxis.setRange(timeRange, true);
pinnedDateAxis.setRange(timeRange, true);
}
@Override
protected void succeeded() {
super.succeeded();
layoutDateLabels();
}
}
}