/** * Copyright (C) 2001-2017 by RapidMiner and the contributors * * Complete list of developers available at our web site: * * http://rapidminer.com * * This program is free software: you can redistribute it and/or modify it under the terms of the * GNU Affero General Public License as published by the Free Software Foundation, either version 3 * of the License, or (at your option) any later version. * * This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without * even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU * Affero General Public License for more details. * * You should have received a copy of the GNU Affero General Public License along with this program. * If not, see http://www.gnu.org/licenses/. */ package com.rapidminer.gui.tools.logging; import java.awt.BorderLayout; import java.awt.Component; import java.awt.Dimension; import java.awt.Font; import java.awt.event.ActionEvent; import java.awt.event.ActionListener; import java.awt.event.MouseAdapter; import java.awt.event.MouseEvent; import java.awt.event.MouseListener; import java.io.File; import java.io.FileWriter; import java.io.IOException; import java.io.PrintWriter; import java.lang.reflect.InvocationTargetException; import java.util.LinkedList; import java.util.List; import java.util.NoSuchElementException; import java.util.Timer; import java.util.TimerTask; import java.util.logging.Level; import java.util.logging.LogRecord; import javax.swing.AbstractAction; import javax.swing.Action; import javax.swing.BorderFactory; import javax.swing.Box; import javax.swing.JButton; import javax.swing.JComponent; import javax.swing.JMenu; import javax.swing.JMenuItem; import javax.swing.JPanel; import javax.swing.JPopupMenu; import javax.swing.JScrollPane; import javax.swing.JTextPane; import javax.swing.JToggleButton; import javax.swing.JToolBar; import javax.swing.SwingUtilities; import javax.swing.text.BadLocationException; import com.rapidminer.Process; import com.rapidminer.gui.GeneralProcessListener; import com.rapidminer.gui.MainFrame; import com.rapidminer.gui.RapidMinerGUI; import com.rapidminer.gui.actions.ToggleAction; import com.rapidminer.gui.dialog.SearchDialog; import com.rapidminer.gui.dialog.SearchableJTextComponent; import com.rapidminer.gui.look.Colors; import com.rapidminer.gui.tools.ExtendedJScrollPane; import com.rapidminer.gui.tools.ExtendedJToolBar; import com.rapidminer.gui.tools.ExtendedStyledDocument; import com.rapidminer.gui.tools.ProgressThread; import com.rapidminer.gui.tools.ResourceAction; import com.rapidminer.gui.tools.ResourceActionAdapter; import com.rapidminer.gui.tools.ResourceDockKey; import com.rapidminer.gui.tools.ResourceMenu; import com.rapidminer.gui.tools.ScrollableJPopupMenu; import com.rapidminer.gui.tools.SwingTools; import com.rapidminer.gui.tools.components.DropDownPopupButton; import com.rapidminer.gui.tools.components.DropDownPopupButton.PopupMenuProvider; import com.rapidminer.gui.tools.logging.LogModel.LogMode; import com.rapidminer.gui.tools.logging.actions.ClearMessageAction; import com.rapidminer.gui.tools.logging.actions.LogCloseAction; import com.rapidminer.gui.tools.logging.actions.LogRefreshAction; import com.rapidminer.gui.tools.logging.actions.LogSearchAction; import com.rapidminer.gui.tools.logging.actions.SaveLogFileAction; import com.rapidminer.operator.Operator; import com.rapidminer.operator.ProcessRootOperator; import com.rapidminer.parameter.UndefinedParameterError; import com.rapidminer.tools.I18N; import com.rapidminer.tools.LogService; import com.rapidminer.tools.Observable; import com.rapidminer.tools.Observer; import com.rapidminer.tools.ParameterService; import com.rapidminer.tools.parameter.ParameterChangeListener; import com.vlsolutions.swing.docking.DockKey; import com.vlsolutions.swing.docking.Dockable; /** * A text area displaying log outputs. The model contains all opened log outputs. One model is * displayed. Since keeping all lines might dramatically increase memory usage and slow down * RapidMiner, only a maximum number of lines is displayed. * * @author Ingo Mierswa, Nils Woehler, Sabrina Kirstein, Marco Boeck */ public class LogViewer extends JPanel implements Dockable { /** * This is the menu for selecting the log level of the current log. */ private class LogLevelMenu extends ResourceMenu { private static final long serialVersionUID = 1L; public LogLevelMenu() { super("log_level"); for (final Level level : LogViewer.SELECTABLE_LEVELS) { JMenuItem item = new JMenuItem(new AbstractAction(level.getName()) { private static final long serialVersionUID = 1L; @Override public void actionPerformed(ActionEvent e) { new Thread(new Runnable() { @Override public void run() { // change the log level outside the EDT // no progress thread because the part that may take some time (the // GUI refresh by Swing) cannot be cancelled anyway setLogLevel(level); } }).start(); } }); // highlight current log level if (getLogSelectionModel().getCurrentLogModel() != null) { if (level.equals(getLogSelectionModel().getCurrentLogModel().getLogLevel())) { item.setFont(item.getFont().deriveFont(Font.BOLD)); } } add(item); } } } private static final long serialVersionUID = 1L; private static final Level[] SELECTABLE_LEVELS = { Level.ALL, Level.FINEST, Level.FINER, Level.FINE, Level.CONFIG, Level.INFO, Level.WARNING, Level.SEVERE, Level.OFF }; /** index of the default log level (INFO) */ public static final int DEFAULT_LEVEL_INDEX = 5; /** human readable names of the available log levels */ public static final String[] SELECTABLE_LEVEL_NAMES = new String[SELECTABLE_LEVELS.length]; static { for (int i = 0; i < SELECTABLE_LEVELS.length; i++) { SELECTABLE_LEVEL_NAMES[i] = SELECTABLE_LEVELS[i].getName(); } } /** default number of log view entries before old ones get discarded for new ones */ public static final int DEFAULT_LOG_ENTRY_NUMBER = 1000; /** the initial delay of the batch append timer */ private static final int BATCH_APPEND_TIMER_DELAY = 500; /** the interval in which the batch append timer triggers */ private static final int BATCH_APPEND_TIMER_INTERVAL = 500; public static final String LOG_VIEWER_DOCK_KEY = "log_viewer"; private final DockKey DOCK_KEY = new ResourceDockKey(LOG_VIEWER_DOCK_KEY); { DOCK_KEY.setDockGroup(MainFrame.DOCK_GROUP_ROOT); } @SuppressWarnings("unused") private final GeneralProcessListener PROCESS_CHANGE_LISTENER; private final ToggleAction TOGGLE_CLEAR_ON_START_ACTION = new ToggleAction(true, "clear_on_start") { private static final long serialVersionUID = 1L; @Override public void actionToggled(ActionEvent e) { // do nothing, state is queried by PROCESS_CHANGE_LISTENER } }; /** the model which contains the log selection */ private final LogSelectionModel selectionModel; private final Action CLEAR_MESSAGE_VIEWER_ACTION = new ClearMessageAction(this); private final Action SAVE_LOGFILE_ACTION = new SaveLogFileAction(this); private final ResourceAction SEARCH_ACTION = new LogSearchAction(this); private final Action REFRESH_ACTION = new LogRefreshAction(this); private final Action CLOSE_ACTION = new LogCloseAction(this); /** remembers the length of each line in the current log */ private final LinkedList<Integer> lineLengths = new LinkedList<>(); private final JTextPane textPane; private final ExtendedStyledDocument logStyledDocument; private final JToolBar toolBar; private final JButton closeButton; /** maximum number of rows to display in the log (oldest ones will be discarded for new ones) */ private int maxRows; /** the timer that periodically checks whether a new batch append has to be triggered */ private final Timer logAppendTimer; /** indicates if currently a batch update is in progress */ private volatile boolean batchUpdateRunning; /** * * * /** Creates the {@link LogViewer} instance for the {@link MainFrame}. * * @param mainFrame */ public LogViewer(MainFrame mainFrame) { super(new BorderLayout()); // set maximum number of rows to display in the log before oldest entries are discarded this.maxRows = DEFAULT_LOG_ENTRY_NUMBER; try { String maxRowsString = ParameterService .getParameterValue(MainFrame.PROPERTY_RAPIDMINER_GUI_MESSAGEVIEWER_ROWLIMIT); if (maxRowsString != null) { maxRows = Integer.parseInt(maxRowsString); } } catch (NumberFormatException e) { LogService.getRoot().log(Level.WARNING, "com.rapidminer.gui.tools.LoggingViewer.bad_integer_format_for_property"); } // listen for changes to the maxRows limit at runtime ParameterService.registerParameterChangeListener(new ParameterChangeListener() { @Override public void informParameterSaved() { // don't care } @Override public void informParameterChanged(String key, String value) { if (MainFrame.PROPERTY_RAPIDMINER_GUI_MESSAGEVIEWER_ROWLIMIT.equals(key)) { try { if (value != null) { maxRows = Integer.parseInt(value); logStyledDocument.setMaxRows(maxRows); } } catch (NumberFormatException e) { LogService.getRoot().log(Level.WARNING, "com.rapidminer.gui.tools.LoggingViewer.bad_integer_format_for_property"); } } } }); selectionModel = new LogSelectionModel(); logStyledDocument = new ExtendedStyledDocument(maxRows); textPane = new JTextPane(logStyledDocument); textPane.setBackground(Colors.PANEL_BACKGROUND); textPane.setEditable(false); toolBar = new ExtendedJToolBar(true); toolBar.setBorder(BorderFactory.createMatteBorder(0, 0, 1, 0, Colors.TAB_BORDER)); JToggleButton clearOnStartToggleButton = TOGGLE_CLEAR_ON_START_ACTION.createToggleButton(); clearOnStartToggleButton.setText(""); toolBar.add(CLEAR_MESSAGE_VIEWER_ACTION); toolBar.add(SEARCH_ACTION); toolBar.add(clearOnStartToggleButton); toolBar.add(Box.createHorizontalGlue()); closeButton = toolBar.add(CLOSE_ACTION); JButton button = makeDropDownButton(); toolBar.add(button); add(toolBar, BorderLayout.NORTH); // allow search action to be shown via Ctrl+F SEARCH_ACTION.addToActionMap(textPane, JComponent.WHEN_FOCUSED); // prepare toolbar for currently selected log if (getLogSelectionModel().getCurrentLogModel() == null) { // this is done via index because we only have actions, not components closeButton.setVisible(false); } else { // only show close button if log is closable closeButton.setVisible(getLogSelectionModel().getCurrentLogModel().isClosable()); } JScrollPane scrollPane = new ExtendedJScrollPane(textPane); scrollPane.setBorder(null); scrollPane.setVerticalScrollBarPolicy(JScrollPane.VERTICAL_SCROLLBAR_ALWAYS); add(scrollPane, BorderLayout.CENTER); PROCESS_CHANGE_LISTENER = new GeneralProcessListener(mainFrame) { @Override public void processStarts(Process process) { if (TOGGLE_CLEAR_ON_START_ACTION.isEnabled() && TOGGLE_CLEAR_ON_START_ACTION.isSelected()) { RapidMinerGUI.getDefaultLogModel().clearLog(); } } @Override public void processStartedOperator(Process process, Operator op) { // noop } @Override public void processFinishedOperator(Process process, Operator op) { // noop } @Override public void processEnded(Process process) { // noop } }; // add mouse listener for popup menu MouseListener mouseListener = new MouseAdapter() { @Override public void mouseReleased(MouseEvent e) { evaluatePopup(e); } @Override public void mousePressed(MouseEvent e) { evaluatePopup(e); } private void evaluatePopup(MouseEvent e) { if (e.isPopupTrigger()) { createPopupMenu().show(textPane, e.getX(), e.getY()); } } }; addMouseListener(mouseListener); this.textPane.addMouseListener(mouseListener); // add observer so we are notified when current log model updates final Observer<List<LogEntry>> currentModelObserver = new Observer<List<LogEntry>>() { @Override public void update(Observable<List<LogEntry>> observable, List<LogEntry> newEntries) { // discard all events which are NOT from the currently selected model if (observable != LogViewer.this.getLogSelectionModel().getCurrentLogModel()) { return; } // if the event is from the current model, process it if (newEntries == null) { // model has been cleared clearLogArea(); } else { Level currentLogLevel = getLogSelectionModel().getCurrentLogModel().getLogLevel(); for (LogEntry entry : newEntries) { // skip entries whose level is less than the currently selected one if (entry.getLogLevel() != null && currentLogLevel != null && entry.getLogLevel().intValue() < currentLogLevel.intValue()) { return; } logStyledDocument.appendLineForBatch(entry.getFormattedString(), entry.getSimpleAttributeSet()); } } } }; // register as listener to current log model if it is a PUSH log LogModel currentModel = getLogSelectionModel().getCurrentLogModel(); if (currentModel != null && currentModel.getLogMode() == LogMode.PUSH) { currentModel.addObserver(currentModelObserver, false); } // add observer so we are notified when selected model changes Observer<LogModel> selectionObserver = new Observer<LogModel>() { @Override public void update(Observable<LogModel> observable, LogModel currentModel) { // make sure that we register to all current PUSH models as observer if (currentModel.getLogMode() == LogMode.PUSH) { try { currentModel.removeObserver(currentModelObserver); } catch (NoSuchElementException e) { // ignore this exception as it serves no purpose and we cannot check if we // already registered } currentModel.addObserver(currentModelObserver, false); } // discard potential batch update entries once we switch logs. // they will be displayed anyway when we switch back to their log logStyledDocument.clearBatch(); // show close button only for closable logs closeButton.setVisible(currentModel.isClosable()); // replace with new log batchFill(currentModel.getLogEntries()); } }; getLogSelectionModel().addObserver(selectionObserver, true); // create and start batch update timer logAppendTimer = new Timer(true); logAppendTimer.schedule(new TimerTask() { @Override public void run() { if (!batchUpdateRunning) { // only do the batch update if the log is visible to the user and there is // something to update if (textPane.isShowing() && logStyledDocument.getBatchSize() > 0) { try { // prevent multiple parallel executions of this task batchUpdateRunning = true; // apply batch and store length of each added row List<Integer> addedRowLengths = logStyledDocument.executeBatchAppend(); lineLengths.addAll(addedRowLengths); // worstcase scenario, we now have 2*maxRows rows. Remove them. // Inserting and removing (as we append but remove from the top) will // always be two calls trimLog(); } catch (Exception e1) { // should not happen (but catch all to keep the timer going) // rather dump to stderr than logging and having this method called back e1.printStackTrace(); } finally { // always reset this flag batchUpdateRunning = false; } } } } }, BATCH_APPEND_TIMER_DELAY, BATCH_APPEND_TIMER_INTERVAL); } /** * Returns the complete log which is currently displayed. * * @return */ public String getLogAsText() { return textPane.getText(); } /** * Clears the currently selected log model and thus the displayed log. */ public void clearLog() { if (getLogSelectionModel().getCurrentLogModel() == null) { return; } getLogSelectionModel().getCurrentLogModel().clearLog(); // PULL logs don't update themselves, so we update right now if (getLogSelectionModel().getCurrentLogModel().getLogMode() == LogMode.PULL) { clearLogArea(); } } /** * Opens a save dialog to save the currently displayed log into a file. */ public void saveLog() { File file = new File("." + File.separator); String logFile = null; try { logFile = RapidMinerGUI.getMainFrame().getProcess().getRootOperator() .getParameterAsString(ProcessRootOperator.PARAMETER_LOGFILE); } catch (UndefinedParameterError ex) { // tries to use process file name for initialization } if (logFile != null) { file = RapidMinerGUI.getMainFrame().getProcess().resolveFileName(logFile); } file = SwingTools.chooseFile(RapidMinerGUI.getMainFrame(), file, false, "log", "log file"); if (file != null) { try (PrintWriter out = new PrintWriter(new FileWriter(file))) { out.println(textPane.getText()); } catch (IOException ex) { SwingTools.showSimpleErrorMessage("cannot_write_log_file", ex); } } } /** * Opens a search dialog for the currently displayed log. */ public void performSearch() { new SearchDialog(textPane, new SearchableJTextComponent(textPane)).setVisible(true); } /** * Requests a refresh from a {@link LogMode#PULL} model. Executed in a ProgressThread. Updates * the GUI automatically afterwards. */ public void performRefresh() { final LogModel currentModel = getLogSelectionModel().getCurrentLogModel(); if (currentModel == null) { return; } // only needed for PULL logs if (currentModel.getLogMode() == LogMode.PULL) { final ProgressThread requestRefresh = new ProgressThread("request_log_refresh", false, currentModel.getName()) { @Override public void run() { getProgressListener().setTotal(100); try { currentModel.updateEntries(getProgressListener()); } catch (final LogUpdateException e) { // update failed, add error to current log view if (getLogSelectionModel().getCurrentLogModel().equals(currentModel)) { LogRecordEntry errorEntry = new LogRecordEntry( new LogRecord(Level.WARNING, I18N.getGUIMessage("gui.logging.error.update.label"))); logStyledDocument.appendLineForBatch(errorEntry.getFormattedString(), errorEntry.getSimpleAttributeSet()); } return; } getProgressListener().complete(); // is the user after the update has completed still displaying the same log? If // so, update it. Otherwise we can ignore this as it will be visually updated // when switching to it anyway if (getLogSelectionModel().getCurrentLogModel().equals(currentModel)) { batchFill(currentModel.getLogEntries()); } } }; requestRefresh.start(); } } /** * Set the log level of the currently selected {@link LogModel}. * * @param level */ private void setLogLevel(Level level) { if (getLogSelectionModel().getCurrentLogModel() == null) { return; } getLogSelectionModel().getCurrentLogModel().setLogLevel(level); batchFill(getLogSelectionModel().getCurrentLogModel().getLogEntries()); // only do this for our own RapidMiner Studio log if (getLogSelectionModel().getCurrentLogModel().equals(RapidMinerGUI.getDefaultLogModel())) { LogService.getRoot().setLevel(level); ParameterService.setParameterValue(MainFrame.PROPERTY_RAPIDMINER_GUI_LOG_LEVEL, level.getName()); ParameterService.saveParameters(); } } @Override public Component getComponent() { return this; } @Override public DockKey getDockKey() { return DOCK_KEY; } /** * Returns the model which backs the available logs. Can be used to change the currently * selected log. * * @return */ public LogSelectionModel getLogSelectionModel() { return selectionModel; } /** * Creates a {@link DropDownPopupButton} button which will show the available log entries to * allow the user to select the currently displayed one. * * @return */ private DropDownPopupButton makeDropDownButton() { // init dialog so listener is registered, otherwise starting a comic via // the popup will not // be registered by the GUI PopupMenuProvider menuProvider = new PopupMenuProvider() { @Override public JPopupMenu getPopupMenu() { JPopupMenu menu = new ScrollableJPopupMenu(ScrollableJPopupMenu.SIZE_NORMAL); for (JMenuItem item : createItems()) { menu.add(item); } return menu; } /** * Creates a list of menu items, one for each available log. * * @return */ private List<JMenuItem> createItems() { List<JMenuItem> list = new LinkedList<>(); for (final LogModel logmodel : LogModelRegistry.INSTANCE.getRegisteredObjects()) { final JMenuItem item = new JMenuItem(); item.setText(logmodel.getName()); if (logmodel.getIcon() != null) { item.setIcon(logmodel.getIcon()); } item.addActionListener(new ActionListener() { @Override public void actionPerformed(final ActionEvent e) { LogViewer.this.getLogSelectionModel().setSelectedLogModel(logmodel); } }); list.add(item); // highlight item for currently selected log if (logmodel.equals(getLogSelectionModel().getCurrentLogModel())) { item.setFont(item.getFont().deriveFont(Font.BOLD)); } } return list; } }; final DropDownPopupButton dropDownToReturn = new DropDownPopupButton( new ResourceActionAdapter(true, "logging.selection"), menuProvider); dropDownToReturn.setMaximumSize(new Dimension(50, 30)); return dropDownToReturn; } /** * Creates the log level menu for the current log. * * @return */ private JMenu makeLogLevelMenu() { return new LogLevelMenu(); } /** * Creates the popup menu for the log text area. * * @return */ private JPopupMenu createPopupMenu() { JPopupMenu menu = new JPopupMenu(); LogModel currentModel = getLogSelectionModel().getCurrentLogModel(); // Studio log can be reset on each process start if (currentModel != null && currentModel.equals(RapidMinerGUI.getDefaultLogModel())) { menu.add(TOGGLE_CLEAR_ON_START_ACTION.createMenuItem()); } // pull logs cannot be cleared if (currentModel != null && currentModel.getLogMode() != LogMode.PULL) { menu.add(CLEAR_MESSAGE_VIEWER_ACTION); } else { menu.add(CLOSE_ACTION); } menu.add(SAVE_LOGFILE_ACTION); menu.add(SEARCH_ACTION); // pull logs can be manually refreshed if (currentModel != null && currentModel.getLogMode() == LogMode.PULL) { menu.add(REFRESH_ACTION); } menu.addSeparator(); menu.add(makeLogLevelMenu()); return menu; } /** * Trims the log to match the maxRows setting (if set) and moves the caret to the end of the * document. * */ private void trimLog() { // if trimming is enabled if (maxRows > 0) { int removeLength = 0; // find out how much of the beginning of the document (first n rows) we have to remove while (lineLengths.size() > maxRows) { removeLength += lineLengths.removeFirst(); } // if we have to remove something do it now if (removeLength > 0) { try { logStyledDocument.remove(0, removeLength); } catch (BadLocationException e) { SwingTools.showSimpleErrorMessage("error_during_logging", e); } } } try { SwingUtilities.invokeAndWait(new Runnable() { @Override public void run() { textPane.setCaretPosition(logStyledDocument.getLength()); } }); } catch (InvocationTargetException | InterruptedException e) { // not important, can be ignored } } /** * Fills the log text area with the specified {@link LogEntry}s. Will not add more than maxRows * (if set). If the minimum log level of the current log is higher than that of an entry, it is * not added. * * @param entries */ private void batchFill(List<LogEntry> entries) { try { // prevent the timer from triggering an update mid-batch batchUpdateRunning = true; // should not happen but just in case if (getLogSelectionModel().getCurrentLogModel() == null) { batchUpdateRunning = false; return; } // clear old contents clearLogArea(); Level currentLogLevel = getLogSelectionModel().getCurrentLogModel().getLogLevel(); for (int counter = 0; counter < entries.size(); counter++) { int index = counter; // check if we are allowed to add this row or if we run into the row limit (if set) if (maxRows > 0) { if (counter >= maxRows) { break; } } if (entries.size() > maxRows) { // calculate correct index in case row limit is smaller than entries.size // start at the end of the array index = entries.size() - maxRows + counter; } LogEntry entry = entries.get(index); // skip entries whose level is less than the currently selected one if (entry.getLogLevel() != null && currentLogLevel != null && entry.getLogLevel().intValue() < currentLogLevel.intValue()) { continue; } // add entry to batch logStyledDocument.appendLineForBatch(entry.getFormattedString(), entry.getSimpleAttributeSet()); } // if we actually have a batch now, execute it if (logStyledDocument.getBatchSize() > 0) { try { List<Integer> addedRowLengths = logStyledDocument.executeBatchAppend(); lineLengths.addAll(addedRowLengths); } catch (BadLocationException e) { // cannot happen // rather dump to stderr than logging and having this method called back e.printStackTrace(); } } // set caret to end position SwingUtilities.invokeLater(new Runnable() { @Override public void run() { textPane.setCaretPosition(logStyledDocument.getLength()); } }); } finally { // always reset this flag batchUpdateRunning = false; } } /** * Clears the text area displaying the current log. As such, the log batch is also reset. */ private void clearLogArea() { try { logStyledDocument.remove(0, logStyledDocument.getLength()); } catch (BadLocationException e) { // should not happen, fallback: try { SwingUtilities.invokeAndWait(new Runnable() { @Override public void run() { textPane.setText(""); } }); } catch (InterruptedException | InvocationTargetException e1) { // should not happen // rather dumpt to stderr instead of logging e1.printStackTrace(); } } lineLengths.clear(); logStyledDocument.clearBatch(); } }