diff --git a/Core/src/org/sleuthkit/autopsy/healthmonitor/EnterpriseHealthMonitor.java b/Core/src/org/sleuthkit/autopsy/healthmonitor/EnterpriseHealthMonitor.java index 8facd6cd3e..b15e547896 100644 --- a/Core/src/org/sleuthkit/autopsy/healthmonitor/EnterpriseHealthMonitor.java +++ b/Core/src/org/sleuthkit/autopsy/healthmonitor/EnterpriseHealthMonitor.java @@ -34,8 +34,6 @@ import java.util.ArrayList; import java.util.Calendar; import java.util.GregorianCalendar; import java.util.UUID; -import java.util.concurrent.ExecutorService; -import java.util.concurrent.Executors; import java.util.concurrent.ScheduledThreadPoolExecutor; import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicBoolean; @@ -67,18 +65,16 @@ public final class EnterpriseHealthMonitor implements PropertyChangeListener { private final static Logger logger = Logger.getLogger(EnterpriseHealthMonitor.class.getName()); private final static String DATABASE_NAME = "EnterpriseHealthMonitor"; - private final static long DATABASE_WRITE_INTERVAL = 60; // Minutes + private final static long DATABASE_WRITE_INTERVAL = 1; // Minutes public static final CaseDbSchemaVersionNumber CURRENT_DB_SCHEMA_VERSION - = new CaseDbSchemaVersionNumber(1, 0); + = new CaseDbSchemaVersionNumber(1, 1); private static final AtomicBoolean isEnabled = new AtomicBoolean(false); private static EnterpriseHealthMonitor instance; - private final ExecutorService healthMonitorExecutor; - private static final String HEALTH_MONITOR_EVENT_THREAD_NAME = "Health-Monitor-Event-Listener-%d"; - private ScheduledThreadPoolExecutor healthMonitorOutputTimer; private final Map timingInfoMap; + private final List userInfoList; private static final int CONN_POOL_SIZE = 10; private BasicDataSource connectionPool = null; private CaseDbConnectionInfo connectionSettingsInUse = null; @@ -90,8 +86,9 @@ public final class EnterpriseHealthMonitor implements PropertyChangeListener { // of whether the monitor is enabled. timingInfoMap = new HashMap<>(); - // Set up the executor to handle case events - healthMonitorExecutor = Executors.newSingleThreadExecutor(new ThreadFactoryBuilder().setNameFormat(HEALTH_MONITOR_EVENT_THREAD_NAME).build()); + // Create the list to hold user information. The list will exist regardless + // of whether the monitor is enabled. + userInfoList = new ArrayList<>(); // Get the host name try { @@ -156,12 +153,70 @@ public final class EnterpriseHealthMonitor implements PropertyChangeListener { initializeDatabaseSchema(); } + if( ! CURRENT_DB_SCHEMA_VERSION.equals(getVersion())) { + upgradeDatabaseSchema(); + } + } catch (CoordinationService.CoordinationServiceException ex) { throw new HealthMonitorException("Error releasing database lock", ex); } // Clear out any old data timingInfoMap.clear(); + userInfoList.clear(); + } + + /** + * Upgrade an older database + */ + private void upgradeDatabaseSchema() throws HealthMonitorException { + + logger.log(Level.INFO, "Upgrading Health Monitor database"); + CaseDbSchemaVersionNumber currentSchema = getVersion(); + + Connection conn = connect(); + if(conn == null) { + throw new HealthMonitorException("Error getting database connection"); + } + + try (Statement statement = conn.createStatement()) { + conn.setAutoCommit(false); + + // Upgrade from 1.0 to 1.1 + // Changes: user_data table added + if(currentSchema.compareTo(new CaseDbSchemaVersionNumber(1,1)) < 0) { + + // Add the user_data table + statement.execute("CREATE TABLE IF NOT EXISTS user_data ("+ + "id SERIAL PRIMARY KEY," + + "host text NOT NULL," + + "timestamp bigint NOT NULL," + + "event_type int NOT NULL," + + "is_examiner boolean NOT NULL," + + "case_name text NOT NULL" + + ")"); + } + + // Update the schema version + statement.execute("UPDATE db_info SET value='" + CURRENT_DB_SCHEMA_VERSION.getMajor() + "' WHERE name='SCHEMA_VERSION'"); + statement.execute("UPDATE db_info SET value='" + CURRENT_DB_SCHEMA_VERSION.getMinor() + "' WHERE name='SCHEMA_MINOR_VERSION'"); + + conn.commit(); + logger.log(Level.INFO, "Health Monitor database upgraded to version {0}", CURRENT_DB_SCHEMA_VERSION.toString()); + } catch (SQLException ex) { + try { + conn.rollback(); + } catch (SQLException ex2) { + logger.log(Level.SEVERE, "Rollback error"); + } + throw new HealthMonitorException("Error upgrading database", ex); + } finally { + try { + conn.close(); + } catch (SQLException ex) { + logger.log(Level.SEVERE, "Error closing connection.", ex); + } + } } /** @@ -190,7 +245,7 @@ public final class EnterpriseHealthMonitor implements PropertyChangeListener { stopTimer(); healthMonitorOutputTimer = new ScheduledThreadPoolExecutor(1, new ThreadFactoryBuilder().setNameFormat("health_monitor_timer").build()); - healthMonitorOutputTimer.scheduleWithFixedDelay(new PeriodicHealthMonitorTask(false), DATABASE_WRITE_INTERVAL, DATABASE_WRITE_INTERVAL, TimeUnit.MINUTES); + healthMonitorOutputTimer.scheduleWithFixedDelay(new PeriodicHealthMonitorTask(), DATABASE_WRITE_INTERVAL, DATABASE_WRITE_INTERVAL, TimeUnit.MINUTES); } /** @@ -207,7 +262,17 @@ public final class EnterpriseHealthMonitor implements PropertyChangeListener { * @throws HealthMonitorException */ static synchronized void startUpIfEnabled() throws HealthMonitorException { - getInstance(); + getInstance().addUserEvent(UserEvent.LOG_ON); + } + + /** + * Called when the application is closing. + * Create a log off event and write all existing metrics to the database + * @throws HealthMonitorException + */ + static synchronized void shutdown() throws HealthMonitorException { + getInstance().addUserEvent(UserEvent.LOG_OFF); + recordMetrics(); } /** @@ -318,6 +383,17 @@ public final class EnterpriseHealthMonitor implements PropertyChangeListener { } } + /** + * Add a user event to the list. + * @param eventType + */ + private void addUserEvent(UserEvent eventType) { + UserData userInfo = new UserData(eventType); + synchronized(this) { + userInfoList.add(userInfo); + } + } + /** * Time a database query. * Database queries are hard to test in normal processing because the time @@ -356,16 +432,10 @@ public final class EnterpriseHealthMonitor implements PropertyChangeListener { /** * Collect metrics at a scheduled time. - * @param caseIsClosing True if this was triggered from a case closed event * @throws HealthMonitorException */ - private void gatherTimerBasedMetrics(boolean caseIsClosing) throws HealthMonitorException { - // Time a database query. If this was triggered from a case close event - // it will fail - since we're on a new thread the case database will - // be in the process of closing. In that case, skip collecting the metric. - if( ! caseIsClosing) { - performDatabaseQuery(); - } + private void gatherTimerBasedMetrics() throws HealthMonitorException { + performDatabaseQuery(); } /** @@ -375,6 +445,7 @@ public final class EnterpriseHealthMonitor implements PropertyChangeListener { private void writeCurrentStateToDatabase() throws HealthMonitorException { Map timingMapCopy; + List userDataCopy; // Do as little as possible within the synchronized block since it will // block threads attempting to record metrics. @@ -387,10 +458,13 @@ public final class EnterpriseHealthMonitor implements PropertyChangeListener { // per metric name. timingMapCopy = new HashMap<>(timingInfoMap); timingInfoMap.clear(); + + userDataCopy = new ArrayList<>(userInfoList); + userInfoList.clear(); } - // Check if there's anything to report (right now we only have the timing map) - if(timingMapCopy.keySet().isEmpty()) { + // Check if there's anything to report + if(timingMapCopy.keySet().isEmpty() && userDataCopy.isEmpty()) { return; } @@ -407,22 +481,33 @@ public final class EnterpriseHealthMonitor implements PropertyChangeListener { throw new HealthMonitorException("Error getting database connection"); } - // Add timing metrics to the database + // Add metrics to the database String addTimingInfoSql = "INSERT INTO timing_data (name, host, timestamp, count, average, max, min) VALUES (?, ?, ?, ?, ?, ?, ?)"; - try (PreparedStatement statement = conn.prepareStatement(addTimingInfoSql)) { + String addUserInfoSql = "INSERT INTO user_data (host, timestamp, event_type, is_examiner, case_name) VALUES (?, ?, ?, ?, ?)"; + try (PreparedStatement timingStatement = conn.prepareStatement(addTimingInfoSql); + PreparedStatement userStatement = conn.prepareStatement(addUserInfoSql)) { for(String name:timingMapCopy.keySet()) { TimingInfo info = timingMapCopy.get(name); - statement.setString(1, name); - statement.setString(2, hostName); - statement.setLong(3, System.currentTimeMillis()); - statement.setLong(4, info.getCount()); - statement.setDouble(5, info.getAverage()); - statement.setDouble(6, info.getMax()); - statement.setDouble(7, info.getMin()); + timingStatement.setString(1, name); + timingStatement.setString(2, hostName); + timingStatement.setLong(3, System.currentTimeMillis()); + timingStatement.setLong(4, info.getCount()); + timingStatement.setDouble(5, info.getAverage()); + timingStatement.setDouble(6, info.getMax()); + timingStatement.setDouble(7, info.getMin()); - statement.execute(); + timingStatement.execute(); + } + + for(UserData userInfo:userDataCopy) { + userStatement.setString(1, hostName); + userStatement.setLong(2, userInfo.getTimestamp()); + userStatement.setInt(3, userInfo.getEventType().getEventValue()); + userStatement.setBoolean(4, userInfo.isExaminerNode()); + userStatement.setString(5, userInfo.getCaseName()); + userStatement.execute(); } } catch (SQLException ex) { @@ -762,9 +847,8 @@ public final class EnterpriseHealthMonitor implements PropertyChangeListener { try (Statement statement = conn.createStatement()) { conn.setAutoCommit(false); - - String createTimingTable = - "CREATE TABLE IF NOT EXISTS timing_data (" + + + statement.execute("CREATE TABLE IF NOT EXISTS timing_data (" + "id SERIAL PRIMARY KEY," + "name text NOT NULL," + "host text NOT NULL," + @@ -773,16 +857,23 @@ public final class EnterpriseHealthMonitor implements PropertyChangeListener { "average double precision NOT NULL," + "max double precision NOT NULL," + "min double precision NOT NULL" + - ")"; - statement.execute(createTimingTable); + ")"); - String createDbInfoTable = - "CREATE TABLE IF NOT EXISTS db_info (" + + statement.execute("CREATE TABLE IF NOT EXISTS db_info (" + "id SERIAL PRIMARY KEY NOT NULL," + "name text NOT NULL," + "value text NOT NULL" + - ")"; - statement.execute(createDbInfoTable); + ")"); + + statement.execute("CREATE TABLE IF NOT EXISTS user_data ("+ + "id SERIAL PRIMARY KEY," + + "host text NOT NULL," + + "timestamp bigint NOT NULL," + + "event_type int NOT NULL," + + "is_examiner BOOLEAN NOT NULL," + + "case_name text NOT NULL" + + ")"); + statement.execute("INSERT INTO db_info (name, value) VALUES ('SCHEMA_VERSION', '" + CURRENT_DB_SCHEMA_VERSION.getMajor() + "')"); statement.execute("INSERT INTO db_info (name, value) VALUES ('SCHEMA_MINOR_VERSION', '" + CURRENT_DB_SCHEMA_VERSION.getMinor() + "')"); @@ -807,31 +898,32 @@ public final class EnterpriseHealthMonitor implements PropertyChangeListener { /** * The task called by the ScheduledThreadPoolExecutor to handle - * the database checks/writes. + * the periodic database update */ static final class PeriodicHealthMonitorTask implements Runnable { - boolean caseIsClosing; - - PeriodicHealthMonitorTask(boolean caseIsClosing) { - this.caseIsClosing = caseIsClosing; - } - - /** - * Perform all periodic tasks: - * - Check if monitoring has been enabled / disabled in the database - * - Calculate any final metrics - * - Write current metric data to the database - */ @Override public void run() { - try { - getInstance().updateFromGlobalEnabledStatus(); - getInstance().gatherTimerBasedMetrics(caseIsClosing); + recordMetrics(); + } + } + + /** + * Perform all periodic tasks: + * - Check if monitoring has been enabled / disabled in the database + * - Gather any additional metrics + * - Write current metric data to the database + * Do not run this from a new thread if the case/application is closing. + */ + private static void recordMetrics() { + try { + getInstance().updateFromGlobalEnabledStatus(); + if(monitorIsEnabled()) { + getInstance().gatherTimerBasedMetrics(); getInstance().writeCurrentStateToDatabase(); - } catch (HealthMonitorException ex) { - logger.log(Level.SEVERE, "Error performing periodic task", ex); //NON-NLS } + } catch (HealthMonitorException ex) { + logger.log(Level.SEVERE, "Error performing periodic task", ex); //NON-NLS } } @@ -842,8 +934,12 @@ public final class EnterpriseHealthMonitor implements PropertyChangeListener { case CURRENT_CASE: if ((null == evt.getNewValue()) && (evt.getOldValue() instanceof Case)) { - // When a case is closed, write the current metrics to the database - healthMonitorExecutor.submit(new EnterpriseHealthMonitor.PeriodicHealthMonitorTask(true)); + // Case is closing + addUserEvent(UserEvent.CASE_CLOSE); + + } else if((null == evt.getOldValue()) && (evt.getNewValue() instanceof Case)) { + // Case is opening + addUserEvent(UserEvent.CASE_OPEN); } break; } @@ -1057,6 +1153,47 @@ public final class EnterpriseHealthMonitor implements PropertyChangeListener { } } + /** + * Get user metrics currently stored in the database. + * @param timeRange Maximum age for returned metrics (in milliseconds) + * @return A list of user metrics + * @throws HealthMonitorException + */ + List getUserMetricsFromDatabase(long timeRange) throws HealthMonitorException { + + // Make sure the monitor is enabled. It could theoretically get disabled after this + // check but it doesn't seem worth holding a lock to ensure that it doesn't since that + // may slow down ingest. + if(! isEnabled.get()) { + throw new HealthMonitorException("Health Monitor is not enabled"); + } + + // Calculate the smallest timestamp we should return + long minimumTimestamp = System.currentTimeMillis() - timeRange; + + try (CoordinationService.Lock lock = getSharedDbLock()) { + if(lock == null) { + throw new HealthMonitorException("Error getting database lock"); + } + + List resultList = new ArrayList<>(); + + try (Connection conn = connect(); + Statement statement = conn.createStatement(); + ResultSet resultSet = statement.executeQuery("SELECT * FROM user_data WHERE timestamp > " + minimumTimestamp)) { + + while (resultSet.next()) { + resultList.add(new UserData(resultSet)); + } + return resultList; + } catch (SQLException ex) { + throw new HealthMonitorException("Error reading user metrics from database", ex); + } + } catch (CoordinationService.CoordinationServiceException ex) { + throw new HealthMonitorException("Error getting database lock", ex); + } + } + /** * Get an exclusive lock for the health monitor database. * Acquire this before creating, initializing, or updating the database schema. @@ -1096,6 +1233,164 @@ public final class EnterpriseHealthMonitor implements PropertyChangeListener { } } + /** + * Types of user events being logged + */ + enum UserEvent { + LOG_ON(0), + LOG_OFF(1), + CASE_OPEN(2), + CASE_CLOSE(3); + + int value; + + UserEvent(int value) { + this.value = value; + } + + /** + * Get the integer value of the event to store in the database. + * @return value corresponding to the event + */ + int getEventValue() { + return value; + } + + /** + * Get the UserEvent from the value stored in the database + * @param value + * @return the corresponding UserEvent object + * @throws HealthMonitorException + */ + static UserEvent valueOf(int value) throws HealthMonitorException { + for (UserEvent v : UserEvent.values()) { + if (v.value == value) { + return v; + } + } + throw new HealthMonitorException("Can not create UserEvent from unknown value " + value); + } + + /** + * Return whether a case is considered to be open given this event + * as the last recorded event. + * @return true if a case is open, false otherwise + */ + boolean caseIsOpen() { + return(this.equals(CASE_OPEN)); + } + + /** + * Return whether a user is considered to be logged in given this event + * as the last recorded event. + * @return true if a the user is logged in, false otherwise + */ + boolean userIsLoggedIn() { + // LOG_ON, CASE_OPEN, and CASE_CLOSED events all imply that the user + // is logged in + return( ! this.equals(LOG_OFF)); + } + } + + /** + * Class holding user metric data. + * Can be used for storing new events or retrieving + * events out of the database. + */ + static class UserData { + private final UserEvent eventType; + private long timestamp; + private final boolean isExaminer; + private final String hostname; + private String caseName; + + /** + * Create a new UserData object using the given event type + * and the current settings. + * @param eventType The type of event being recorded + */ + private UserData(UserEvent eventType) { + this.eventType = eventType; + this.timestamp = System.currentTimeMillis(); + this.isExaminer = (UserPreferences.SelectedMode.STANDALONE == UserPreferences.getMode()); + this.hostname = ""; + + // If there's a case open, record the name + try { + this.caseName = Case.getCurrentCaseThrows().getDisplayName(); + } catch (NoCurrentCaseException ex) { + // It's not an error if there's no case open + this.caseName = ""; + } + } + + /** + * Create a UserData object from a database result set. + * @param resultSet The result set containing the data + * @throws SQLException + * @throws HealthMonitorException + */ + UserData(ResultSet resultSet) throws SQLException, HealthMonitorException { + this.timestamp = resultSet.getLong("timestamp"); + this.hostname = resultSet.getString("host"); + this.eventType = UserEvent.valueOf(resultSet.getInt("event_type")); + this.isExaminer = resultSet.getBoolean("is_examiner"); + this.caseName = resultSet.getString("case_name"); + } + + /** + * This should only be used to make a dummy object to use for timestamp + * comparisons. + * @param timestamp + * @return A UserData object with the given timestamp + */ + static UserData createDummyUserData(long timestamp) { + UserData userData = new UserData(UserEvent.CASE_CLOSE); + userData.timestamp = timestamp; + return userData; + } + + /** + * Get the timestamp for the event + * @return Timestamp in milliseconds + */ + long getTimestamp() { + return timestamp; + } + + /** + * Get the host that created the metric + * @return the host name + */ + String getHostname() { + return hostname; + } + + /** + * Get the type of event + * @return the event type + */ + UserEvent getEventType() { + return eventType; + } + + /** + * Check whether this node is an examiner node or an auto ingest node + * @return true if it is an examiner node + */ + boolean isExaminerNode() { + return isExaminer; + } + + /** + * Get the name of the case for this metric + * @return the case name. Will be the empty string if no case was open. + */ + String getCaseName() { + return caseName; + } + } + /** * Internal class for collecting timing metrics. * Instead of storing each TimingMetric, we only store the min and max diff --git a/Core/src/org/sleuthkit/autopsy/healthmonitor/HealthMonitorDashboard.java b/Core/src/org/sleuthkit/autopsy/healthmonitor/HealthMonitorDashboard.java index 9dbd08233e..07f8fcced7 100644 --- a/Core/src/org/sleuthkit/autopsy/healthmonitor/HealthMonitorDashboard.java +++ b/Core/src/org/sleuthkit/autopsy/healthmonitor/HealthMonitorDashboard.java @@ -25,6 +25,7 @@ import java.util.Set; import java.util.HashSet; import java.util.HashMap; import java.util.Arrays; +import java.util.ArrayList; import java.util.List; import java.awt.event.ActionEvent; import java.awt.event.ActionListener; @@ -61,13 +62,16 @@ public class HealthMonitorDashboard { private final static String ADMIN_ACCESS_FILE_PATH = Paths.get(Places.getUserDirectory().getAbsolutePath(), ADMIN_ACCESS_FILE_NAME).toString(); Map> timingData; + List userData; - private JComboBox dateComboBox = null; - private JComboBox hostComboBox = null; - private JCheckBox hostCheckBox = null; - private JCheckBox showTrendLineCheckBox = null; - private JCheckBox skipOutliersCheckBox = null; - private JPanel graphPanel = null; + private JComboBox timingDateComboBox = null; + private JComboBox timingHostComboBox = null; + private JCheckBox timingHostCheckBox = null; + private JCheckBox timingShowTrendLineCheckBox = null; + private JCheckBox timingSkipOutliersCheckBox = null; + private JPanel timingGraphPanel = null; + private JComboBox userDateComboBox = null; + private JPanel userGraphPanel = null; private JDialog dialog = null; private final Container parentWindow; @@ -78,6 +82,7 @@ public class HealthMonitorDashboard { */ public HealthMonitorDashboard(Container parent) { timingData = new HashMap<>(); + userData = new ArrayList<>(); parentWindow = parent; } @@ -91,10 +96,12 @@ public class HealthMonitorDashboard { // Update the enabled status and get the timing data, then create all // the sub panels. JPanel timingPanel; + JPanel userPanel; JPanel adminPanel; try { updateData(); timingPanel = createTimingPanel(); + userPanel = createUserPanel(); adminPanel = createAdminPanel(); } catch (HealthMonitorException ex) { logger.log(Level.SEVERE, "Error creating panels for health monitor dashboard", ex); @@ -109,6 +116,9 @@ public class HealthMonitorDashboard { // Add the timing panel mainPanel.add(timingPanel); + // Add the user panel + mainPanel.add(userPanel); + // Add the admin panel if the admin file is present File adminFile = new File(ADMIN_ACCESS_FILE_PATH); if(adminFile.exists()) { @@ -148,6 +158,9 @@ public class HealthMonitorDashboard { if(EnterpriseHealthMonitor.monitorIsEnabled()) { // Get a copy of the timing data from the database timingData = EnterpriseHealthMonitor.getInstance().getTimingMetricsFromDatabase(DateRange.getMaximumTimestampRange()); + + // Get a copy of the user data from the database + userData = EnterpriseHealthMonitor.getInstance().getUserMetricsFromDatabase(DateRange.getMaximumTimestampRange()); } } @@ -185,12 +198,12 @@ public class HealthMonitorDashboard { timingMetricPanel.add(new JSeparator()); // Create panel to hold graphs - graphPanel = new JPanel(); - graphPanel.setLayout(new GridLayout(0,2)); + timingGraphPanel = new JPanel(); + timingGraphPanel.setLayout(new GridLayout(0,2)); // Update the graph panel, put it in a scroll pane, and add to the timing metric panel updateTimingMetricGraphs(); - JScrollPane scrollPane = new JScrollPane(graphPanel, JScrollPane.VERTICAL_SCROLLBAR_AS_NEEDED, JScrollPane.HORIZONTAL_SCROLLBAR_NEVER); + JScrollPane scrollPane = new JScrollPane(timingGraphPanel, JScrollPane.VERTICAL_SCROLLBAR_AS_NEEDED, JScrollPane.HORIZONTAL_SCROLLBAR_NEVER); timingMetricPanel.add(scrollPane); timingMetricPanel.revalidate(); timingMetricPanel.repaint(); @@ -216,11 +229,11 @@ public class HealthMonitorDashboard { // Create the combo box for selecting how much data to display String[] dateOptionStrings = Arrays.stream(DateRange.values()).map(e -> e.getLabel()).toArray(String[]::new); - dateComboBox = new JComboBox<>(dateOptionStrings); - dateComboBox.setSelectedItem(DateRange.ONE_DAY.getLabel()); + timingDateComboBox = new JComboBox<>(dateOptionStrings); + timingDateComboBox.setSelectedItem(DateRange.ONE_DAY.getLabel()); // Set up the listener on the date combo box - dateComboBox.addActionListener(new ActionListener() { + timingDateComboBox.addActionListener(new ActionListener() { @Override public void actionPerformed(ActionEvent arg0) { try { @@ -240,14 +253,14 @@ public class HealthMonitorDashboard { } // Load the host names into the combo box - hostComboBox = new JComboBox<>(hostNameSet.toArray(new String[hostNameSet.size()])); + timingHostComboBox = new JComboBox<>(hostNameSet.toArray(new String[hostNameSet.size()])); // Set up the listener on the combo box - hostComboBox.addActionListener(new ActionListener() { + timingHostComboBox.addActionListener(new ActionListener() { @Override public void actionPerformed(ActionEvent arg0) { try { - if((hostCheckBox != null) && hostCheckBox.isSelected()) { + if((timingHostCheckBox != null) && timingHostCheckBox.isSelected()) { updateTimingMetricGraphs(); } } catch (HealthMonitorException ex) { @@ -257,16 +270,16 @@ public class HealthMonitorDashboard { }); // Create the host checkbox - hostCheckBox = new JCheckBox(Bundle.HealthMonitorDashboard_createTimingControlPanel_filterByHost()); - hostCheckBox.setSelected(false); - hostComboBox.setEnabled(false); + timingHostCheckBox = new JCheckBox(Bundle.HealthMonitorDashboard_createTimingControlPanel_filterByHost()); + timingHostCheckBox.setSelected(false); + timingHostComboBox.setEnabled(false); // Set up the listener on the checkbox - hostCheckBox.addActionListener(new ActionListener() { + timingHostCheckBox.addActionListener(new ActionListener() { @Override public void actionPerformed(ActionEvent arg0) { try { - hostComboBox.setEnabled(hostCheckBox.isSelected()); + timingHostComboBox.setEnabled(timingHostCheckBox.isSelected()); updateTimingMetricGraphs(); } catch (HealthMonitorException ex) { logger.log(Level.SEVERE, "Error populating timing metric panel", ex); @@ -275,11 +288,11 @@ public class HealthMonitorDashboard { }); // Create the checkbox for showing the trend line - showTrendLineCheckBox = new JCheckBox(Bundle.HealthMonitorDashboard_createTimingControlPanel_showTrendLine()); - showTrendLineCheckBox.setSelected(true); + timingShowTrendLineCheckBox = new JCheckBox(Bundle.HealthMonitorDashboard_createTimingControlPanel_showTrendLine()); + timingShowTrendLineCheckBox.setSelected(true); // Set up the listener on the checkbox - showTrendLineCheckBox.addActionListener(new ActionListener() { + timingShowTrendLineCheckBox.addActionListener(new ActionListener() { @Override public void actionPerformed(ActionEvent arg0) { try { @@ -291,11 +304,11 @@ public class HealthMonitorDashboard { }); // Create the checkbox for omitting outliers - skipOutliersCheckBox = new JCheckBox(Bundle.HealthMonitorDashboard_createTimingControlPanel_skipOutliers()); - skipOutliersCheckBox.setSelected(false); + timingSkipOutliersCheckBox = new JCheckBox(Bundle.HealthMonitorDashboard_createTimingControlPanel_skipOutliers()); + timingSkipOutliersCheckBox.setSelected(false); // Set up the listener on the checkbox - skipOutliersCheckBox.addActionListener(new ActionListener() { + timingSkipOutliersCheckBox.addActionListener(new ActionListener() { @Override public void actionPerformed(ActionEvent arg0) { try { @@ -308,26 +321,26 @@ public class HealthMonitorDashboard { // Add the date range combo box and label to the panel timingControlPanel.add(new JLabel(Bundle.HealthMonitorDashboard_createTimingControlPanel_maxDays())); - timingControlPanel.add(dateComboBox); + timingControlPanel.add(timingDateComboBox); // Put some space between the elements timingControlPanel.add(Box.createHorizontalStrut(100)); // Add the host combo box and checkbox to the panel - timingControlPanel.add(hostCheckBox); - timingControlPanel.add(hostComboBox); + timingControlPanel.add(timingHostCheckBox); + timingControlPanel.add(timingHostComboBox); // Put some space between the elements timingControlPanel.add(Box.createHorizontalStrut(100)); // Add the skip outliers checkbox - timingControlPanel.add(this.showTrendLineCheckBox); + timingControlPanel.add(this.timingShowTrendLineCheckBox); // Put some space between the elements timingControlPanel.add(Box.createHorizontalStrut(100)); // Add the skip outliers checkbox - timingControlPanel.add(this.skipOutliersCheckBox); + timingControlPanel.add(this.timingSkipOutliersCheckBox); return timingControlPanel; } @@ -340,11 +353,11 @@ public class HealthMonitorDashboard { private void updateTimingMetricGraphs() throws HealthMonitorException { // Clear out any old graphs - graphPanel.removeAll(); + timingGraphPanel.removeAll(); if(timingData.keySet().isEmpty()) { // There are no timing metrics in the database - graphPanel.add(new JLabel(Bundle.HealthMonitorDashboard_updateTimingMetricGraphs_noData())); + timingGraphPanel.add(new JLabel(Bundle.HealthMonitorDashboard_updateTimingMetricGraphs_noData())); return; } @@ -352,8 +365,8 @@ public class HealthMonitorDashboard { // If necessary, trim down the list of results to fit the selected time range List intermediateTimingDataForDisplay; - if(dateComboBox.getSelectedItem() != null) { - DateRange selectedDateRange = DateRange.fromLabel(dateComboBox.getSelectedItem().toString()); + if(timingDateComboBox.getSelectedItem() != null) { + DateRange selectedDateRange = DateRange.fromLabel(timingDateComboBox.getSelectedItem().toString()); long threshold = System.currentTimeMillis() - selectedDateRange.getTimestampRange(); intermediateTimingDataForDisplay = timingData.get(metricName).stream() .filter(t -> t.getTimestamp() > threshold) @@ -366,25 +379,150 @@ public class HealthMonitorDashboard { // The graph always uses the data from all hosts to generate the x and y scales // so we don't filter anything out here. String hostToDisplay = null; - if(hostCheckBox.isSelected() && (hostComboBox.getSelectedItem() != null)) { - hostToDisplay = hostComboBox.getSelectedItem().toString(); + if(timingHostCheckBox.isSelected() && (timingHostComboBox.getSelectedItem() != null)) { + hostToDisplay = timingHostComboBox.getSelectedItem().toString(); } // Generate the graph TimingMetricGraphPanel singleTimingGraphPanel = new TimingMetricGraphPanel(intermediateTimingDataForDisplay, - hostToDisplay, true, metricName, skipOutliersCheckBox.isSelected(), showTrendLineCheckBox.isSelected()); + hostToDisplay, true, metricName, timingSkipOutliersCheckBox.isSelected(), timingShowTrendLineCheckBox.isSelected()); singleTimingGraphPanel.setPreferredSize(new Dimension(700,200)); - graphPanel.add(singleTimingGraphPanel); + timingGraphPanel.add(singleTimingGraphPanel); } - graphPanel.revalidate(); - graphPanel.repaint(); + timingGraphPanel.revalidate(); + timingGraphPanel.repaint(); + } + + /** + * Create the user panel. + * This displays cases open and users logged in + * @return the user panel + */ + @NbBundle.Messages({"HealthMonitorDashboard.createUserPanel.noData=No data to display - monitor is not enabled", + "HealthMonitorDashboard.createUserPanel.userMetricsTitle=User Metrics"}) + private JPanel createUserPanel() throws HealthMonitorException { + // If the monitor isn't enabled, just add a message + if(! EnterpriseHealthMonitor.monitorIsEnabled()) { + JPanel emptyUserMetricPanel = new JPanel(); + emptyUserMetricPanel.add(new JLabel(Bundle.HealthMonitorDashboard_createUserPanel_userMetricsTitle())); + emptyUserMetricPanel.add(new JLabel(" ")); + emptyUserMetricPanel.add(new JLabel(Bundle.HealthMonitorDashboard_createUserPanel_noData())); + + return emptyUserMetricPanel; + } + + JPanel userMetricPanel = new JPanel(); + userMetricPanel.setLayout(new BoxLayout(userMetricPanel, BoxLayout.PAGE_AXIS)); + userMetricPanel.setBorder(BorderFactory.createEtchedBorder()); + + // Add title + JLabel userMetricTitle = new JLabel(Bundle.HealthMonitorDashboard_createUserPanel_userMetricsTitle()); + userMetricPanel.add(userMetricTitle); + userMetricPanel.add(new JSeparator()); + + // Add the controls + userMetricPanel.add(createUserControlPanel()); + userMetricPanel.add(new JSeparator()); + + // Create panel to hold graphs + userGraphPanel = new JPanel(); + userGraphPanel.setLayout(new GridLayout(0,2)); + + // Update the graph panel, put it in a scroll pane, and add to the timing metric panel + updateUserMetricGraphs(); + JScrollPane scrollPane = new JScrollPane(userGraphPanel, JScrollPane.VERTICAL_SCROLLBAR_AS_NEEDED, JScrollPane.HORIZONTAL_SCROLLBAR_NEVER); + userMetricPanel.add(scrollPane); + userMetricPanel.revalidate(); + userMetricPanel.repaint(); + + return userMetricPanel; + } + + /** + * Create the panel with controls for the user panel + * @return the control panel + */ + @NbBundle.Messages({"HealthMonitorDashboard.createUserControlPanel.maxDays=Max days to display"}) + private JPanel createUserControlPanel() { + JPanel userControlPanel = new JPanel(); + + // If the monitor is not enabled, don't add any components + if(! EnterpriseHealthMonitor.monitorIsEnabled()) { + return userControlPanel; + } + + // Create the combo box for selecting how much data to display + String[] dateOptionStrings = Arrays.stream(DateRange.values()).map(e -> e.getLabel()).toArray(String[]::new); + userDateComboBox = new JComboBox<>(dateOptionStrings); + userDateComboBox.setSelectedItem(DateRange.ONE_DAY.getLabel()); + + // Set up the listener on the date combo box + userDateComboBox.addActionListener(new ActionListener() { + @Override + public void actionPerformed(ActionEvent arg0) { + try { + updateUserMetricGraphs(); + } catch (HealthMonitorException ex) { + logger.log(Level.SEVERE, "Error updating user metric panel", ex); + } + } + }); + + // Add the date range combo box and label to the panel + userControlPanel.add(new JLabel(Bundle.HealthMonitorDashboard_createUserControlPanel_maxDays())); + userControlPanel.add(userDateComboBox); + + return userControlPanel; + } + + /** + * Update the user graphs. + * @throws HealthMonitorException + */ + @NbBundle.Messages({"HealthMonitorDashboard.updateUserMetricGraphs.noData=No data to display"}) + private void updateUserMetricGraphs() throws HealthMonitorException { + + // Clear out any old graphs + userGraphPanel.removeAll(); + + if(userData.isEmpty()) { + // There are no user metrics in the database + userGraphPanel.add(new JLabel(Bundle.HealthMonitorDashboard_updateUserMetricGraphs_noData())); + return; + } + + // Calculate the minimum timestamp for the graph. + // Unlike the timing graphs, we do not filter the list of user metrics here. + // This is because even if we're only displaying one day, the + // last metric for a host may be that it logged on two days ago, so we would want + // to show that node as logged on. + long timestampThreshold; + if(userDateComboBox.getSelectedItem() != null) { + DateRange selectedDateRange = DateRange.fromLabel(userDateComboBox.getSelectedItem().toString()); + timestampThreshold = System.currentTimeMillis() - selectedDateRange.getTimestampRange(); + + } else { + timestampThreshold = System.currentTimeMillis() - DateRange.getMaximumTimestampRange(); + } + + // Generate the graphs + UserMetricGraphPanel caseGraphPanel = new UserMetricGraphPanel(userData, timestampThreshold, true); + caseGraphPanel.setPreferredSize(new Dimension(700,200)); + + UserMetricGraphPanel logonGraphPanel = new UserMetricGraphPanel(userData, timestampThreshold, false); + logonGraphPanel.setPreferredSize(new Dimension(700,200)); + + userGraphPanel.add(caseGraphPanel); + userGraphPanel.add(logonGraphPanel); + userGraphPanel.revalidate(); + userGraphPanel.repaint(); } /** * Create the admin panel. * This allows the health monitor to be enabled and disabled. - * @return + * @return the admin panel */ @NbBundle.Messages({"HealthMonitorDashboard.createAdminPanel.enableButton=Enable monitor", "HealthMonitorDashboard.createAdminPanel.disableButton=Disable monitor"}) diff --git a/Core/src/org/sleuthkit/autopsy/healthmonitor/Installer.java b/Core/src/org/sleuthkit/autopsy/healthmonitor/Installer.java index 61ea5a5244..187513a84a 100644 --- a/Core/src/org/sleuthkit/autopsy/healthmonitor/Installer.java +++ b/Core/src/org/sleuthkit/autopsy/healthmonitor/Installer.java @@ -50,4 +50,13 @@ public class Installer extends ModuleInstall { logger.log(Level.SEVERE, "Error starting health services monitor", ex); } } + + @Override + public void close() { + try { + EnterpriseHealthMonitor.shutdown(); + } catch (HealthMonitorException ex) { + logger.log(Level.SEVERE, "Error stopping health services monitor", ex); + } + } } \ No newline at end of file diff --git a/Core/src/org/sleuthkit/autopsy/healthmonitor/UserMetricGraphPanel.java b/Core/src/org/sleuthkit/autopsy/healthmonitor/UserMetricGraphPanel.java new file mode 100644 index 0000000000..d8da7afe97 --- /dev/null +++ b/Core/src/org/sleuthkit/autopsy/healthmonitor/UserMetricGraphPanel.java @@ -0,0 +1,455 @@ +/* + * Autopsy Forensic Browser + * + * Copyright 2018 Basis Technology Corp. + * Contact: carrier sleuthkit 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.healthmonitor; + +import java.awt.Color; +import java.awt.FontMetrics; +import java.awt.Graphics; +import java.awt.Graphics2D; +import java.awt.RenderingHints; +import java.util.Collections; +import java.util.Comparator; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.HashMap; +import java.util.HashSet; +import java.util.TreeSet; +import java.util.Calendar; +import java.util.GregorianCalendar; +import javax.swing.JPanel; +import java.util.TimeZone; +import java.util.concurrent.TimeUnit; +import org.openide.util.NbBundle; +import org.sleuthkit.autopsy.healthmonitor.EnterpriseHealthMonitor.UserData; + +/** + * Creates graphs using the given user metric data + */ +class UserMetricGraphPanel extends JPanel { + + private static final int padding = 25; + private static final int labelPadding = 25; + private final Color examinerColor = new Color(0x12, 0x20, 0xdb, 255); + private final Color autoIngestColor = new Color(0x12, 0x80, 0x20, 255); + private final Color gridColor = new Color(200, 200, 200, 200); + private static final int pointWidth = 4; + private static final int numberYDivisions = 10; + private final List dataToPlot; + private final String graphLabel; + private final long dataInterval; + private final long MILLISECONDS_PER_HOUR = 1000 * 60 * 60; + private final long MILLISECONDS_PER_DAY = MILLISECONDS_PER_HOUR * 24; + private final long maxTimestamp; + private final long minTimestamp; + private int maxCount; + private static final int minCount = 0; // The bottom of the graph will always be zero + + @NbBundle.Messages({"UserMetricGraphPanel.constructor.casesOpen=Cases open", + "UserMetricGraphPanel.constructor.loggedIn=Users logged in - examiner nodes in blue, auto ingest nodes in green" + }) + UserMetricGraphPanel(List userResults, long timestampThreshold, boolean plotCases) { + + maxTimestamp = System.currentTimeMillis(); + minTimestamp = timestampThreshold; + + // Make the label + if (plotCases) { + graphLabel = Bundle.UserMetricGraphPanel_constructor_casesOpen(); + } else { + graphLabel = Bundle.UserMetricGraphPanel_constructor_loggedIn(); + } + + // Comparator for the set of UserData objects + Comparator sortOnTimestamp = new Comparator() { + @Override + public int compare(UserData o1, UserData o2) { + return Long.compare(o1.getTimestamp(), o2.getTimestamp()); + } + }; + + // Create a map from host name to data and get the timestamp bounds. + // We're using TreeSets here because they support the floor function. + Map> userDataMap = new HashMap<>(); + for(UserData result:userResults) { + if(userDataMap.containsKey(result.getHostname())) { + userDataMap.get(result.getHostname()).add(result); + } else { + TreeSet resultTreeSet = new TreeSet<>(sortOnTimestamp); + resultTreeSet.add(result); + userDataMap.put(result.getHostname(), resultTreeSet); + } + } + + // Create a list of data points to plot + // The idea here is that starting at maxTimestamp, we go backwards in increments, + // see what the state of each node was at that time and make the counts of nodes + // that are logged in/ have a case open. + // A case is open if the last event was "case open"; closed otherwise + // A user is logged in if the last event was anything but "log out";logged out otherwise + dataToPlot = new ArrayList<>(); + dataInterval = MILLISECONDS_PER_HOUR; + maxCount = Integer.MIN_VALUE; + for (long timestamp = maxTimestamp;timestamp > minTimestamp;timestamp -= dataInterval) { + + // Collect both counts so that we can use the same scale in the open case graph and + // the logged in users graph + UserCount openCaseCount = new UserCount(timestamp); + UserCount loggedInUserCount = new UserCount(timestamp); + + Set openCaseNames = new HashSet<>(); + UserData timestampUserData = UserData.createDummyUserData(timestamp); + + for (String hostname:userDataMap.keySet()) { + // Get the most recent record before this timestamp + UserData lastRecord = userDataMap.get(hostname).floor(timestampUserData); + + if (lastRecord != null) { + + // Update the case count. + if (lastRecord.getEventType().caseIsOpen()) { + + // Only add each case once regardless of how many users have it open + if ( ! openCaseNames.contains(lastRecord.getCaseName())) { + + // Store everything as examiner nodes. The graph will represent + // the number of distinct cases open, not anything about the + // nodes that have them open. + openCaseCount.addExaminer(); + openCaseNames.add(lastRecord.getCaseName()); + } + } + + // Update the logged in user count + if (lastRecord.getEventType().userIsLoggedIn()) { + if(lastRecord.isExaminerNode()) { + loggedInUserCount.addExaminer(); + } else { + loggedInUserCount.addAutoIngestNode(); + } + } + } + } + + // Check if this is a new maximum. + // Assuming we log all the events, there should never be more cases open than + // there are logged in users, but it could happen if we lose data. + maxCount = Integer.max(maxCount, openCaseCount.getTotalNodeCount()); + maxCount = Integer.max(maxCount, loggedInUserCount.getTotalNodeCount()); + + // Add the count to be plotted + if(plotCases) { + dataToPlot.add(openCaseCount); + } else { + dataToPlot.add(loggedInUserCount); + } + } + } + + /** + * Setup of the graphics panel: + * Origin (0,0) is at the top left corner + * + * Horizontally (from the left): (padding)(label padding)(the graph)(padding) + * For plotting data on the x-axis, we scale it to the size of the graph and then add the padding and label padding + * + * Vertically (from the top): (padding)(the graph)(label padding)(padding) + * For plotting data on the y-axis, we subtract from the max value in the graph and then scale to the size of the graph + * @param g + */ + @Override + protected void paintComponent(Graphics g) { + super.paintComponent(g); + Graphics2D g2 = (Graphics2D) g; + g2.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON); + + // Get the max and min timestamps to create the x-axis. + // We add a small buffer to each side so the data won't overwrite the axes. + double maxValueOnXAxis = maxTimestamp + TimeUnit.HOURS.toMillis(2); // Two hour buffer (the last bar graph will take up one of the hours) + double minValueOnXAxis = minTimestamp - TimeUnit.HOURS.toMillis(1); // One hour buffer + + // Get the max and min times to create the y-axis + // To make the intervals even, make sure the maximum is a multiple of five + if((maxCount % 5) != 0) { + maxCount += (5 - (maxCount % 5)); + } + int maxValueOnYAxis = Integer.max(maxCount, 5); + int minValueOnYAxis = minCount; + + // The graph itself has the following corners: + // (padding + label padding, padding + font height) -> top left + // (padding + label padding, getHeight() - label padding - padding) -> bottom left + // (getWidth() - padding, padding + font height) -> top right + // (padding + label padding, getHeight() - label padding - padding) -> bottom right + int leftGraphPadding = padding + labelPadding; + int rightGraphPadding = padding; + int topGraphPadding = padding + g2.getFontMetrics().getHeight(); + int bottomGraphPadding = labelPadding; + + // Calculate the scale for each axis. + // The size of the graph area is the width/height of the panel minus any padding. + // The scale is calculated based on this size of the graph compared to the data range. + // For example: + // getWidth() = 575 => graph width = 500 + // If our max x value to plot is 10000 and our min is 0, then the xScale would be 0.05 - i.e., + // our original x values will be multipled by 0.05 to translate them to an x-coordinate in the + // graph (plus the padding) + int graphWidth = getWidth() - leftGraphPadding - rightGraphPadding; + int graphHeight = getHeight() - topGraphPadding - bottomGraphPadding; + double xScale = ((double) graphWidth) / (maxValueOnXAxis - minValueOnXAxis); + double yScale = ((double) graphHeight) / (maxValueOnYAxis - minValueOnYAxis); + + // Draw white background + g2.setColor(Color.WHITE); + g2.fillRect(leftGraphPadding, topGraphPadding, graphWidth, graphHeight); + + // Create hatch marks and grid lines for y axis. + int labelWidth; + int positionForMetricNameLabel = 0; + Map countToGraphPosition = new HashMap<>(); + for (int i = 0; i < numberYDivisions + 1; i++) { + int x0 = leftGraphPadding; + int x1 = pointWidth + leftGraphPadding; + int y0 = getHeight() - ((i * graphHeight) / numberYDivisions + bottomGraphPadding); + int y1 = y0; + + if ( ! dataToPlot.isEmpty()) { + // Draw the grid line + g2.setColor(gridColor); + g2.drawLine(leftGraphPadding + 1 + pointWidth, y0, getWidth() - rightGraphPadding, y1); + + // Create the label + g2.setColor(Color.BLACK); + double yValue = minValueOnYAxis + ((maxValueOnYAxis - minValueOnYAxis) * ((i * 1.0) / numberYDivisions)); + int intermediateLabelVal = (int) (yValue * 100); + if ((i == numberYDivisions) || ((intermediateLabelVal % 100) == 0)) { + countToGraphPosition.put(intermediateLabelVal / 100, y0); + String yLabel = Integer.toString(intermediateLabelVal / 100); + FontMetrics fontMetrics = g2.getFontMetrics(); + labelWidth = fontMetrics.stringWidth(yLabel); + g2.drawString(yLabel, x0 - labelWidth - 5, y0 + (fontMetrics.getHeight() / 2) - 3); + + // The nicest looking alignment for this label seems to be left-aligned with the top + // y-axis label. Save this position to be used to write the label later. + if (i == numberYDivisions) { + positionForMetricNameLabel = x0 - labelWidth - 5; + } + } + } + + // Draw the small hatch mark + g2.setColor(Color.BLACK); + g2.drawLine(x0, y0, x1, y1); + } + + // On the x-axis, the farthest right grid line should represent midnight preceding the last recorded value + Calendar maxDate = new GregorianCalendar(); + maxDate.setTimeInMillis(maxTimestamp); + maxDate.set(Calendar.HOUR_OF_DAY, 0); + maxDate.set(Calendar.MINUTE, 0); + maxDate.set(Calendar.SECOND, 0); + maxDate.set(Calendar.MILLISECOND, 0); + long maxMidnightInMillis = maxDate.getTimeInMillis(); + + // We don't want to display more than 20 grid lines. If we have more + // data then that, put multiple days within one division + long totalDays = (maxMidnightInMillis - (long)minValueOnXAxis) / MILLISECONDS_PER_DAY; + long daysPerDivision; + if(totalDays <= 20) { + daysPerDivision = 1; + } else { + daysPerDivision = (totalDays / 20); + if((totalDays % 20) != 0) { + daysPerDivision++; + } + } + + // Draw the vertical grid lines and labels + // The vertical grid lines will be at midnight, and display the date underneath them + // At present we use GMT because of some complications with daylight savings time. + for (long currentDivision = maxMidnightInMillis; currentDivision >= minValueOnXAxis; currentDivision -= MILLISECONDS_PER_DAY * daysPerDivision) { + + int x0 = (int) ((currentDivision - minValueOnXAxis) * xScale + leftGraphPadding); + int x1 = x0; + int y0 = getHeight() - bottomGraphPadding; + int y1 = y0 - pointWidth; + + // Draw the light grey grid line + g2.setColor(gridColor); + g2.drawLine(x0, getHeight() - bottomGraphPadding - 1 - pointWidth, x1, topGraphPadding); + + // Draw the hatch mark + g2.setColor(Color.BLACK); + g2.drawLine(x0, y0, x1, y1); + + // Draw the label + Calendar thisDate = new GregorianCalendar(); + thisDate.setTimeZone(TimeZone.getTimeZone("GMT")); // Stick with GMT to avoid daylight savings issues + thisDate.setTimeInMillis(currentDivision); + int month = thisDate.get(Calendar.MONTH) + 1; + int day = thisDate.get(Calendar.DAY_OF_MONTH); + + String xLabel = month + "/" + day; + FontMetrics metrics = g2.getFontMetrics(); + labelWidth = metrics.stringWidth(xLabel); + g2.drawString(xLabel, x0 - labelWidth / 2, y0 + metrics.getHeight() + 3); + } + + // Create x and y axes + g2.setColor(Color.BLACK); + g2.drawLine(leftGraphPadding, getHeight() - bottomGraphPadding, leftGraphPadding, topGraphPadding); + g2.drawLine(leftGraphPadding, getHeight() - bottomGraphPadding, getWidth() - rightGraphPadding, getHeight() - bottomGraphPadding); + + // Sort dataToPlot on timestamp + Collections.sort(dataToPlot, new Comparator(){ + @Override + public int compare(UserCount o1, UserCount o2){ + return Long.compare(o1.getTimestamp(), o2.getTimestamp()); + } + }); + + // Create the bars + for(int i = 0;i < dataToPlot.size();i++) { + UserCount userCount = dataToPlot.get(i); + int x = (int) ((userCount.getTimestamp() - minValueOnXAxis) * xScale + leftGraphPadding); + int yTopOfExaminerBox; + if(countToGraphPosition.containsKey(userCount.getTotalNodeCount())) { + // If we've drawn a grid line for this count, use the recorded value. If we don't do + // this, rounding differences lead to the bar graph not quite lining up with the existing grid. + yTopOfExaminerBox = countToGraphPosition.get(userCount.getTotalNodeCount()); + } else { + yTopOfExaminerBox = (int) ((maxValueOnYAxis - userCount.getTotalNodeCount()) * yScale + topGraphPadding); + } + + // Calculate the width. If this isn't the last column, set this to one less than + // the distance to the next column starting point. + int width; + if(i < dataToPlot.size() - 1) { + width = Integer.max((int)((dataToPlot.get(i + 1).getTimestamp() - minValueOnXAxis) * xScale + leftGraphPadding) - x - 1, + 1); + } else { + width = Integer.max((int)(dataInterval * xScale), 1); + } + + // The examiner bar goes all the way to the bottom of the graph. + // The bottom will be overwritten by the auto ingest bar for displaying + // logged in users. + int heightExaminerBox = (getHeight() - bottomGraphPadding) - yTopOfExaminerBox; + + // Plot the examiner bar + g2.setColor(examinerColor); + g2.fillRect(x, yTopOfExaminerBox, width, heightExaminerBox); + + // Check that there is an auto ingest node count before plotting its bar. + // For the cases open graph, this will always be empty. + if (userCount.getAutoIngestNodeCount() > 0) { + int yTopOfAutoIngestBox; + if(countToGraphPosition.containsKey(userCount.getAutoIngestNodeCount())) { + // As above, if we've drawn a grid line for this count, use the recorded value. If we don't do + // this, rounding differences lead to the bar graph not quite lining up with the existing grid. + yTopOfAutoIngestBox =countToGraphPosition.get(userCount.getAutoIngestNodeCount()); + } else { + yTopOfAutoIngestBox = yTopOfExaminerBox + heightExaminerBox; + } + int heightAutoIngestBox = (getHeight() - bottomGraphPadding) - yTopOfAutoIngestBox; + + // Plot the auto ingest bar + g2.setColor(autoIngestColor); + g2.fillRect(x, yTopOfAutoIngestBox, width, heightAutoIngestBox); + } + } + + // The graph lines may have extended up past the bounds of the graph. Overwrite that + // area with the original background color. + g2.setColor(this.getBackground()); + g2.fillRect(leftGraphPadding, 0, graphWidth, topGraphPadding); + + // Write the scale. Do this after we erase the top block of the graph. + g2.setColor(Color.BLACK); + String titleStr = graphLabel; + g2.drawString(titleStr, positionForMetricNameLabel, padding); + } + + /** + * Utility class to keep track of the data to be graphed. + * Used for tracking logging in users and open cases. + */ + private class UserCount { + private final long timestamp; + private int examinerCount; + private int autoIngestCount; + + /** + * Create a UserCount object with counts initialized to zero. + * @param timestamp + */ + UserCount(long timestamp) { + this.timestamp = timestamp; + this.examinerCount = 0; + this.autoIngestCount = 0; + } + + /** + * Add one examiner node to the count. + */ + void addExaminer() { + examinerCount++; + } + + /** + * Add one auto ingest node to the count. + */ + void addAutoIngestNode() { + autoIngestCount++; + } + + /** + * Get the number of examiner nodes. + * @return number of examiner nodes + */ + int getExaminerNodeCount() { + return examinerCount; + } + + /** + * Get the number of auto ingest nodes. + * @return number of auto ingest nodes + */ + int getAutoIngestNodeCount() { + return autoIngestCount; + } + + /** + * Get the total number of nodes + * @return the sum of the examiner and auto ingest nodes + */ + int getTotalNodeCount() { + return examinerCount + autoIngestCount; + } + + /** + * Get the timestamp for this metric + * @return the timestamp + */ + long getTimestamp() { + return timestamp; + } + } +} diff --git a/branding/core/core.jar/org/netbeans/core/startup/Bundle.properties b/branding/core/core.jar/org/netbeans/core/startup/Bundle.properties index 9a9a904cc5..b1adb5d40b 100644 --- a/branding/core/core.jar/org/netbeans/core/startup/Bundle.properties +++ b/branding/core/core.jar/org/netbeans/core/startup/Bundle.properties @@ -1,5 +1,5 @@ #Updated by build script -#Tue, 08 May 2018 10:29:55 -0600 +#Mon, 19 Mar 2018 11:17:11 -0700 LBL_splash_window_title=Starting Autopsy SPLASH_HEIGHT=314 SPLASH_WIDTH=538 @@ -8,4 +8,4 @@ SplashRunningTextBounds=0,289,538,18 SplashRunningTextColor=0x0 SplashRunningTextFontSize=19 -currentVersion=Autopsy 4.7.0 +currentVersion=Autopsy 4.6.0 diff --git a/branding/modules/org-netbeans-core-windows.jar/org/netbeans/core/windows/view/ui/Bundle.properties b/branding/modules/org-netbeans-core-windows.jar/org/netbeans/core/windows/view/ui/Bundle.properties index db3cd01af5..6cb9d4bdea 100644 --- a/branding/modules/org-netbeans-core-windows.jar/org/netbeans/core/windows/view/ui/Bundle.properties +++ b/branding/modules/org-netbeans-core-windows.jar/org/netbeans/core/windows/view/ui/Bundle.properties @@ -1,4 +1,4 @@ #Updated by build script -#Tue, 08 May 2018 10:29:55 -0600 -CTL_MainWindow_Title=Autopsy 4.7.0 -CTL_MainWindow_Title_No_Project=Autopsy 4.7.0 +#Fri, 09 Mar 2018 13:03:41 -0700 +CTL_MainWindow_Title=Autopsy 4.6.0 +CTL_MainWindow_Title_No_Project=Autopsy 4.6.0