View Javadoc

    1   /*
    2    * Licensed to the Apache Software Foundation (ASF) under one or more
    3    * contributor license agreements.  See the NOTICE file distributed with
    4    * this work for additional information regarding copyright ownership.
    5    * The ASF licenses this file to You under the Apache License, Version 2.0
    6    * (the "License"); you may not use this file except in compliance with
    7    * the License.  You may obtain a copy of the License at
    8    *
    9    *      http://www.apache.org/licenses/LICENSE-2.0
    10   *
    11   * Unless required by applicable law or agreed to in writing, software
    12   * distributed under the License is distributed on an "AS IS" BASIS,
    13   * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
    14   * See the License for the specific language governing permissions and
    15   * limitations under the License.
    16   */
    17  package org.apache.log4j.chainsaw.zeroconf;
    18
    19  import com.thoughtworks.xstream.XStream;
    20  import com.thoughtworks.xstream.io.xml.DomDriver;
    21  import org.apache.log4j.BasicConfigurator;
    22  import org.apache.log4j.LogManager;
    23  import org.apache.log4j.Logger;
    24  import org.apache.log4j.chainsaw.ChainsawConstants;
    25  import org.apache.log4j.chainsaw.LogFilePatternLayoutBuilder;
    26  import org.apache.log4j.chainsaw.SmallButton;
    27  import org.apache.log4j.chainsaw.help.HelpManager;
    28  import org.apache.log4j.chainsaw.icons.ChainsawIcons;
    29  import org.apache.log4j.chainsaw.plugins.GUIPluginSkeleton;
    30  import org.apache.log4j.chainsaw.prefs.SettingsManager;
    31  import org.apache.log4j.chainsaw.vfs.VFSLogFilePatternReceiver;
    32  import org.apache.log4j.helpers.LogLog;
    33  import org.apache.log4j.net.*;
    34  import org.apache.log4j.plugins.Plugin;
    35  import org.apache.log4j.plugins.PluginEvent;
    36  import org.apache.log4j.plugins.PluginListener;
    37  import org.apache.log4j.plugins.Receiver;
    38  import org.apache.log4j.spi.LoggerRepositoryEx;
    39
    40  import javax.jmdns.JmDNS;
    41  import javax.jmdns.ServiceEvent;
    42  import javax.jmdns.ServiceInfo;
    43  import javax.jmdns.ServiceListener;
    44  import javax.swing.*;
    45  import java.awt.*;
    46  import java.awt.event.ActionEvent;
    47  import java.awt.event.MouseAdapter;
    48  import java.awt.event.MouseEvent;
    49  import java.io.File;
    50  import java.io.FileReader;
    51  import java.io.FileWriter;
    52  import java.util.*;
    53
    54  /**
    55   * This plugin is designed to detect specific Zeroconf zones (Rendevouz/Bonjour,
    56   * whatever people are calling it) and allow the user to double click on
    57   * 'devices' to try and connect to them with no configuration needed.
    58   * <p>
    59   * TODO need to handle
    60   * NON-log4j devices that may be broadcast in the interested zones
    61   * TODO add the
    62   * default Zone, and the list of user-specified zones to a preferenceModel
    63   *
    64   * @author psmith
    65   */
    66  public class ZeroConfPlugin extends GUIPluginSkeleton {
    67
    68      private static final Logger LOG = Logger.getLogger(ZeroConfPlugin.class);
    69
    70      private ZeroConfDeviceModel discoveredDevices = new ZeroConfDeviceModel();
    71
    72      private JTable deviceTable = new JTable(discoveredDevices);
    73
    74      private final JScrollPane scrollPane = new JScrollPane(deviceTable);
    75
    76      private ZeroConfPreferenceModel preferenceModel;
    77
    78      private final Map<ServiceInfo, Plugin> serviceInfoToReceiveMap = new HashMap<>();
    79
    80      private JMenu connectToMenu = new JMenu("Connect to");
    81      private JMenuItem helpItem = new JMenuItem(new AbstractAction("Learn more about ZeroConf...",
    82          ChainsawIcons.ICON_HELP) {
    83
    84          public void actionPerformed(ActionEvent e) {
    85              HelpManager.getInstance()
    86                  .showHelpForClass(ZeroConfPlugin.class);
    87          }
    88      });
    89
    90      private JMenuItem nothingToConnectTo = new JMenuItem("No devices discovered");
    91      private static final String MULTICAST_APPENDER_SERVICE_NAME = "_log4j_xml_mcast_appender.local.";
    92      private static final String UDP_APPENDER_SERVICE_NAME = "_log4j_xml_udp_appender.local.";
    93      private static final String XML_SOCKET_APPENDER_SERVICE_NAME = "_log4j_xml_tcpconnect_appender.local.";
    94      private static final String TCP_APPENDER_SERVICE_NAME = "_log4j._tcp.local.";
    95      private static final String NEW_UDP_APPENDER_SERVICE_NAME = "_log4j._udp.local.";
    96
    97      private JmDNS jmDNS;
    98
    99      public ZeroConfPlugin() {
    100         setName("Zeroconf");
    101         deviceTable.setRowHeight(ChainsawConstants.DEFAULT_ROW_HEIGHT);
    102     }
    103
    104     public void shutdown() {
    105         if (jmDNS != null) {
    106             try {
    107                 jmDNS.close();
    108             } catch (Exception e) {
    109                 LOG.error("Unable to close JMDNS", e);
    110             }
    111         }
    112         save();
    113     }
    114
    115     private void save() {
    116         File fileLocation = getPreferenceFileLocation();
    117         XStream stream = new XStream(new DomDriver());
    118         try {
    119             stream.toXML(preferenceModel, new FileWriter(fileLocation));
    120         } catch (Exception e) {
    121             LOG.error("Failed to save ZeroConfPlugin configuration file", e);
    122         }
    123     }
    124
    125     private File getPreferenceFileLocation() {
    126         return new File(SettingsManager.getInstance().getSettingsDirectory(), "zeroconfprefs.xml");
    127     }
    128
    129     public void activateOptions() {
    130         setLayout(new BorderLayout());
    131         jmDNS = (JmDNS) ZeroConfSupport.getJMDNSInstance();
    132
    133         registerServiceListenersForAppenders();
    134
    135         deviceTable.addMouseListener(new ConnectorMouseListener());
    136
    137
    138         JToolBar toolbar = new JToolBar();
    139         SmallButton helpButton = new SmallButton(helpItem.getAction());
    140         helpButton.setText(helpItem.getText());
    141         toolbar.add(helpButton);
    142         toolbar.setFloatable(false);
    143         add(toolbar, BorderLayout.NORTH);
    144         add(scrollPane, BorderLayout.CENTER);
    145
    146         injectMenu();
    147
    148         ((LoggerRepositoryEx) LogManager.getLoggerRepository()).getPluginRegistry().addPluginListener(new PluginListener() {
    149
    150             public void pluginStarted(PluginEvent e) {
    151
    152             }
    153
    154             public void pluginStopped(PluginEvent e) {
    155                 Plugin plugin = e.getPlugin();
    156                 synchronized (serviceInfoToReceiveMap) {
    157                     for (Iterator<Map.Entry<ServiceInfo, Plugin>> iter = serviceInfoToReceiveMap.entrySet().iterator(); iter.hasNext(); ) {
    158                         Map.Entry<ServiceInfo, Plugin> entry = iter.next();
    159                         if (entry.getValue() == plugin) {
    160                             iter.remove();
    161                         }
    162                     }
    163                 }
    164 //                 need to make sure that the menu item tracking this item has it's icon and enabled state updade
    165                 discoveredDevices.fireTableDataChanged();
    166             }
    167         });
    168
    169         File fileLocation = getPreferenceFileLocation();
    170         XStream stream = new XStream(new DomDriver());
    171         if (fileLocation.exists()) {
    172             try {
    173                 this.preferenceModel = (ZeroConfPreferenceModel) stream
    174                     .fromXML(new FileReader(fileLocation));
    175             } catch (Exception e) {
    176                 LOG.error("Failed to load ZeroConfPlugin configuration file", e);
    177             }
    178         } else {
    179             this.preferenceModel = new ZeroConfPreferenceModel();
    180         }
    181         discoveredDevices.setZeroConfPreferenceModel(preferenceModel);
    182         discoveredDevices.setZeroConfPluginParent(this);
    183     }
    184
    185     private void registerServiceListenersForAppenders() {
    186         Set<String> serviceNames = new HashSet<>();
    187         serviceNames.add(MULTICAST_APPENDER_SERVICE_NAME);
    188         serviceNames.add(UDP_APPENDER_SERVICE_NAME);
    189         serviceNames.add(XML_SOCKET_APPENDER_SERVICE_NAME);
    190         serviceNames.add(TCP_APPENDER_SERVICE_NAME);
    191         serviceNames.add(NEW_UDP_APPENDER_SERVICE_NAME);
    192
    193         for (Object serviceName1 : serviceNames) {
    194             String serviceName = serviceName1.toString();
    195             jmDNS.addServiceListener(
    196                 serviceName,
    197                 new ZeroConfServiceListener());
    198
    199             jmDNS.addServiceListener(serviceName, discoveredDevices);
    200         }
    201
    202         //now add each appender constant
    203     }
    204
    205     /**
    206      * Attempts to find a JFrame container as a parent,and addse a "Connect to" menu
    207      */
    208     private void injectMenu() {
    209
    210         JFrame frame = (JFrame) SwingUtilities.getWindowAncestor(this);
    211         if (frame == null) {
    212             LOG.info("Could not locate parent JFrame to add menu to");
    213         } else {
    214             JMenuBar menuBar = frame.getJMenuBar();
    215             if (menuBar == null) {
    216                 menuBar = new JMenuBar();
    217                 frame.setJMenuBar(menuBar);
    218             }
    219             insertToLeftOfHelp(menuBar, connectToMenu);
    220             connectToMenu.add(nothingToConnectTo);
    221
    222             discoveredDevices.addTableModelListener(e -> {
    223                 if (discoveredDevices.getRowCount() == 0) {
    224                     connectToMenu.add(nothingToConnectTo, 0);
    225                 } else if (discoveredDevices.getRowCount() > 0) {
    226                     connectToMenu.remove(nothingToConnectTo);
    227                 }
    228
    229             });
    230
    231             nothingToConnectTo.setEnabled(false);
    232
    233             connectToMenu.addSeparator();
    234             connectToMenu.add(helpItem);
    235         }
    236     }
    237
    238     /**
    239      * Hack method to locate the JMenu that is the Help menu, and inserts the new menu
    240      * just to the left of it.
    241      *
    242      * @param menuBar
    243      * @param item
    244      */
    245     private void insertToLeftOfHelp(JMenuBar menuBar, JMenu item) {
    246         for (int i = 0; i < menuBar.getMenuCount(); i++) {
    247             JMenu menu = menuBar.getMenu(i);
    248             if (menu.getText().equalsIgnoreCase("help")) {
    249                 menuBar.add(item, i - 1);
    250             }
    251         }
    252         LOG.warn("menu '" + item.getText() + "' was NOT added because the 'Help' menu could not be located");
    253     }
    254
    255     /**
    256      * When a device is discovered, we create a menu item for it so it can be connected to via that
    257      * GUI mechanism, and also if the device is one of the auto-connect devices then a background thread
    258      * is created to connect the device.
    259      *
    260      * @param info
    261      */
    262     private void deviceDiscovered(final ServiceInfo info) {
    263         final String name = info.getName();
    264 //        TODO currently adding ALL devices to autoConnectlist
    265 //        preferenceModel.addAutoConnectDevice(name);
    266
    267
    268         JMenuItem connectToDeviceMenuItem = new JMenuItem(new AbstractAction(info.getName()) {
    269
    270             public void actionPerformed(ActionEvent e) {
    271                 connectTo(info);
    272             }
    273         });
    274
    275         if (discoveredDevices.getRowCount() > 0) {
    276             Component[] menuComponents = connectToMenu.getMenuComponents();
    277             boolean located = false;
    278             for (int i = 0; i < menuComponents.length; i++) {
    279                 Component c = menuComponents[i];
    280                 if (!(c instanceof JPopupMenu.Separator)) {
    281                     JMenuItem item = (JMenuItem) menuComponents[i];
    282                     if (item.getText().compareToIgnoreCase(name) < 0) {
    283                         connectToMenu.insert(connectToDeviceMenuItem, i);
    284                         located = true;
    285                         break;
    286                     }
    287                 }
    288             }
    289             if (!located) {
    290                 connectToMenu.insert(connectToDeviceMenuItem, 0);
    291             }
    292         } else {
    293             connectToMenu.insert(connectToDeviceMenuItem, 0);
    294         }
    295 //         if the device name is one of the autoconnect devices, then connect immediately
    296         if (preferenceModel != null && preferenceModel.getAutoConnectDevices() != null && preferenceModel.getAutoConnectDevices().contains(name)) {
    297             new Thread(() -> {
    298                 LOG.info("Auto-connecting to " + name);
    299                 connectTo(info);
    300             }).start();
    301         }
    302     }
    303
    304     /**
    305      * When a device is removed or disappears we need to remove any JMenu item associated with it.
    306      *
    307      * @param name
    308      */
    309     private void deviceRemoved(String name) {
    310         Component[] menuComponents = connectToMenu.getMenuComponents();
    311         for (Component c : menuComponents) {
    312             if (!(c instanceof JPopupMenu.Separator)) {
    313                 JMenuItem item = (JMenuItem) c;
    314                 if (item.getText().compareToIgnoreCase(name) == 0) {
    315                     connectToMenu.remove(item);
    316                     break;
    317                 }
    318             }
    319         }
    320     }
    321
    322     /**
    323      * Listens out on the JmDNS/ZeroConf network for new devices that appear
    324      * and adds/removes these device information from the list/model.
    325      */
    326     private class ZeroConfServiceListener implements ServiceListener {
    327
    328         public void serviceAdded(final ServiceEvent event) {
    329             LOG.info("Service Added: " + event);
    330             /**
    331              * it's not very clear whether we should do the resolving in a
    332              * background thread or not.. All it says is to NOT do it in the AWT
    333              * thread, so I'm thinking it probably should be a background thread
    334              */
    335             Runnable runnable = () -> ZeroConfPlugin.this.jmDNS.requestServiceInfo(event
    336                 .getType(), event.getName());
    337             Thread thread = new Thread(runnable,
    338                 "ChainsawZeroConfRequestResolutionThread");
    339             thread.setPriority(Thread.MIN_PRIORITY);
    340             thread.start();
    341         }
    342
    343         public void serviceRemoved(ServiceEvent event) {
    344             LOG.info("Service Removed: " + event);
    345             deviceRemoved(event.getName());
    346         }
    347
    348         public void serviceResolved(ServiceEvent event) {
    349             LOG.info("Service Resolved: " + event);
    350             deviceDiscovered(event.getInfo());
    351         }
    352
    353     }
    354
    355
    356     /**
    357      * When the user double clicks on a row, then the device is connected to,
    358      * the only exception is when clicking in the check box column for auto connect.
    359      */
    360     private class ConnectorMouseListener extends MouseAdapter {
    361
    362         public void mouseClicked(MouseEvent e) {
    363             if (e.getClickCount() == 2) {
    364                 int row = deviceTable.rowAtPoint(e.getPoint());
    365                 if (deviceTable.columnAtPoint(e.getPoint()) == 2) {
    366                     return;
    367                 }
    368                 ServiceInfo info = discoveredDevices.getServiceInfoAtRow(row);
    369
    370                 if (!isConnectedTo(info)) {
    371                     connectTo(info);
    372                 } else {
    373                     disconnectFrom(info);
    374                 }
    375             }
    376         }
    377
    378         public void mousePressed(MouseEvent e) {
    379             /**
    380              * This methodh handles when the user clicks the
    381              * auto-connect
    382              */
    383 //            int index = listBox.locationToIndex(e.getPoint());
    384 //
    385 //            if (index != -1) {
    386 ////                Point p = SwingUtilities.convertPoint(e.getComponent(), e.getPoint(), )
    387 //                Component c = SwingUtilities.getDeepestComponentAt(ZeroConfPlugin.this, e.getX(), e.getY());
    388 //                if (c instanceof JCheckBox) {
    389 //                    ServiceInfo info = (ServiceInfo) listBox.getModel()
    390 //                            .getElementAt(index);
    391 //                    String name = info.getName();
    392 //                    if (preferenceModel.getAutoConnectDevices().contains(name)) {
    393 //                        preferenceModel.removeAutoConnectDevice(name);
    394 //                    } else {
    395 //                        preferenceModel.addAutoConnectDevice(name);
    396 //                    }
    397 //                    discoveredDevices.fireContentsChanged();
    398 //                    repaint();
    399 //                }
    400 //            }
    401         }
    402     }
    403
    404     private void disconnectFrom(ServiceInfo info) {
    405         if (!isConnectedTo(info)) {
    406             return; // not connected, who cares
    407         }
    408         Plugin plugin;
    409         synchronized (serviceInfoToReceiveMap) {
    410             plugin = serviceInfoToReceiveMap.get(info);
    411         }
    412         ((LoggerRepositoryEx) LogManager.getLoggerRepository()).getPluginRegistry().stopPlugin(plugin.getName());
    413
    414         JMenuItem item = locateMatchingMenuItem(info.getName());
    415         if (item != null) {
    416             item.setIcon(null);
    417             item.setEnabled(true);
    418         }
    419     }
    420
    421     /**
    422      * returns true if the serviceInfo record already has a matching connected receiver
    423      *
    424      * @param info
    425      * @return
    426      */
    427     boolean isConnectedTo(ServiceInfo info) {
    428         return serviceInfoToReceiveMap.containsKey(info);
    429     }
    430
    431     /**
    432      * Starts a receiver to the appender referenced within the ServiceInfo
    433      *
    434      * @param info
    435      */
    436     private void connectTo(ServiceInfo info) {
    437         LOG.info("Connection request for " + info);
    438         //Chainsaw can construct receivers from discovered appenders
    439         Receiver receiver = getReceiver(info);
    440         //if null, unable to resolve the service name..no-op
    441         if (receiver == null) {
    442             return;
    443         }
    444         ((LoggerRepositoryEx) LogManager.getLoggerRepository()).getPluginRegistry().addPlugin(receiver);
    445         receiver.activateOptions();
    446         LOG.info("Receiver '" + receiver.getName() + "' has been started");
    447
    448         // ServiceInfo obeys equals() and hashCode() contracts, so this should be safe.
    449         synchronized (serviceInfoToReceiveMap) {
    450             serviceInfoToReceiveMap.put(info, receiver);
    451         }
    452
    453 //         this instance of the menu item needs to be disabled, and have an icon added
    454         JMenuItem item = locateMatchingMenuItem(info.getName());
    455         if (item != null) {
    456             item.setIcon(new ImageIcon(ChainsawIcons.ANIM_NET_CONNECT));
    457             item.setEnabled(false);
    458         }
    459 //        // now notify the list model has changed, it needs redrawing of the receiver icon now it's connected
    460 //        discoveredDevices.fireContentsChanged();
    461     }
    462
    463     private Receiver getReceiver(ServiceInfo info) {
    464         String zone = info.getType();
    465         int port = info.getPort();
    466         String hostAddress = info.getHostAddress();
    467         String name = info.getName();
    468         String decoderClass = info.getPropertyString("decoder");
    469
    470         if (NEW_UDP_APPENDER_SERVICE_NAME.equals(zone)) {
    471             UDPReceiver receiver = new UDPReceiver();
    472             receiver.setPort(port);
    473             receiver.setName(name + "-receiver");
    474             return receiver;
    475         }
    476         //FileAppender or socketappender
    477         //TODO: add more checks (actual layout format, etc)
    478         if (TCP_APPENDER_SERVICE_NAME.equals(zone)) {
    479             //CHECK content type
    480             //text/plain = VFSLogFilePatternReceiver (if structured=false)
    481             String contentType = info.getPropertyString("contentType").toLowerCase();
    482             //won't work with log4j2, as Chainsaw depends on log4j1.x
    483             //this will work - regular text log files are fine
    484             if ("text/plain".equals(contentType)) {
    485                 VFSLogFilePatternReceiver receiver = new VFSLogFilePatternReceiver();
    486                 receiver.setAppendNonMatches(true);
    487                 receiver.setFileURL(info.getPropertyString("fileURI"));
    488                 receiver.setLogFormat(LogFilePatternLayoutBuilder.getLogFormatFromPatternLayout(info.getPropertyString("format")));
    489                 receiver.setTimestampFormat(LogFilePatternLayoutBuilder.getTimeStampFormat(info.getPropertyString("format")));
    490                 receiver.setName(name + "-receiver");
    491                 receiver.setTailing(true);
    492                 return receiver;
    493             }
    494         }
    495
    496         //MulticastAppender
    497         if (MULTICAST_APPENDER_SERVICE_NAME.equals(zone)) {
    498             MulticastReceiver receiver = new MulticastReceiver();
    499             //this needs to be a multicast address, not the host address, so we need to use a property
    500             receiver.setAddress(info.getPropertyString("multicastAddress"));
    501             receiver.setPort(port);
    502             receiver.setName(name + "-receiver");
    503             if (decoderClass != null && !decoderClass.equals("")) {
    504                 receiver.setDecoder(decoderClass);
    505             }
    506
    507             return receiver;
    508         }
    509         //UDPAppender
    510         if (UDP_APPENDER_SERVICE_NAME.equals(zone)) {
    511             UDPReceiver receiver = new UDPReceiver();
    512             receiver.setPort(port);
    513             receiver.setName(name + "-receiver");
    514             if (decoderClass != null && !decoderClass.equals("")) {
    515                 receiver.setDecoder(decoderClass);
    516             }
    517             return receiver;
    518         }
    519
    520         //non-log4j XML-based socketappender
    521         if (XML_SOCKET_APPENDER_SERVICE_NAME.equals(zone)) {
    522             XMLSocketReceiver receiver = new XMLSocketReceiver();
    523             receiver.setPort(port);
    524             receiver.setName(name + "-receiver");
    525             if (decoderClass != null && !decoderClass.equals("")) {
    526                 receiver.setDecoder(decoderClass);
    527             }
    528             return receiver;
    529         }
    530
    531         //not recognized
    532         LogLog.debug("Unable to find receiver for appender with service name: " + zone);
    533         return null;
    534     }
    535
    536     /**
    537      * Finds the matching JMenuItem based on name, may return null if there is no match.
    538      *
    539      * @param name
    540      * @return
    541      */
    542     private JMenuItem locateMatchingMenuItem(String name) {
    543         Component[] menuComponents = connectToMenu.getMenuComponents();
    544         for (Component c : menuComponents) {
    545             if (!(c instanceof JPopupMenu.Separator)) {
    546                 JMenuItem item = (JMenuItem) c;
    547                 if (item.getText().compareToIgnoreCase(name) == 0) {
    548                     return item;
    549                 }
    550             }
    551         }
    552         return null;
    553     }
    554
    555     public static void main(String[] args) {
    556
    557         BasicConfigurator.resetConfiguration();
    558         BasicConfigurator.configure();
    559
    560         final ZeroConfPlugin plugin = new ZeroConfPlugin();
    561
    562
    563         JFrame frame = new JFrame();
    564         frame.setDefaultCloseOperation(WindowConstants.EXIT_ON_CLOSE);
    565
    566         frame.getContentPane().setLayout(new BorderLayout());
    567         frame.getContentPane().add(plugin, BorderLayout.CENTER);
    568
    569         // needs to be activated after being added to the JFrame for Menu injection to work
    570         plugin.activateOptions();
    571
    572         frame.pack();
    573         frame.setVisible(true);
    574
    575         Thread thread = new Thread(plugin::shutdown);
    576         Runtime.getRuntime().addShutdownHook(thread);
    577     }
    578
    579 }