001/*
002 * JGrapes Event Driven Framework
003 * Copyright (C) 2017-2024 Michael N. Lipp
004 * 
005 * This program is free software; you can redistribute it and/or modify it 
006 * under the terms of the GNU Affero General Public License as published by 
007 * the Free Software Foundation; either version 3 of the License, or 
008 * (at your option) any later version.
009 * 
010 * This program is distributed in the hope that it will be useful, but 
011 * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
012 * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License 
013 * for more details.
014 * 
015 * You should have received a copy of the GNU Affero General Public License along 
016 * with this program; if not, see <http://www.gnu.org/licenses/>.
017 */
018
019package org.jgrapes.webconsole.base;
020
021import java.io.IOException;
022import java.lang.management.ManagementFactory;
023import java.lang.ref.WeakReference;
024import java.time.ZoneId;
025import java.util.Arrays;
026import java.util.Collections;
027import java.util.HashSet;
028import java.util.Locale;
029import java.util.Optional;
030import java.util.Set;
031import java.util.SortedMap;
032import java.util.TreeMap;
033import java.util.logging.Level;
034import java.util.logging.Logger;
035import java.util.stream.Collectors;
036import javax.management.InstanceAlreadyExistsException;
037import javax.management.MBeanRegistrationException;
038import javax.management.MBeanServer;
039import javax.management.MalformedObjectNameException;
040import javax.management.NotCompliantMBeanException;
041import javax.management.ObjectName;
042import org.jgrapes.core.Channel;
043import org.jgrapes.core.Component;
044import org.jgrapes.core.Components;
045import org.jgrapes.core.annotation.Handler;
046import org.jgrapes.core.events.Stop;
047import org.jgrapes.webconsole.base.Conlet.RenderMode;
048import org.jgrapes.webconsole.base.WcJsonRpc.ConletInfo;
049import org.jgrapes.webconsole.base.events.AddConletRequest;
050import org.jgrapes.webconsole.base.events.ConletDeleted;
051import org.jgrapes.webconsole.base.events.ConsoleConfigured;
052import org.jgrapes.webconsole.base.events.ConsoleLayoutChanged;
053import org.jgrapes.webconsole.base.events.ConsoleReady;
054import org.jgrapes.webconsole.base.events.DeleteConlet;
055import org.jgrapes.webconsole.base.events.JsonInput;
056import org.jgrapes.webconsole.base.events.NotifyConletModel;
057import org.jgrapes.webconsole.base.events.RenderConletRequest;
058import org.jgrapes.webconsole.base.events.SetLocale;
059import org.jgrapes.webconsole.base.events.SimpleConsoleCommand;
060
061/**
062 * Provides the web console component related part of the console.
063 */
064@SuppressWarnings("PMD.GuardLogStatement")
065public class WebConsole extends Component {
066
067    @SuppressWarnings("PMD.FieldNamingConventions")
068    private static final Logger logger
069        = Logger.getLogger(WebConsole.class.getName());
070
071    private ConsoleWeblet view;
072
073    /**
074     * @param componentChannel
075     */
076    /* default */ WebConsole(Channel componentChannel) {
077        super(componentChannel);
078    }
079
080    /* default */ void setView(ConsoleWeblet view) {
081        this.view = view;
082        MBeanView.addConsole(this);
083    }
084
085    /**
086     * Provides access to the weblet's channel.
087     *
088     * @return the channel
089     */
090    public Channel webletChannel() {
091        return view.channel();
092    }
093
094    /**
095     * Handle JSON input.
096     *
097     * @param event the event
098     * @param channel the channel
099     * @throws InterruptedException the interrupted exception
100     * @throws IOException Signals that an I/O exception has occurred.
101     */
102    @Handler
103    @SuppressWarnings({ "PMD.DataflowAnomalyAnalysis",
104        "PMD.AvoidInstantiatingObjectsInLoops", "PMD.NcssCount" })
105    public void onJsonInput(JsonInput event, ConsoleConnection channel)
106            throws InterruptedException, IOException {
107        // Send events to web console components on console's channel
108        var request = event.request();
109        switch (request.method()) {
110        case "consoleReady": {
111            fire(new ConsoleReady(view.renderSupport()), channel);
112            break;
113        }
114        case "addConlet": {
115            fire(new AddConletRequest(view.renderSupport(),
116                request.param(0),
117                Arrays.stream((String[]) request.param(1)).map(
118                    value -> RenderMode.valueOf(value))
119                    .collect(Collectors.toSet()),
120                request.params().length < 3 ? Collections.emptyMap()
121                    : request.param(2)).setFrontendRequest(),
122                channel);
123            break;
124        }
125        case "conletsDeleted": {
126            for (var conletInfo : (ConletInfo[]) request.param(0)) {
127                fire(
128                    new ConletDeleted(view.renderSupport(),
129                        conletInfo.conletId(),
130                        conletInfo.modes().stream().map(
131                            value -> RenderMode.valueOf((String) value))
132                            .collect(Collectors.toSet()),
133                        Optional.ofNullable(conletInfo.opts())
134                            .orElse(Collections.emptyMap())),
135                    channel);
136            }
137            break;
138        }
139        case "consoleLayout": {
140            String[] previewLayout = request.param(0);
141            String[] tabsLayout = request.param(1);
142            Object xtraInfo = request.param(2);
143            fire(new ConsoleLayoutChanged(
144                previewLayout, tabsLayout, xtraInfo), channel);
145            break;
146        }
147        case "renderConlet": {
148            fire(new RenderConletRequest(view.renderSupport(),
149                request.param(0),
150                Arrays.stream((String[]) request.param(1)).map(
151                    value -> RenderMode.valueOf(value))
152                    .collect(Collectors.toSet())),
153                channel);
154            break;
155        }
156        case "setLocale": {
157            fire(new SetLocale(view.renderSupport(),
158                Locale.forLanguageTag(request.param(0)),
159                request.param(1)), channel);
160            break;
161        }
162        case "notifyConletModel": {
163            fire(new NotifyConletModel(view.renderSupport(),
164                request.param(0), request.param(1),
165                request.params().length <= 2 ? new Object[0]
166                    : request.param(2)),
167                channel);
168            break;
169        }
170        default:
171            // Ignore unknown
172            break;
173        }
174    }
175
176    /**
177     * Handle network configured condition.
178     *
179     * @param event the event
180     * @param channel the channel
181     * @throws InterruptedException the interrupted exception
182     * @throws IOException Signals that an I/O exception has occurred.
183     */
184    @Handler
185    public void onConsoleConfigured(
186            ConsoleConfigured event, ConsoleConnection channel)
187            throws InterruptedException, IOException {
188        channel.respond(new SimpleConsoleCommand("consoleConfigured"));
189    }
190
191    /**
192     * Fallback handler that sends a {@link DeleteConlet} event 
193     * if the {@link RenderConletRequest} event has not been handled
194     * successfully.
195     *
196     * @param event the event
197     * @param channel the channel
198     */
199    @Handler(priority = -1_000_000)
200    public void onRenderConlet(
201            RenderConletRequest event, ConsoleConnection channel) {
202        if (!event.hasBeenRendered()) {
203            channel.respond(
204                new DeleteConlet(event.conletId(), Collections.emptySet()));
205        }
206    }
207
208    /**
209     * Discard all console connections on stop.
210     *
211     * @param event the event
212     */
213    @Handler
214    public void onStop(Stop event) {
215        for (ConsoleConnection ps : ConsoleConnection.byConsole(this)) {
216            ps.close();
217        }
218    }
219
220    /**
221     * The MBeans view of a console.
222     */
223    @SuppressWarnings({ "PMD.CommentRequired", "PMD.AvoidDuplicateLiterals" })
224    public interface ConsoleMXBean {
225
226        @SuppressWarnings("PMD.CommentRequired")
227        class ConsoleConnectionInfo {
228
229            private final ConsoleConnection connection;
230
231            public ConsoleConnectionInfo(ConsoleConnection connection) {
232                super();
233                this.connection = connection;
234            }
235
236            public String getChannel() {
237                return connection.upstreamChannel().toString();
238            }
239
240            public String getExpiresAt() {
241                return connection.expiresAt().atZone(ZoneId.systemDefault())
242                    .toString();
243            }
244        }
245
246        String getComponentPath();
247
248        String getPrefix();
249
250        boolean isUseMinifiedResources();
251
252        void setUseMinifiedResources(boolean useMinifiedResources);
253
254        SortedMap<String, ConsoleConnectionInfo> getConsoleConnections();
255    }
256
257    @SuppressWarnings("PMD.CommentRequired")
258    public static class WebConsoleInfo implements ConsoleMXBean {
259
260        private static MBeanServer mbs
261            = ManagementFactory.getPlatformMBeanServer();
262
263        private ObjectName mbeanName;
264        private final WeakReference<WebConsole> consoleRef;
265
266        @SuppressWarnings("PMD.GuardLogStatement")
267        public WebConsoleInfo(WebConsole console) {
268            try {
269                mbeanName = new ObjectName("org.jgrapes.webconsole:type="
270                    + WebConsole.class.getSimpleName() + ",name="
271                    + ObjectName.quote(Components.simpleObjectName(console)
272                        + " (" + console.view.prefix().toString() + ")"));
273            } catch (MalformedObjectNameException e) {
274                // Should not happen
275                logger.log(Level.WARNING, e.getMessage(), e);
276            }
277            consoleRef = new WeakReference<>(console);
278            try {
279                mbs.unregisterMBean(mbeanName);
280            } catch (Exception e) { // NOPMD
281                // Just in case, should not work
282            }
283            try {
284                mbs.registerMBean(this, mbeanName);
285            } catch (InstanceAlreadyExistsException | MBeanRegistrationException
286                    | NotCompliantMBeanException e) {
287                // Should not happen
288                logger.log(Level.WARNING, e.getMessage(), e);
289            }
290        }
291
292        public Optional<WebConsole> console() {
293            WebConsole console = consoleRef.get();
294            if (console == null) {
295                try {
296                    mbs.unregisterMBean(mbeanName);
297                } catch (Exception e) { // NOPMD
298                    // Should work.
299                }
300            }
301            return Optional.ofNullable(console);
302        }
303
304        @Override
305        public String getComponentPath() {
306            return console().map(Component::componentPath)
307                .orElse("<removed>");
308        }
309
310        @Override
311        public String getPrefix() {
312            return console().map(
313                console -> console.view.prefix().toString())
314                .orElse("<unknown>");
315        }
316
317        @Override
318        public boolean isUseMinifiedResources() {
319            return console().map(
320                console -> console.view.useMinifiedResources())
321                .orElse(false);
322        }
323
324        @Override
325        public void setUseMinifiedResources(boolean useMinifiedResources) {
326            console().ifPresent(console -> console.view.setUseMinifiedResources(
327                useMinifiedResources));
328        }
329
330        @Override
331        @SuppressWarnings("PMD.AvoidInstantiatingObjectsInLoops")
332        public SortedMap<String, ConsoleConnectionInfo>
333                getConsoleConnections() {
334            SortedMap<String, ConsoleConnectionInfo> result = new TreeMap<>();
335            console().ifPresent(console -> {
336                for (ConsoleConnection ps : ConsoleConnection
337                    .byConsole(console)) {
338                    result.put(Components.simpleObjectName(ps),
339                        new ConsoleConnectionInfo(ps));
340                }
341            });
342            return result;
343        }
344    }
345
346    /**
347     * An MBean interface for getting information about all consoles.
348     * 
349     * There is currently no summary information. However, the (periodic)
350     * invocation of {@link WebConsoleSummaryMXBean#getConsoles()} ensures
351     * that entries for removed {@link WebConsole}s are unregistered.
352     */
353    @SuppressWarnings("PMD.CommentRequired")
354    public interface WebConsoleSummaryMXBean {
355
356        Set<ConsoleMXBean> getConsoles();
357
358    }
359
360    /**
361     * Provides an MBean view of the console.
362     */
363    @SuppressWarnings("PMD.CommentRequired")
364    private static final class MBeanView implements WebConsoleSummaryMXBean {
365
366        private static Set<WebConsoleInfo> consoleInfos = new HashSet<>();
367
368        @SuppressWarnings("PMD.AvoidSynchronizedStatement")
369        public static void addConsole(WebConsole console) {
370            synchronized (consoleInfos) {
371                consoleInfos.add(new WebConsoleInfo(console));
372            }
373        }
374
375        @Override
376        @SuppressWarnings("PMD.AvoidSynchronizedStatement")
377        public Set<ConsoleMXBean> getConsoles() {
378            Set<WebConsoleInfo> expired = new HashSet<>();
379            synchronized (consoleInfos) {
380                for (WebConsoleInfo consoleInfo : consoleInfos) {
381                    if (!consoleInfo.console().isPresent()) {
382                        expired.add(consoleInfo);
383                    }
384                }
385                consoleInfos.removeAll(expired);
386            }
387            @SuppressWarnings("unchecked")
388            Set<ConsoleMXBean> result
389                = (Set<ConsoleMXBean>) (Object) consoleInfos;
390            return result;
391        }
392    }
393
394    static {
395        try {
396            MBeanServer mbs = ManagementFactory.getPlatformMBeanServer();
397            ObjectName mxbeanName
398                = new ObjectName("org.jgrapes.webconsole:type="
399                    + WebConsole.class.getSimpleName() + "s");
400            mbs.registerMBean(new MBeanView(), mxbeanName);
401        } catch (MalformedObjectNameException | InstanceAlreadyExistsException
402                | MBeanRegistrationException | NotCompliantMBeanException e) {
403            // Should not happen
404            logger.log(Level.WARNING, e.getMessage(), e);
405        }
406    }
407}