001/*
002 * This file is part of McIDAS-V
003 *
004 * Copyright 2007-2024
005 * Space Science and Engineering Center (SSEC)
006 * University of Wisconsin - Madison
007 * 1225 W. Dayton Street, Madison, WI 53706, USA
008 * https://www.ssec.wisc.edu/mcidas/
009 * 
010 * All Rights Reserved
011 * 
012 * McIDAS-V is built on Unidata's IDV and SSEC's VisAD libraries, and
013 * some McIDAS-V source code is based on IDV and VisAD source code.  
014 * 
015 * McIDAS-V is free software; you can redistribute it and/or modify
016 * it under the terms of the GNU Lesser Public License as published by
017 * the Free Software Foundation; either version 3 of the License, or
018 * (at your option) any later version.
019 * 
020 * McIDAS-V is distributed in the hope that it will be useful,
021 * but WITHOUT ANY WARRANTY; without even the implied warranty of
022 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
023 * GNU Lesser Public License for more details.
024 * 
025 * You should have received a copy of the GNU Lesser Public License
026 * along with this program.  If not, see https://www.gnu.org/licenses/.
027 */
028package edu.wisc.ssec.mcidasv.servermanager;
029
030import static edu.wisc.ssec.mcidasv.servermanager.EntryStore.getLocalPort;
031import static java.util.Objects.requireNonNull;
032
033import static edu.wisc.ssec.mcidasv.util.CollectionHelpers.arrList;
034import static edu.wisc.ssec.mcidasv.util.CollectionHelpers.newLinkedHashSet;
035import static edu.wisc.ssec.mcidasv.util.McVGuiUtils.runOnEDT;
036import static edu.wisc.ssec.mcidasv.util.McVGuiUtils.safeGetText;
037import static ucar.unidata.ui.Help.getDefaultHelp;
038
039import java.awt.BorderLayout;
040import java.awt.Component;
041import java.awt.Dimension;
042import java.awt.Font;
043import java.awt.event.MouseAdapter;
044import java.awt.event.MouseEvent;
045import java.awt.event.WindowAdapter;
046import java.awt.event.WindowEvent;
047import java.io.File;
048import java.util.Collection;
049import java.util.Collections;
050import java.util.EnumSet;
051import java.util.List;
052import java.util.Set;
053import java.util.concurrent.Callable;
054import java.util.concurrent.CompletionService;
055import java.util.concurrent.ExecutionException;
056import java.util.concurrent.ExecutorCompletionService;
057import java.util.concurrent.ExecutorService;
058import java.util.concurrent.Executors;
059import java.util.regex.Pattern;
060
061import javax.swing.Box;
062import javax.swing.BoxLayout;
063import javax.swing.GroupLayout;
064import javax.swing.Icon;
065import javax.swing.JButton;
066import javax.swing.JCheckBox;
067import javax.swing.JCheckBoxMenuItem;
068import javax.swing.JComponent;
069import javax.swing.JDialog;
070import javax.swing.JFileChooser;
071import javax.swing.JFrame;
072import javax.swing.JLabel;
073import javax.swing.JMenu;
074import javax.swing.JMenuBar;
075import javax.swing.JMenuItem;
076import javax.swing.JPanel;
077import javax.swing.JScrollPane;
078import javax.swing.JSeparator;
079import javax.swing.JTabbedPane;
080import javax.swing.JTable;
081import javax.swing.JTextField;
082import javax.swing.LayoutStyle;
083import javax.swing.ListSelectionModel;
084import javax.swing.SwingUtilities;
085import javax.swing.UIManager;
086import javax.swing.border.EmptyBorder;
087import javax.swing.event.ChangeEvent;
088import javax.swing.event.ListSelectionEvent;
089import javax.swing.table.AbstractTableModel;
090import javax.swing.table.DefaultTableCellRenderer;
091
092import edu.wisc.ssec.mcidasv.McIDASV;
093import net.miginfocom.swing.MigLayout;
094
095import org.bushe.swing.event.EventBus;
096import org.bushe.swing.event.annotation.AnnotationProcessor;
097import org.bushe.swing.event.annotation.EventSubscriber;
098
099import org.slf4j.Logger;
100import org.slf4j.LoggerFactory;
101
102import ucar.unidata.idv.IdvObjectStore;
103import ucar.unidata.ui.Help;
104import ucar.unidata.util.GuiUtils;
105import ucar.unidata.util.LogUtil;
106
107import edu.wisc.ssec.mcidasv.servermanager.AddeEntry.EntrySource;
108import edu.wisc.ssec.mcidasv.servermanager.AddeEntry.EntryType;
109import edu.wisc.ssec.mcidasv.servermanager.AddeEntry.EntryValidity;
110import edu.wisc.ssec.mcidasv.servermanager.AddeThread.McservEvent;
111import edu.wisc.ssec.mcidasv.servermanager.EntryStore.Event;
112import edu.wisc.ssec.mcidasv.servermanager.RemoteEntryEditor.AddeStatus;
113import edu.wisc.ssec.mcidasv.ui.BetterJTable;
114import edu.wisc.ssec.mcidasv.util.McVTextField.Prompt;
115import java.awt.event.ActionEvent;
116import java.util.stream.Collectors;
117
118/**
119 * This class is the GUI frontend to {@link EntryStore} (the server manager).
120 * It allows users to manipulate their local and remote ADDE data.
121 */
122// TODO(jon): don't forget to persist tab choice and window position. maybe also the "positions" of the scrollpanes (if possible).
123// TODO(jon): GUI could look much better.
124// TODO(jon): finish up the javadocs.
125@SuppressWarnings({"serial", "AssignmentToStaticFieldFromInstanceMethod", "FieldCanBeLocal"})
126public class TabbedAddeManager extends JFrame {
127
128    /** Pretty typical logger object. */
129    private final static Logger logger =
130        LoggerFactory.getLogger(TabbedAddeManager.class);
131
132    /** Path to the help resources. */
133    private static final String HELP_TOP_DIR = "/docs/userguide";
134
135    /** Help target for the remote servers. */
136    private static final String REMOTE_HELP_TARGET = "idv.tools.remotedata";
137
138    /** Help target for the local servers. */
139    private static final String LOCAL_HELP_TARGET = "idv.tools.localdata";
140
141    /** ID used to save/restore the last visible tab between sessions. */
142    private static final String LAST_TAB = "mcv.adde.lasttab";
143
144    /** ID used to save/restore last directory that contained a MCTABLE.TXT. */
145    private static final String LAST_IMPORTED = "mcv.adde.lastmctabledir";
146
147    /** Size of the ADDE entry verification thread pool. */
148    private static final int POOL = 2;
149
150    /** Static reference to an instance of this class. Bad idea! */
151    private static TabbedAddeManager staticTabbedManager;
152
153    /**
154     * These are the various {@literal "events"} that the server manager GUI
155     * supports. These are published via the wonderful
156     * {@link EventBus#publish(Object)} method.
157     */
158    public enum Event { 
159        /** The GUI was created. */
160        OPENED,
161        /** The GUI was hidden/minimized/etc. */
162        HIDDEN,
163        /** GUI was unhidden or some such thing. */
164        SHOWN,
165        /** The GUI was closed. */
166        CLOSED
167    }
168
169    /** Reference to the actual server manager. */
170    private final EntryStore serverManager;
171
172    /** */
173    private final List<RemoteAddeEntry> selectedRemoteEntries;
174
175    /** */
176    private final List<LocalAddeEntry> selectedLocalEntries;
177
178    /** */
179    private JTextField importUser;
180
181    /** */
182    private JTextField importProject;
183
184    /** Whether or not {@link #initComponents()} has been called. */
185    private boolean guiInitialized = false;
186
187    /**
188     * Creates a standalone server manager GUI.
189     */
190    public TabbedAddeManager() {
191        //noinspection AssignmentToNull
192        AnnotationProcessor.process(this);
193        this.serverManager = null;
194        this.selectedLocalEntries = arrList();
195        this.selectedRemoteEntries = arrList();
196
197        SwingUtilities.invokeLater(this::initComponents);
198    }
199
200    /**
201     * Creates a server manager GUI that's linked back to the rest of McIDAS-V.
202     * 
203     * @param entryStore Server manager reference.
204     * 
205     * @throws NullPointerException if {@code entryStore} is {@code null}.
206     */
207    public TabbedAddeManager(final EntryStore entryStore) {
208        requireNonNull(entryStore, "Cannot pass a null server manager");
209        AnnotationProcessor.process(this);
210        this.serverManager = entryStore; 
211        this.selectedLocalEntries = arrList();
212        this.selectedRemoteEntries = arrList();
213        SwingUtilities.invokeLater(this::initComponents);
214    }
215
216    /** 
217     * Returns an instance of this class. The instance <i>should</i> correspond
218     * to the one being used by the {@literal "rest"} of McIDAS-V.
219     * 
220     * @return Either an instance of this class or {@code null}.
221     */
222    public static TabbedAddeManager getTabbedManager() {
223        return staticTabbedManager;
224    }
225
226    /**
227     * If the GUI isn't shown, this method will display things. If the GUI
228     * <i>is shown</i>, bring it to the front.
229     * 
230     * <p>This method publishes {@link Event#SHOWN}.
231     */
232    public void showManager() {
233        if (isVisible()) {
234            toFront();
235        } else {
236            setVisible(true);
237        }
238        staticTabbedManager = this;
239        EventBus.publish(Event.SHOWN);
240    }
241
242    /**
243     * Closes and disposes (if needed) the GUI.
244     */
245    public void closeManager() {
246        //noinspection AssignmentToNull
247        staticTabbedManager = null;
248        EventBus.publish(Event.CLOSED);
249        if (isDisplayable()) {
250            dispose();
251        }
252    }
253
254    /**
255     * Attempts to refresh the contents of both the local and remote dataset
256     * tables. 
257     */
258    public void refreshDisplay() {
259        if (guiInitialized) {
260            ((RemoteAddeTableModel)remoteTable.getModel()).refreshEntries();
261            ((LocalAddeTableModel)localTable.getModel()).refreshEntries();
262        }
263    }
264
265    /**
266     * Create and show the GUI the remote ADDE dataset GUI. Since no 
267     * {@link RemoteAddeEntry RemoteAddeEntries} have been provided, none of 
268     * the fields will be prefilled (user is creating a new dataset). 
269     */
270    // TODO(jon): differentiate between showRemoteEditor() and showRemoteEditor(entries)
271    public void showRemoteEditor() {
272        if (tabbedPane.getSelectedIndex() != 0) {
273            tabbedPane.setSelectedIndex(0);
274        }
275        RemoteEntryEditor editor =
276            new RemoteEntryEditor(this, true, this, serverManager);
277        editor.setVisible(true);
278    }
279
280    /**
281     * Create and show the GUI the remote ADDE dataset GUI. Since some 
282     * {@link RemoteAddeEntry RemoteAddeEntries} have been provided, all of the
283     * applicable fields will be filled (user is editing an existing dataset).
284     * 
285     * @param entries Selection to edit. Should not be {@code null}.
286     */
287    // TODO(jon): differentiate between showRemoteEditor() and showRemoteEditor(entries)
288    public void showRemoteEditor(final List<RemoteAddeEntry> entries) {
289        if (tabbedPane.getSelectedIndex() != 0) {
290            tabbedPane.setSelectedIndex(0);
291        }
292        RemoteEntryEditor editor =
293            new RemoteEntryEditor(this, true, this, serverManager, entries);
294        editor.setVisible(true);
295    }
296
297    /**
298     * Removes the given remote ADDE entries from the server manager GUI.
299     * 
300     * @param entries Entries to remove. {@code null} is permissible, but is a
301     * {@literal "no-op"}.
302     */
303    public void removeRemoteEntries(final Collection<RemoteAddeEntry> entries) {
304        if (entries == null) {
305            return;
306        }
307        List<RemoteAddeEntry> removable = arrList(entries.size());
308        removable.addAll(
309            entries.stream()
310                   .filter(e -> e.getEntrySource() != EntrySource.SYSTEM)
311                   .collect(Collectors.toList()));
312
313        if (serverManager.removeEntries(removable)) {
314            RemoteAddeTableModel tableModel =
315                (RemoteAddeTableModel)remoteTable.getModel();
316            int first = Integer.MAX_VALUE;
317            int last = Integer.MIN_VALUE;
318            for (RemoteAddeEntry entry : removable) {
319                int index = tableModel.getRowForEntry(entry);
320                if (index >= 0) {
321                    if (index < first) {
322                        first = index;
323                    }
324                    if (index > last) {
325                        last = index;
326                    }
327                }
328            }
329            tableModel.fireTableDataChanged();
330            refreshDisplay();
331            remoteTable.revalidate();
332            if (first < remoteTable.getRowCount()) {
333                remoteTable.setRowSelectionInterval(first, first);
334            }
335        } else {
336            logger.debug("could not remove entries={}", removable);
337        }
338    }
339
340    /**
341     * Shows a local ADDE entry editor <b>without</b> anything pre-populated 
342     * (creating a new local ADDE dataset).
343     */
344    public void showLocalEditor() {
345        // TODO(jon): differentiate between showLocalEditor() and showLocalEditor(entry)
346        if (tabbedPane.getSelectedIndex() != 1) {
347            tabbedPane.setSelectedIndex(1);
348        }
349        LocalEntryEditor editor =
350            new LocalEntryEditor(this, true, this, serverManager);
351        editor.setVisible(true);
352    }
353
354    /**
355     * Shows a local ADDE entry editor <b>with</b> the appropriate fields
356     * pre-populated, using the values from {@code entry}. This is intended to 
357     * handle {@literal "editing"} a local ADDE dataset.
358     * 
359     * @param entry Entry to edit; should not be {@code null}.
360     */
361    public void showLocalEditor(final LocalAddeEntry entry) {
362        // TODO(jon): differentiate between showLocalEditor() and showLocalEditor(entry)
363        if (tabbedPane.getSelectedIndex() != 1) {
364            tabbedPane.setSelectedIndex(1);
365        }
366        LocalEntryEditor editor =
367            new LocalEntryEditor(this, true, this, serverManager, entry);
368        editor.setVisible(true);
369    }
370
371    /**
372     * Removes the given local ADDE entries from the server manager GUI.
373     * 
374     * @param entries Entries to remove. {@code null} is permissible, but is a
375     * {@literal "no-op"}.
376     */
377    public void removeLocalEntries(final Collection<LocalAddeEntry> entries) {
378        if (entries == null) {
379            return;
380        }
381
382        if (serverManager.removeEntries(entries)) {
383            logger.trace("successful removal of entries={}",entries);
384            LocalAddeTableModel tableModel =
385                (LocalAddeTableModel)localTable.getModel();
386            int first = Integer.MAX_VALUE;
387            int last = Integer.MIN_VALUE;
388            for (LocalAddeEntry entry : entries) {
389                int index = tableModel.getRowForEntry(entry);
390                if (index >= 0) {
391                    if (index < first) {
392                        first = index;
393                    }
394                    if (index > last) {
395                        last = index;
396                    }
397                }
398            }
399            tableModel.fireTableDataChanged();
400            refreshDisplay();
401            localTable.revalidate();
402            if (first < localTable.getRowCount()) {
403                localTable.setRowSelectionInterval(first, first);
404            }
405        } else {
406            logger.debug("could not remove entries={}", entries);
407        }
408    }
409
410    /**
411     * Extracts datasets from a given MCTABLE.TXT and adds them to the server
412     * manager.
413     * 
414     * @param path Path to the MCTABLE.TXT. Cannot be {@code null}.
415     * @param username ADDE username to use for verifying extracted datasets.
416     * Cannot be {@code null}.
417     * @param project ADDE project number to use for verifying extracted
418     * datasets. Cannot be {@code null}.
419     */
420    public void importMctable(final String path, final String username,
421                              final String project)
422    {
423        logger.trace("extracting path={} username={}, project={}", path, username, project);
424        final Set<RemoteAddeEntry> imported =
425            EntryTransforms.extractMctableEntries(path, username, project);
426        logger.trace("extracted entries={}", imported);
427        if (imported.equals(Collections.emptySet())) {
428            LogUtil.userErrorMessage("Selection does not appear to a valid MCTABLE.TXT file:\n"+path);
429        } else {
430            logger.trace("adding extracted entries...");
431            // verify entries first!
432            serverManager.addEntries(imported);
433            refreshDisplay();
434            repaint();
435            Thread t = new Thread(() -> checkDatasets(imported));
436            t.start();
437        }
438    }
439
440    /**
441     * Attempts to start the local servers. 
442     * 
443     * @see EntryStore#startLocalServer()
444     */
445    public void startLocalServers() {
446        logger.trace("starting local servers...?");
447        serverManager.startLocalServer();
448    }
449
450    /**
451     * Attempts to stop the local servers.
452     * 
453     * @see EntryStore#stopLocalServer()
454     */
455    public void stopLocalServers() {
456        logger.trace("stopping local servers...?");
457        serverManager.stopLocalServer();
458    }
459
460    /**
461     * Responds to local server events and attempts to update the GUI status
462     * message.
463     * 
464     * @param event Local server event. Should not be {@code null}.
465     */
466    @EventSubscriber(eventClass=AddeThread.McservEvent.class)
467    public void mcservUpdated(final AddeThread.McservEvent event) {
468        logger.trace("eventbus evt={}", event.toString());
469        final String msg;
470        switch (event) {
471            case ACTIVE: case DIED: case STOPPED:
472                msg = event.getMessage();
473                break;
474            case STARTED:
475                msg = String.format(event.getMessage(), getLocalPort());
476                break;
477            default:
478                msg = "Unknown local servers status: "+event.toString();
479                break;
480        }
481        SwingUtilities.invokeLater(() -> {
482            if (statusLabel != null) {
483                statusLabel.setText(msg);
484            }
485        });
486    }
487
488    public void handleUrlImportMenuItem(ActionEvent e) {
489        SwingUtilities.invokeLater(() -> {
490            try {
491                ImportUrl dialog = new ImportUrl(serverManager, this);
492                dialog.setDefaultCloseOperation(JDialog.DISPOSE_ON_CLOSE);
493                dialog.setVisible(true);
494            } catch (Exception ex) {
495                logger.error("error importing from url", ex);
496            }
497        });
498    }
499
500    /**
501     * Builds the server manager GUI.
502     */
503    @SuppressWarnings({"unchecked", "FeatureEnvy", "MagicNumber"})
504    public void initComponents() {
505        Dimension frameSize = new Dimension(730, 460);
506        Help.setTopDir(HELP_TOP_DIR);
507        system = icon("padlock_closed.png");
508        mctable = icon("bug.png");
509        user = icon("hand_pro.png");
510        invalid = icon("emotion_sad.png");
511        unverified = icon("eye.png");
512        setTitle("ADDE Data Manager");
513        setDefaultCloseOperation(JFrame.DISPOSE_ON_CLOSE);
514        setSize(frameSize);
515        setMinimumSize(frameSize);
516        addWindowListener(new WindowAdapter() {
517            public void windowClosed(WindowEvent evt) {
518                formWindowClosed(evt);
519            }
520        });
521
522        JMenuBar menuBar = new JMenuBar();
523        setJMenuBar(menuBar);
524
525        JMenu fileMenu = new JMenu("File");
526        menuBar.add(fileMenu);
527
528        JMenuItem remoteNewMenuItem = new JMenuItem("New Remote Dataset");
529        remoteNewMenuItem.addActionListener(evt -> showRemoteEditor());
530        fileMenu.add(remoteNewMenuItem);
531
532        JMenuItem localNewMenuItem = new JMenuItem("New Local Dataset");
533        localNewMenuItem.addActionListener(evt -> showLocalEditor());
534        fileMenu.add(localNewMenuItem);
535
536        fileMenu.add(new JSeparator());
537
538        JMenuItem importMctableMenuItem = new JMenuItem("Import MCTABLE...");
539        importMctableMenuItem.addActionListener(this::importButtonActionPerformed);
540        fileMenu.add(importMctableMenuItem);
541
542        JMenuItem importUrlMenuItem = new JMenuItem("Import from URL...");
543        importUrlMenuItem.addActionListener(this::handleUrlImportMenuItem);
544        fileMenu.add(importUrlMenuItem);
545
546        fileMenu.add(new JSeparator());
547
548        JMenuItem closeMenuItem = new JMenuItem("Close");
549        closeMenuItem.addActionListener(evt -> {
550            closeManager();
551        });
552        fileMenu.add(closeMenuItem);
553
554        JMenu editMenu = new JMenu("Edit");
555        menuBar.add(editMenu);
556
557        editMenuItem = new JMenuItem("Edit Entry...");
558        editMenuItem.setEnabled(false);
559        editMenuItem.addActionListener(evt -> {
560            if (tabbedPane.getSelectedIndex() == 0) {
561                showRemoteEditor(getSelectedRemoteEntries());
562            } else {
563                showLocalEditor(getSingleLocalSelection());
564            }
565        });
566        editMenu.add(editMenuItem);
567
568        removeMenuItem = new JMenuItem("Remove Selection");
569        removeMenuItem.setEnabled(false);
570        removeMenuItem.addActionListener(evt -> {
571            if (tabbedPane.getSelectedIndex() == 0) {
572                removeRemoteEntries(getSelectedRemoteEntries());
573            } else {
574                removeLocalEntries(getSelectedLocalEntries());
575            }
576        });
577        editMenu.add(removeMenuItem);
578
579        JMenu localServersMenu = new JMenu("Local Servers");
580        menuBar.add(localServersMenu);
581
582        JMenuItem startLocalMenuItem = new JMenuItem("Start Local Servers");
583        startLocalMenuItem.addActionListener(e -> startLocalServers());
584        localServersMenu.add(startLocalMenuItem);
585
586        JMenuItem stopLocalMenuItem = new JMenuItem("Stop Local Servers");
587        stopLocalMenuItem.addActionListener(e -> stopLocalServers());
588        localServersMenu.add(stopLocalMenuItem);
589
590        JMenu helpMenu = new JMenu("Help");
591        menuBar.add(helpMenu);
592
593        JMenuItem remoteHelpMenuItem = new JMenuItem("Show Remote Data Help");
594        remoteHelpMenuItem.addActionListener(evt -> getDefaultHelp().gotoTarget(REMOTE_HELP_TARGET));
595        helpMenu.add(remoteHelpMenuItem);
596
597        JMenuItem localHelpMenuItem = new JMenuItem("Show Local Data Help");
598        localHelpMenuItem.addActionListener(evt -> getDefaultHelp().gotoTarget(LOCAL_HELP_TARGET));
599        helpMenu.add(localHelpMenuItem);
600
601        contentPane = new JPanel();
602        contentPane.setBorder(null);
603        setContentPane(contentPane);
604        contentPane.setLayout(new MigLayout("", "[grow]", "[grow][grow][grow]"));
605
606        tabbedPane = new JTabbedPane(JTabbedPane.TOP);
607        tabbedPane.addChangeListener(this::handleTabStateChanged);
608        contentPane.add(tabbedPane, "cell 0 0 1 3,grow");
609
610        JPanel remoteTab = new JPanel();
611        remoteTab.setBorder(new EmptyBorder(0, 4, 4, 4));
612        tabbedPane.addTab("Remote Data", null, remoteTab, null);
613        remoteTab.setLayout(new BoxLayout(remoteTab, BoxLayout.Y_AXIS));
614
615        remoteTable = new BetterJTable();
616        JScrollPane remoteScroller =
617            BetterJTable.createStripedJScrollPane(remoteTable);
618
619        remoteTable.setModel(new RemoteAddeTableModel(serverManager));
620        remoteTable.setAutoCreateRowSorter(true);
621        remoteTable.setColumnSelectionAllowed(false);
622        remoteTable.setRowSelectionAllowed(true);
623        remoteTable.getTableHeader().setReorderingAllowed(false);
624        remoteTable.setFont(UIManager.getFont("Table.font").deriveFont(11.0f));
625        remoteTable.getColumnModel().getSelectionModel().setSelectionMode(ListSelectionModel.MULTIPLE_INTERVAL_SELECTION);
626        remoteTable.setDefaultRenderer(String.class, new TextRenderer());
627        remoteTable.getColumnModel().getColumn(0).setPreferredWidth(10);
628        remoteTable.getColumnModel().getColumn(1).setPreferredWidth(10);
629        remoteTable.getColumnModel().getColumn(3).setPreferredWidth(50);
630        remoteTable.getColumnModel().getColumn(4).setPreferredWidth(50);
631        remoteTable.getColumnModel().getColumn(0).setCellRenderer(new EntryValidityRenderer());
632        remoteTable.getColumnModel().getColumn(1).setCellRenderer(new EntrySourceRenderer());
633        remoteTable.getSelectionModel().addListSelectionListener(this::remoteSelectionModelChanged);
634        remoteTable.addMouseListener(new MouseAdapter() {
635            @Override public void mouseClicked(final MouseEvent e) {
636                if ((e.getClickCount() == 2) && hasSingleRemoteSelection()) {
637                    showRemoteEditor(getSelectedRemoteEntries());
638                }
639            }
640        });
641        remoteScroller.setViewportView(remoteTable);
642        remoteTab.add(remoteScroller);
643
644        JPanel remoteActionPanel = new JPanel();
645        remoteTab.add(remoteActionPanel);
646        remoteActionPanel.setLayout(new BoxLayout(remoteActionPanel, BoxLayout.X_AXIS));
647
648        newRemoteButton = new JButton("Add New Dataset");
649        newRemoteButton.addActionListener(e -> showRemoteEditor());
650        newRemoteButton.setToolTipText("Create a new remote ADDE dataset.");
651        remoteActionPanel.add(newRemoteButton);
652
653        editRemoteButton = new JButton("Edit Dataset");
654        editRemoteButton.addActionListener(e -> showRemoteEditor(getSelectedRemoteEntries()));
655        editRemoteButton.setToolTipText("Edit an existing remote ADDE dataset.");
656        remoteActionPanel.add(editRemoteButton);
657
658        removeRemoteButton = new JButton("Remove Selection");
659        removeRemoteButton.addActionListener(e -> removeRemoteEntries(getSelectedRemoteEntries()));
660        removeRemoteButton.setToolTipText("Remove the selected remote ADDE datasets.");
661        remoteActionPanel.add(removeRemoteButton);
662
663        importRemoteButton = new JButton("Import MCTABLE...");
664        importRemoteButton.addActionListener(e -> importButtonActionPerformed(e));
665        remoteActionPanel.add(importRemoteButton);
666
667        JPanel localTab = new JPanel();
668        localTab.setBorder(new EmptyBorder(0, 4, 4, 4));
669        tabbedPane.addTab("Local Data", null, localTab, null);
670        localTab.setLayout(new BoxLayout(localTab, BoxLayout.Y_AXIS));
671
672        localTable = new BetterJTable();
673        JScrollPane localScroller =
674            BetterJTable.createStripedJScrollPane(localTable);
675        localTable.setModel(new LocalAddeTableModel(serverManager));
676        localTable.setAutoCreateRowSorter(true);
677        localTable.setColumnSelectionAllowed(false);
678        localTable.setRowSelectionAllowed(true);
679        localTable.getTableHeader().setReorderingAllowed(false);
680        localTable.setFont(UIManager.getFont("Table.font").deriveFont(11.0f));
681        localTable.setDefaultRenderer(String.class, new TextRenderer());
682        localTable.getColumnModel().getSelectionModel().setSelectionMode(ListSelectionModel.SINGLE_SELECTION);
683        localTable.getSelectionModel().addListSelectionListener(this::localSelectionModelChanged);
684        localTable.addMouseListener(new MouseAdapter() {
685            @Override public void mouseClicked(final MouseEvent e) {
686                if ((e.getClickCount() == 2) && hasSingleLocalSelection()) {
687                    showLocalEditor(getSingleLocalSelection());
688                }
689            }
690        });
691        localScroller.setViewportView(localTable);
692        localTab.add(localScroller);
693
694        JPanel localActionPanel = new JPanel();
695        localTab.add(localActionPanel);
696        localActionPanel.setLayout(new BoxLayout(localActionPanel, BoxLayout.X_AXIS));
697
698        newLocalButton = new JButton("Add New Dataset");
699        newLocalButton.addActionListener(e -> showLocalEditor());
700        newLocalButton.setToolTipText("Create a new local ADDE dataset.");
701        localActionPanel.add(newLocalButton);
702
703        editLocalButton = new JButton("Edit Dataset");
704        editLocalButton.setEnabled(false);
705        editLocalButton.addActionListener(e -> showLocalEditor(getSingleLocalSelection()));
706        editLocalButton.setToolTipText("Edit an existing local ADDE dataset.");
707        localActionPanel.add(editLocalButton);
708
709        removeLocalButton = new JButton("Remove Selection");
710        removeLocalButton.setEnabled(false);
711        removeLocalButton.addActionListener(e -> removeLocalEntries(getSelectedLocalEntries()));
712        removeLocalButton.setToolTipText("Remove the selected local ADDE datasets.");
713        localActionPanel.add(removeLocalButton);
714
715        JComponent statusPanel = new JPanel();
716        statusPanel.setBorder(new EmptyBorder(0, 6, 0, 6));
717        contentPane.add(statusPanel, "cell 0 3,grow");
718        statusPanel.setLayout(new BorderLayout(0, 0));
719
720        Box statusMessageBox = Box.createHorizontalBox();
721        statusPanel.add(statusMessageBox, BorderLayout.WEST);
722
723        String statusMessage = McservEvent.STOPPED.getMessage();
724        if (serverManager.checkLocalServer()) {
725            statusMessage = McservEvent.ACTIVE.getMessage();
726        }
727        statusLabel = new JLabel(statusMessage);
728        statusMessageBox.add(statusLabel);
729        statusLabel.setEnabled(false);
730
731        Box frameControlBox = Box.createHorizontalBox();
732        statusPanel.add(frameControlBox, BorderLayout.EAST);
733
734        okButton = new JButton("Ok");
735        okButton.addActionListener(e -> closeManager());
736        frameControlBox.add(okButton);
737        tabbedPane.setSelectedIndex(getLastTab());
738        guiInitialized = true;
739    }
740
741    /**
742     * Respond to changes in {@link #tabbedPane}; primarily switching tabs.
743     * 
744     * @param event Event being handled. Ignored for now.
745     */
746    private void handleTabStateChanged(final ChangeEvent event) {
747        assert SwingUtilities.isEventDispatchThread();
748        boolean hasSelection = false;
749        int index = 0;
750        if (guiInitialized) {
751            index = tabbedPane.getSelectedIndex();
752            if (index == 0) {
753                hasSelection = hasRemoteSelection();
754                editRemoteButton.setEnabled(hasSelection);
755                removeRemoteButton.setEnabled(hasSelection);
756            } else {
757                hasSelection = hasLocalSelection();
758                editLocalButton.setEnabled(hasSelection);
759                removeLocalButton.setEnabled(hasSelection);
760            }
761            editMenuItem.setEnabled(hasSelection);
762            removeMenuItem.setEnabled(hasSelection);
763            setLastTab(index);
764        }
765        logger.trace("index={} hasRemote={} hasLocal={} guiInit={}", index, hasRemoteSelection(), hasLocalSelection(), guiInitialized);
766    }
767
768    /**
769     * Respond to events.
770     * 
771     * @param e {@link ListSelectionEvent} that necessitated this call.
772     */
773    private void remoteSelectionModelChanged(final ListSelectionEvent e) {
774        if (e.getValueIsAdjusting()) {
775            return;
776        }
777
778        int selectedRowCount = 0;
779        ListSelectionModel selModel = (ListSelectionModel)e.getSource();
780        Set<RemoteAddeEntry> selectedEntries;
781        if (selModel.isSelectionEmpty()) {
782            selectedEntries = Collections.emptySet();
783        } else {
784            int min = selModel.getMinSelectionIndex();
785            int max = selModel.getMaxSelectionIndex();
786            RemoteAddeTableModel tableModel = (RemoteAddeTableModel)remoteTable.getModel();
787            selectedEntries = newLinkedHashSet((max - min) * AddeEntry.EntryType.values().length);
788            for (int i = min; i <= max; i++) {
789                if (selModel.isSelectedIndex(i)) {
790                    int realRow = remoteTable.convertRowIndexToModel(i);
791                    logger.trace("original row: {} real row: {}", i, realRow);
792                    List<RemoteAddeEntry> entries = tableModel.getEntriesAtRow(realRow);
793                    
794                    selectedEntries.addAll(entries);
795                    selectedRowCount++;
796                }
797            }
798        }
799
800        boolean onlyDefaultEntries = true;
801        for (RemoteAddeEntry entry : selectedEntries) {
802            if (entry.getEntrySource() != EntrySource.SYSTEM) {
803                onlyDefaultEntries = false;
804                break;
805            }
806        }
807        setSelectedRemoteEntries(selectedEntries);
808
809        // the current "edit" dialog doesn't work so well with multiple 
810        // servers/datasets, so only allow the user to edit entries one at a time.
811        boolean singleSelection = selectedRowCount == 1;
812        editRemoteButton.setEnabled(singleSelection);
813        editMenuItem.setEnabled(singleSelection);
814
815        boolean hasSelection = (selectedRowCount >= 1) && !onlyDefaultEntries;
816        removeRemoteButton.setEnabled(hasSelection);
817        removeMenuItem.setEnabled(hasSelection);
818    }
819
820    /**
821     * Respond to events from the local dataset table.
822     * 
823     * @param e {@link ListSelectionEvent} that necessitated this call.
824     */
825    private void localSelectionModelChanged(final ListSelectionEvent e) {
826        if (e.getValueIsAdjusting()) {
827            return;
828        }
829        ListSelectionModel selModel = (ListSelectionModel)e.getSource();
830        Set<LocalAddeEntry> selectedEntries;
831        if (selModel.isSelectionEmpty()) {
832            selectedEntries = Collections.emptySet();
833        } else {
834            int min = selModel.getMinSelectionIndex();
835            int max = selModel.getMaxSelectionIndex();
836            LocalAddeTableModel tableModel = (LocalAddeTableModel)localTable.getModel();
837            selectedEntries = newLinkedHashSet(max - min);
838            for (int i = min; i <= max; i++) {
839                if (selModel.isSelectedIndex(i)) {
840                    int realRow = localTable.convertRowIndexToModel(i);
841                    selectedEntries.add(tableModel.getEntryAtRow(realRow));
842                }
843            }
844        }
845
846        setSelectedLocalEntries(selectedEntries);
847
848        // the current "edit" dialog doesn't work so well with multiple 
849        // servers/datasets, so only allow the user to edit entries one at a time.
850        boolean singleSelection = selectedEntries.size() == 1;
851        this.editRemoteButton.setEnabled(singleSelection);
852        this.editMenuItem.setEnabled(singleSelection);
853
854        boolean hasSelection = !selectedEntries.isEmpty();
855        removeRemoteButton.setEnabled(hasSelection);
856        removeMenuItem.setEnabled(hasSelection);
857    }
858
859    /**
860     * Checks to see if {@link #selectedRemoteEntries} contains any 
861     * {@link RemoteAddeEntry}s.
862     *
863     * @return Whether or not any {@code RemoteAddeEntry} values are selected.
864     */
865    private boolean hasRemoteSelection() {
866        return !selectedRemoteEntries.isEmpty();
867    }
868
869    /**
870     * Checks to see if {@link #selectedLocalEntries} contains any
871     * {@link LocalAddeEntry}s.
872     *
873     * @return Whether or not any {@code LocalAddeEntry} values are selected.
874     */
875    private boolean hasLocalSelection() {
876        return !selectedLocalEntries.isEmpty();
877    }
878
879    /**
880     * Checks to see if the user has select a <b>single</b> remote dataset.
881     * 
882     * @return {@code true} if there is a single remote dataset selected.
883     * {@code false} otherwise.
884     */
885    private boolean hasSingleRemoteSelection() {
886        String entryText = null;
887        boolean result = true;
888        for (RemoteAddeEntry entry : selectedRemoteEntries) {
889            if (entryText == null) {
890                entryText = entry.getEntryText();
891            }
892            if (!entry.getEntryText().equals(entryText)) {
893                result = false;
894                break;
895            }
896        }
897        return result;
898    }
899
900    /**
901     * Checks to see if the user has select a <b>single</b> local dataset.
902     * 
903     * @return {@code true} if there is a single local dataset selected. {@code false} otherwise.
904     */
905    private boolean hasSingleLocalSelection() {
906        return selectedLocalEntries.size() == 1;
907    }
908
909    /**
910     * If there is a single local dataset selected, this method will return that
911     * dataset.
912     * 
913     * @return Either the single selected local dataset, or {@link LocalAddeEntry#INVALID_ENTRY}.
914     */
915    private LocalAddeEntry getSingleLocalSelection() {
916        LocalAddeEntry entry = LocalAddeEntry.INVALID_ENTRY;
917        if (selectedLocalEntries.size() == 1) {
918            entry = selectedLocalEntries.get(0);
919        }
920        return entry;
921    }
922
923    /**
924     * Corresponds to the selected remote ADDE entries in the GUI.
925     * 
926     * @param entries Should not be {@code null}.
927     */
928    private void setSelectedRemoteEntries(final Collection<RemoteAddeEntry> entries) {
929        selectedRemoteEntries.clear();
930        selectedRemoteEntries.addAll(entries);
931        this.editRemoteButton.setEnabled(entries.size() == 1);
932        this.removeRemoteButton.setEnabled(!entries.isEmpty());
933        logger.trace("remote entries={}", entries);
934    }
935
936    /**
937     * Gets the selected remote ADDE entries.
938     * 
939     * @return Either an empty list or the remote entries selected in the GUI.
940     */
941    private List<RemoteAddeEntry> getSelectedRemoteEntries() {
942        List<RemoteAddeEntry> selected = Collections.emptyList();
943        if (!selectedRemoteEntries.isEmpty()) {
944            selected = arrList(selectedRemoteEntries);
945        }
946        return selected;
947    }
948
949    /**
950     * Corresponds to the selected local ADDE entries in the GUI.
951     * 
952     * @param entries Should not be {@code null}.
953     */
954    private void setSelectedLocalEntries(final Collection<LocalAddeEntry> entries) {
955        selectedLocalEntries.clear();
956        selectedLocalEntries.addAll(entries);
957        this.editLocalButton.setEnabled(entries.size() == 1);
958        this.removeLocalButton.setEnabled(!entries.isEmpty());
959        logger.trace("local entries={}", entries);
960    }
961
962    /**
963     * Gets the selected local ADDE entries.
964     * 
965     * @return Either an empty list or the local entries selected in the GUI.
966     */
967    private List<LocalAddeEntry> getSelectedLocalEntries() {
968        List<LocalAddeEntry> selected = Collections.emptyList();
969        if (!selectedLocalEntries.isEmpty()) {
970            selected = arrList(selectedLocalEntries);
971        }
972        return selected;
973    }
974
975    /**
976     * Handles the user closing the server manager GUI.
977     * 
978     * @param evt Event that triggered this method call. Currently ignored.
979     * 
980     * @see #closeManager()
981     */
982    private void formWindowClosed(WindowEvent evt) {
983        closeManager();
984    }
985
986    @SuppressWarnings({"MagicNumber"})
987    private JPanel makeFileChooserAccessory() {
988        assert SwingUtilities.isEventDispatchThread();
989        JPanel accessory = new JPanel();
990        accessory.setLayout(new BoxLayout(accessory, BoxLayout.PAGE_AXIS));
991        importAccountBox = new JCheckBox("Use ADDE Accounting?");
992        importAccountBox.setSelected(false);
993        importAccountBox.addActionListener(evt -> {
994            boolean selected = importAccountBox.isSelected();
995            importUser.setEnabled(selected);
996            importProject.setEnabled(selected);
997        });
998        String clientProp = "JComponent.sizeVariant";
999        String propVal = "mini";
1000
1001        importUser = new JTextField();
1002        importUser.putClientProperty(clientProp, propVal);
1003        Prompt userPrompt = new Prompt(importUser, "Username");
1004        userPrompt.putClientProperty(clientProp, propVal);
1005        importUser.setEnabled(importAccountBox.isSelected());
1006
1007        importProject = new JTextField();
1008        Prompt projPrompt = new Prompt(importProject, "Project Number");
1009        projPrompt.putClientProperty(clientProp, propVal);
1010        importProject.putClientProperty(clientProp, propVal);
1011        importProject.setEnabled(importAccountBox.isSelected());
1012
1013        GroupLayout layout = new GroupLayout(accessory);
1014        accessory.setLayout(layout);
1015        layout.setHorizontalGroup(
1016            layout.createParallelGroup(GroupLayout.Alignment.LEADING)
1017            .addComponent(importAccountBox)
1018            .addGroup(layout.createSequentialGroup()
1019                .addContainerGap()
1020                .addGroup(layout.createParallelGroup(GroupLayout.Alignment.TRAILING, false)
1021                    .addComponent(importProject, GroupLayout.Alignment.LEADING)
1022                    .addComponent(importUser, GroupLayout.Alignment.LEADING, GroupLayout.DEFAULT_SIZE, 131, Short.MAX_VALUE)))
1023        );
1024        layout.setVerticalGroup(
1025            layout.createParallelGroup(GroupLayout.Alignment.LEADING)
1026            .addGroup(layout.createSequentialGroup()
1027                .addComponent(importAccountBox)
1028                .addPreferredGap(LayoutStyle.ComponentPlacement.RELATED)
1029                .addComponent(importUser, GroupLayout.PREFERRED_SIZE, GroupLayout.DEFAULT_SIZE, GroupLayout.PREFERRED_SIZE)
1030                .addPreferredGap(LayoutStyle.ComponentPlacement.RELATED)
1031                .addComponent(importProject, GroupLayout.PREFERRED_SIZE, GroupLayout.DEFAULT_SIZE, GroupLayout.PREFERRED_SIZE)
1032                .addContainerGap(55, (int)Short.MAX_VALUE))
1033        );
1034        return accessory;
1035    }
1036
1037    private void importButtonActionPerformed(ActionEvent evt) {
1038        assert SwingUtilities.isEventDispatchThread();
1039        JFileChooser fc = new JFileChooser(getLastImportPath());
1040        fc.setAccessory(makeFileChooserAccessory());
1041        fc.setFileSelectionMode(JFileChooser.FILES_ONLY);
1042        int ret = fc.showOpenDialog(this);
1043        if (ret == JFileChooser.APPROVE_OPTION) {
1044            File f = fc.getSelectedFile();
1045            String path = f.getPath();
1046
1047            boolean defaultUser = false;
1048            String forceUser = safeGetText(importUser);
1049            if (forceUser.isEmpty()) {
1050                forceUser = AddeEntry.DEFAULT_ACCOUNT.getUsername();
1051                defaultUser = true;
1052            }
1053
1054            boolean defaultProj = false;
1055            String forceProj = safeGetText(importProject);
1056            if (forceProj.isEmpty()) {
1057                forceProj = AddeEntry.DEFAULT_ACCOUNT.getProject();
1058                defaultProj = true;
1059            }
1060
1061            if (importAccountBox.isSelected() && (defaultUser || defaultProj)) {
1062                logger.warn("bad acct dialog: forceUser={} forceProj={}", forceUser, forceProj);
1063            } else {
1064                logger.warn("acct appears valid: forceUser={} forceProj={}", forceUser, forceProj);
1065                importMctable(path, forceUser, forceProj);
1066                // don't worry about file validity; i'll just assume the user clicked
1067                // on the wrong entry by accident.
1068                setLastImportPath(f.getParent());
1069            }
1070        }
1071    }
1072
1073    /**
1074     * Returns the directory that contained the most recently imported MCTABLE.TXT.
1075     *
1076     * @return Either the path to the most recently imported MCTABLE.TXT file,
1077     * or an empty {@code String}.
1078     */
1079    private String getLastImportPath() {
1080        String lastPath = serverManager.getIdvStore().get(LAST_IMPORTED, "");
1081        logger.trace("last path='{}'", lastPath);
1082        return lastPath;
1083    }
1084
1085    /**
1086     * Saves the directory that contained the most recently imported MCTABLE.TXT.
1087     *
1088     * @param path Path to the most recently imported MCTABLE.TXT file.
1089     * {@code null} values are replaced with an empty {@code String}.
1090     */
1091    private void setLastImportPath(final String path) {
1092        String okayPath = (path == null) ? "" : path;
1093        logger.trace("saving path='{}'", okayPath);
1094        serverManager.getIdvStore().put(LAST_IMPORTED, okayPath);
1095    }
1096
1097    /**
1098     * Returns the index of the user's last server manager tab.
1099     *
1100     * @return Index of the user's most recently viewed server manager tab, or {@code 0}.
1101     */
1102    private int getLastTab() {
1103        int index = serverManager.getIdvStore().get(LAST_TAB, 0);
1104        logger.trace("last tab={}", index);
1105        return index;
1106    }
1107
1108    /**
1109     * Saves the index of the last server manager tab the user was looking at.
1110     * 
1111     * @param index Index of the user's most recently viewed server manager tab.
1112     */
1113    private void setLastTab(final int index) {
1114        int okayIndex = ((index >= 0) && (index < 2)) ? index : 0;
1115        IdvObjectStore store = serverManager.getIdvStore();
1116        logger.trace("storing tab={}", okayIndex);
1117        store.put(LAST_TAB, okayIndex);
1118    }
1119
1120    // stupid adde.ucar.edu entries never seem to time out! great! making the gui hang is just so awesome!
1121    @SuppressWarnings({"ObjectAllocationInLoop"})
1122    public Set<RemoteAddeEntry> checkDatasets(final Collection<RemoteAddeEntry> entries) {
1123        requireNonNull(entries, "can't check a null collection of entries");
1124        if (entries.isEmpty()) {
1125            return Collections.emptySet();
1126        }
1127
1128        Set<RemoteAddeEntry> valid = newLinkedHashSet();
1129        ExecutorService exec = Executors.newFixedThreadPool(POOL);
1130        CompletionService<List<RemoteAddeEntry>> ecs =
1131            new ExecutorCompletionService<>(exec);
1132        final RemoteAddeTableModel tableModel =
1133            (RemoteAddeTableModel)remoteTable.getModel();
1134
1135        // place entries
1136        for (RemoteAddeEntry entry : entries) {
1137            ecs.submit(new BetterCheckTask(entry));
1138            logger.trace("submitting entry={}", entry);
1139            final int row = tableModel.getRowForEntry(entry);
1140            runOnEDT(() -> tableModel.fireTableRowsUpdated(row, row));
1141        }
1142
1143        // work through the entries
1144        try {
1145            for (int i = 0; i < entries.size(); i++) {
1146                final List<RemoteAddeEntry> checkedEntries = ecs.take().get();
1147                if (!checkedEntries.isEmpty()) {
1148                    final int row =
1149                        tableModel.getRowForEntry(checkedEntries.get(0));
1150                    runOnEDT(() -> {
1151                        List<RemoteAddeEntry> old =
1152                            tableModel.getEntriesAtRow(row);
1153                        serverManager.replaceEntries(old, checkedEntries);
1154                        tableModel.fireTableRowsUpdated(row, row);
1155                    });
1156                }
1157                valid.addAll(checkedEntries);
1158            }
1159        } catch (InterruptedException e) {
1160            LogUtil.logException("Interrupted while validating entries", e);
1161        } catch (ExecutionException e) {
1162            LogUtil.logException("ADDE validation execution error", e);
1163        } finally {
1164            exec.shutdown();
1165        }
1166        return valid;
1167    }
1168
1169    private static class BetterCheckTask implements Callable<List<RemoteAddeEntry>> {
1170        private final RemoteAddeEntry entry;
1171        public BetterCheckTask(final RemoteAddeEntry entry) {
1172            this.entry = entry;
1173            this.entry.setEntryValidity(EntryValidity.VALIDATING);
1174        }
1175        @SuppressWarnings({"FeatureEnvy"})
1176        public List<RemoteAddeEntry> call() {
1177            List<RemoteAddeEntry> valid = arrList();
1178            if (RemoteAddeEntry.checkHost(entry)) {
1179                EntryTransforms.createEntriesFrom(entry)
1180                               .stream()
1181                               .filter(tmp -> RemoteAddeEntry.checkEntry(false, tmp) == AddeStatus.OK)
1182                               .forEach(tmp -> {
1183                                   tmp.setEntryValidity(EntryValidity.VERIFIED);
1184                                   valid.add(tmp);
1185                               });
1186            }
1187            if (valid.isEmpty()) {
1188                entry.setEntryValidity(EntryValidity.INVALID);
1189            } else {
1190                entry.setEntryValidity(EntryValidity.VERIFIED);
1191            }
1192            return valid;
1193        }
1194    }
1195
1196    private class CheckEntryTask implements Callable<RemoteAddeEntry> {
1197        private final RemoteAddeEntry entry;
1198        public CheckEntryTask(final RemoteAddeEntry entry) {
1199            requireNonNull(entry);
1200            this.entry = entry;
1201            this.entry.setEntryValidity(EntryValidity.VALIDATING);
1202        }
1203        @SuppressWarnings({"FeatureEnvy"})
1204        public RemoteAddeEntry call() {
1205            AddeStatus status = RemoteAddeEntry.checkEntry(entry);
1206            switch (status) {
1207                case OK: entry.setEntryValidity(EntryValidity.VERIFIED); break;
1208                default: entry.setEntryValidity(EntryValidity.INVALID); break;
1209            }
1210            return entry;
1211        }
1212    }
1213
1214    private static class RemoteAddeTableModel extends AbstractTableModel {
1215
1216        // TODO(jon): these constants can go once things calm down
1217        private static final int VALID = 0;
1218        private static final int SOURCE = 1;
1219        private static final int DATASET = 2;
1220        private static final int ACCT = 3;
1221        private static final int TYPES = 4;
1222        private static final Pattern ENTRY_ID_SPLITTER = Pattern.compile("!");
1223
1224        /** Labels that appear as the column headers. */
1225        private final String[] columnNames = {
1226            "Valid", "Source", "Dataset", "Accounting", "Data Types"
1227        };
1228
1229        private final List<String> servers;
1230
1231        /** {@link EntryStore} used to query and apply changes. */
1232        private final EntryStore entryStore;
1233
1234        /**
1235         * Builds an {@link javax.swing.table.AbstractTableModel} with some
1236         * extensions that facilitate working with
1237         * {@link RemoteAddeEntry RemoteAddeEntrys}.
1238         * 
1239         * @param entryStore Server manager object.
1240         */
1241        public RemoteAddeTableModel(final EntryStore entryStore) {
1242            requireNonNull(entryStore, "Cannot query a null EntryStore");
1243            this.entryStore = entryStore;
1244            this.servers = arrList(entryStore.getRemoteEntryTexts());
1245        }
1246
1247        /**
1248         * Returns the {@link RemoteAddeEntry} at the given index.
1249         * 
1250         * @param row Index of the entry.
1251         * 
1252         * @return {@code RemoteAddeEntry} at index specified by {@code row}.
1253         */
1254        protected List<RemoteAddeEntry> getEntriesAtRow(final int row) {
1255            String server = servers.get(row).replace('/', '!');
1256            List<RemoteAddeEntry> matches = arrList();
1257            matches.addAll(
1258                entryStore.searchWithPrefix(server)
1259                          .stream()
1260                          .filter(entry -> entry instanceof RemoteAddeEntry)
1261                          .map(entry -> (RemoteAddeEntry) entry)
1262                          .collect(Collectors.toList()));
1263            return matches;
1264        }
1265
1266        /**
1267         * Returns the index of the given {@code entry}.
1268         *
1269         * @param entry {@link RemoteAddeEntry} whose row is desired.
1270         *
1271         * @return Index of the desired {@code entry}, or {@code -1} if the
1272         * entry wasn't found.
1273         */
1274        protected int getRowForEntry(final RemoteAddeEntry entry) {
1275            return getRowForEntry(entry.getEntryText());
1276        }
1277
1278        /**
1279         * Returns the index of the given entry text within the table.
1280         *
1281         * @param entryText String representation of the desired entry.
1282         *
1283         * @return Index of the desired entry, or {@code -1} if the entry was
1284         * not found.
1285         *
1286         * @see AddeEntry#getEntryText()
1287         */
1288        protected int getRowForEntry(final String entryText) {
1289            return servers.indexOf(entryText);
1290        }
1291
1292        /**
1293         * Clears and re-adds all {@link RemoteAddeEntry}s within
1294         * {@link #entryStore}.
1295         */
1296        public void refreshEntries() {
1297            servers.clear();
1298            servers.addAll(entryStore.getRemoteEntryTexts());
1299            this.fireTableDataChanged();
1300        }
1301
1302        /**
1303         * Returns the length of {@link #columnNames}.
1304         * 
1305         * @return The number of columns.
1306         */
1307        @Override public int getColumnCount() {
1308            return columnNames.length;
1309        }
1310
1311        /**
1312         * Returns the number of entries being managed.
1313         */
1314        @Override public int getRowCount() {
1315            return servers.size();
1316        }
1317
1318        /**
1319         * Finds the value at the given coordinates.
1320         * 
1321         * @param row Table row.
1322         * @param column Table column.
1323         * 
1324         * @return Value stored at the given {@code row} and {@code column}
1325         * coordinates
1326         * 
1327         * @throws IndexOutOfBoundsException if {@code row} or {@code column}
1328         * refer to an invalid table cell.
1329         */
1330        @Override public Object getValueAt(int row, int column) {
1331            String serverText = servers.get(row);
1332            String prefix = serverText.replace('/', '!');
1333            switch (column) {
1334                case VALID: return formattedValidity(prefix, entryStore);
1335                case SOURCE: return formattedSource(prefix, entryStore);
1336                case DATASET: return serverText;
1337                case ACCT: return formattedAccounting(prefix, entryStore);
1338                case TYPES: return formattedTypes(prefix, entryStore);
1339                default: throw new IndexOutOfBoundsException();
1340            }
1341        }
1342
1343        private static String formattedSource(final String serv,
1344                                              final EntryStore manager)
1345        {
1346            List<AddeEntry> matches = manager.searchWithPrefix(serv);
1347            EntrySource source = EntrySource.INVALID;
1348            if (!matches.isEmpty()) {
1349                for (AddeEntry entry : matches) {
1350                    if (entry.getEntrySource() == EntrySource.USER) {
1351                        return EntrySource.USER.toString();
1352                    }
1353                }
1354              source = matches.get(0).getEntrySource();
1355            }
1356            return source.toString();
1357        }
1358
1359        private static String formattedValidity(final String serv,
1360                                                final EntryStore manager)
1361        {
1362            List<AddeEntry> matches = manager.searchWithPrefix(serv);
1363            EntryValidity validity = EntryValidity.INVALID;
1364            if (!matches.isEmpty()) {
1365                validity = matches.get(0).getEntryValidity();
1366            }
1367            return validity.toString();
1368        }
1369
1370        private static String formattedAccounting(final String serv,
1371                                                  final EntryStore manager)
1372        {
1373            List<AddeEntry> matches = manager.searchWithPrefix(serv);
1374            AddeAccount acct = AddeEntry.DEFAULT_ACCOUNT;
1375            if (!matches.isEmpty()) {
1376                acct = matches.get(0).getAccount();
1377            }
1378            if (AddeEntry.DEFAULT_ACCOUNT.equals(acct)) {
1379                return "public dataset";
1380            }
1381            return acct.friendlyString();
1382        }
1383
1384        private static boolean hasType(final String serv,
1385                                       final EntryStore manager,
1386                                       final EntryType type)
1387        {
1388            String[] chunks = ENTRY_ID_SPLITTER.split(serv);
1389            Set<EntryType> types = Collections.emptySet();
1390            if (chunks.length == 2) {
1391                types = manager.getTypes(chunks[0], chunks[1]);
1392            }
1393            return types.contains(type);
1394        }
1395
1396        private static String formattedTypes(final String serv,
1397                                             final EntryStore manager)
1398        {
1399            String[] chunks = ENTRY_ID_SPLITTER.split(serv);
1400//            Set<EntryType> types = Collections.emptySet();
1401//            if (chunks.length == 2) {
1402//                types = manager.getTypes(chunks[0], chunks[1]);
1403//            }
1404            Set<EntryType> types = chunks.length == 2
1405                                   ? manager.getTypes(chunks[0], chunks[1])
1406                                   : Collections.emptySet();
1407
1408
1409//            @SuppressWarnings({"MagicNumber"})
1410//            StringBuilder sb = new StringBuilder(30);
1411//            for (EntryType type : EnumSet.of(EntryType.IMAGE, EntryType.GRID, EntryType.NAV, EntryType.POINT, EntryType.RADAR, EntryType.TEXT)) {
1412//                if (types.contains(type)) {
1413//                    sb.append(type.toString()).append(' ');
1414//                }
1415//            }
1416            return EnumSet.of(EntryType.IMAGE, EntryType.GRID, EntryType.NAV,
1417                              EntryType.POINT, EntryType.RADAR, EntryType.TEXT
1418                   ).stream()
1419                   .filter(types::contains)
1420                   .map(String::valueOf)
1421                   .collect(Collectors.joining(" "));
1422//            return sb.toString().toLowerCase();
1423        }
1424
1425        /**
1426         * Returns the column name associated with {@code column}.
1427         * 
1428         * @return One of {@link #columnNames}.
1429         */
1430        @Override public String getColumnName(final int column) {
1431            return columnNames[column];
1432        }
1433
1434        @Override public Class<?> getColumnClass(final int column) {
1435            return String.class;
1436        }
1437
1438        @Override public boolean isCellEditable(final int row,
1439                                                final int column)
1440        {
1441            return false;
1442        }
1443    }
1444
1445    private static class LocalAddeTableModel extends AbstractTableModel {
1446
1447        /** Labels that appear as the column headers. */
1448        private final String[] columnNames = {
1449            "Dataset (e.g. MYDATA)", "Image Type (e.g. JAN 07 GOES)",
1450            "Format", "Directory"
1451        };
1452
1453        /** Entries that currently populate the server manager. */
1454        private final List<LocalAddeEntry> entries;
1455
1456        /** {@link EntryStore} used to query and apply changes. */
1457        private final EntryStore entryStore;
1458
1459        public LocalAddeTableModel(final EntryStore entryStore) {
1460            requireNonNull(entryStore, "Cannot query a null EntryStore");
1461            this.entryStore = entryStore;
1462            this.entries = arrList(entryStore.getLocalEntries());
1463        }
1464
1465        /**
1466         * Returns the {@link LocalAddeEntry} at the given index.
1467         * 
1468         * @param row Index of the entry.
1469         * 
1470         * @return {@code LocalAddeEntry} at index specified by {@code row}.
1471         */
1472        protected LocalAddeEntry getEntryAtRow(final int row) {
1473            return entries.get(row);
1474        }
1475
1476        protected int getRowForEntry(final LocalAddeEntry entry) {
1477            return entries.indexOf(entry);
1478        }
1479
1480        protected List<LocalAddeEntry> getSelectedEntries(final int[] rows) {
1481            List<LocalAddeEntry> selected = arrList(rows.length);
1482            int rowCount = entries.size();
1483            for (int tmpIdx : rows) {
1484                if ((tmpIdx >= 0) && (tmpIdx < rowCount)) {
1485                    selected.add(entries.get(tmpIdx));
1486                } else {
1487                    throw new IndexOutOfBoundsException();
1488                }
1489            }
1490            return selected;
1491        }
1492
1493        public void refreshEntries() {
1494            entries.clear();
1495            entries.addAll(entryStore.getLocalEntries());
1496            this.fireTableDataChanged();
1497        }
1498
1499        /**
1500         * Returns the length of {@link #columnNames}.
1501         * 
1502         * @return The number of columns.
1503         */
1504        @Override public int getColumnCount() {
1505            return columnNames.length;
1506        }
1507
1508        /**
1509         * Returns the number of entries being managed.
1510         */
1511        @Override public int getRowCount() {
1512            return entries.size();
1513        }
1514
1515        /**
1516         * Finds the value at the given coordinates.
1517         * 
1518         * @param row Table row.
1519         * @param column Table column.
1520         * 
1521         * @return Value stored at the given {@code row} and {@code column}
1522         * coordinates
1523         *
1524         * @throws IndexOutOfBoundsException if {@code row} or {@code column}
1525         * refer to an invalid table cell.
1526         */
1527        @Override public Object getValueAt(int row, int column) {
1528            LocalAddeEntry entry = entries.get(row);
1529            if (entry == null) {
1530                throw new IndexOutOfBoundsException(); // still questionable...
1531            }
1532
1533            switch (column) {
1534                case 0: return entry.getGroup();
1535                case 1: return entry.getName();
1536                case 2: return entry.getFormat();
1537                case 3: return entry.getMask();
1538                default: throw new IndexOutOfBoundsException();
1539            }
1540        }
1541
1542        /**
1543         * Returns the column name associated with {@code column}.
1544         * 
1545         * @return One of {@link #columnNames}.
1546         */
1547        @Override public String getColumnName(final int column) {
1548            return columnNames[column];
1549        }
1550    }
1551
1552    // i need the following icons:
1553    // something to convey entry validity: invalid, verified, unverified
1554    // a "system" entry icon (thinking of something with prominent "V")
1555    // a "mctable" entry icon (similar to above, but with a prominent "X")
1556    // a "user" entry icon (no idea yet!)
1557    public class EntrySourceRenderer extends DefaultTableCellRenderer {
1558
1559        public Component getTableCellRendererComponent(JTable table,
1560                                                       Object value,
1561                                                       boolean isSelected,
1562                                                       boolean hasFocus,
1563                                                       int row,
1564                                                       int column)
1565        {
1566            Component comp = super.getTableCellRendererComponent(table, value, isSelected, hasFocus, row, column);
1567            EntrySource source = EntrySource.valueOf((String)value);
1568            EntrySourceRenderer renderer = (EntrySourceRenderer)comp;
1569            Icon icon = null;
1570            String tooltip = null;
1571            switch (source) {
1572                case SYSTEM:
1573                    icon = system;
1574                    tooltip = "Default dataset and cannot be removed, only disabled.";
1575                    break;
1576                case MCTABLE:
1577                    icon = mctable;
1578                    tooltip = "Dataset imported from a MCTABLE.TXT.";
1579                    break;
1580                case USER:
1581                    icon = user;
1582                    tooltip = "Dataset created or altered by you!";
1583                    break;
1584            }
1585            renderer.setIcon(icon);
1586            renderer.setToolTipText(tooltip);
1587            renderer.setText(null);
1588            return comp;
1589        }
1590    }
1591
1592    public class EntryValidityRenderer extends DefaultTableCellRenderer {
1593        public Component getTableCellRendererComponent(JTable table,
1594                                                       Object value,
1595                                                       boolean isSelected,
1596                                                       boolean hasFocus,
1597                                                       int row,
1598                                                       int column)
1599        {
1600            Component comp = super.getTableCellRendererComponent(table, value, isSelected, hasFocus, row, column);
1601            EntryValidity validity = EntryValidity.valueOf((String)value);
1602            EntryValidityRenderer renderer = (EntryValidityRenderer)comp;
1603            Icon icon = null;
1604            String msg = null;
1605            String tooltip = null;
1606            switch (validity) {
1607                case INVALID:
1608                    icon = invalid;
1609                    tooltip = "Dataset verification failed.";
1610                    break;
1611                case VERIFIED:
1612                    break;
1613                case UNVERIFIED:
1614                    icon = unverified;
1615                    tooltip = "Dataset has not been verified.";
1616                    break;
1617                case VALIDATING:
1618                    msg = "Checking...";
1619                    break;
1620            }
1621            renderer.setIcon(icon);
1622            renderer.setToolTipText(tooltip);
1623            renderer.setText(msg);
1624            return comp;
1625        }
1626    }
1627
1628    public static class TextRenderer extends DefaultTableCellRenderer {
1629
1630        /** */
1631        private Font bold;
1632
1633        /** */
1634        private Font boldItalic;
1635
1636        public Component getTableCellRendererComponent(JTable table,
1637                                                       Object value,
1638                                                       boolean isSelected,
1639                                                       boolean hasFocus,
1640                                                       int row,
1641                                                       int column)
1642        {
1643            Component comp = super.getTableCellRendererComponent(table, value, isSelected, hasFocus, row, column);
1644            Font currentFont = comp.getFont();
1645            if (bold == null) {
1646                bold = currentFont.deriveFont(Font.BOLD); 
1647            }
1648            if (boldItalic == null) {
1649                boldItalic = currentFont.deriveFont(Font.BOLD | Font.ITALIC);
1650            }
1651            if (column == 2) {
1652                comp.setFont(bold);
1653            } else if (column == 3) {
1654                // why can't i set the color for just a single column!?
1655            } else if (column == 4) {
1656                comp.setFont(boldItalic);
1657            }
1658            return comp;
1659        }
1660    }
1661
1662    /**
1663     * Create an {@code Icon} from the name of one the server manager icons.
1664     *
1665     * <p>Note: this method expects the server manager icons to live in the
1666     * same directory within the McIDAS-V JAR file. Currently, the directory is
1667     * {@code /edu/wisc/ssec/mcidasv/resources/icons/servermanager/}.
1668     * 
1669     * @param icon Name of icon within server manager icon directory to use.
1670     *             Should not be {@code null}.
1671     * 
1672     * @return {@code Icon} object with the desired image.
1673     */
1674    private static Icon icon(final String icon) {
1675        // keep the trailing slash at the end of the path.
1676        StringBuilder path = new StringBuilder("/edu/wisc/ssec/mcidasv/resources/icons/servermanager/");
1677        if (McIDASV.isDarkMode()) {
1678            path.append("inverted_");
1679        }
1680        path.append(icon);
1681        return GuiUtils.getImageIcon(path.toString(), TabbedAddeManager.class, true);
1682    }
1683
1684    /**
1685     * Launch the application. Makes for a simplistic test.
1686     * 
1687     * @param args Command line arguments. These are currently ignored.
1688     */
1689    public static void main(String[] args) {
1690        SwingUtilities.invokeLater(() -> {
1691            try {
1692                TabbedAddeManager frame = new TabbedAddeManager();
1693                frame.setVisible(true);
1694            } catch (Exception e) {
1695                logger.error("Problem creating TabbedAddeManager", e);
1696            }
1697        });
1698    }
1699
1700    private JPanel contentPane;
1701    private JTable remoteTable;
1702    private JTable localTable;
1703    private JTabbedPane tabbedPane;
1704    private JLabel statusLabel;
1705    private JButton newRemoteButton;
1706    private JButton editRemoteButton;
1707    private JButton removeRemoteButton;
1708    private JButton importRemoteButton;
1709    private JButton newLocalButton;
1710    private JButton editLocalButton;
1711    private JButton removeLocalButton;
1712    private JButton okButton;
1713    private JMenuItem editMenuItem;
1714    private JMenuItem removeMenuItem;
1715    private JCheckBox importAccountBox;
1716
1717    /** Icon for datasets that are part of a default McIDAS-V install. */
1718    private Icon system;
1719
1720    /** Icon for datasets that originate from a MCTABLE.TXT. */
1721    private Icon mctable;
1722
1723    /** Icon for datasets that the user has provided. */
1724    private Icon user;
1725
1726    /** Icon for invalid datasets. */
1727    private Icon invalid;
1728
1729    /** Icon for datasets that have not been verified. */
1730    private Icon unverified;
1731}