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 }