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    private static final Logger logger
068        = Logger.getLogger(WebConsole.class.getName());
069
070    private ConsoleWeblet view;
071
072    /**
073     * @param componentChannel
074     */
075    /* default */ WebConsole(Channel componentChannel) {
076        super(componentChannel);
077    }
078
079    /* default */ void setView(ConsoleWeblet view) {
080        this.view = view;
081        MBeanView.addConsole(this);
082    }
083
084    /**
085     * Provides access to the weblet's channel.
086     *
087     * @return the channel
088     */
089    public Channel webletChannel() {
090        return view.channel();
091    }
092
093    /**
094     * Handle JSON input.
095     *
096     * @param event the event
097     * @param channel the channel
098     * @throws InterruptedException the interrupted exception
099     * @throws IOException Signals that an I/O exception has occurred.
100     */
101    @Handler
102    @SuppressWarnings("PMD.AvoidInstantiatingObjectsInLoops")
103    public void onJsonInput(JsonInput event, ConsoleConnection channel)
104            throws InterruptedException, IOException {
105        // Send events to web console components on console's channel
106        var request = event.request();
107        switch (request.method()) {
108        case "consoleReady": {
109            fire(new ConsoleReady(view.renderSupport()), channel);
110            break;
111        }
112        case "addConlet": {
113            fire(new AddConletRequest(view.renderSupport(),
114                request.param(0),
115                Arrays.stream((String[]) request.param(1))
116                    .map(RenderMode::valueOf)
117                    .collect(Collectors.toSet()),
118                request.params().length < 3 ? Collections.emptyMap()
119                    : request.param(2)).setFrontendRequest(),
120                channel);
121            break;
122        }
123        case "conletsDeleted": {
124            for (var conletInfo : (ConletInfo[]) request.param(0)) {
125                fire(
126                    new ConletDeleted(view.renderSupport(),
127                        conletInfo.conletId(),
128                        conletInfo.modes().stream().map(RenderMode::valueOf)
129                            .collect(Collectors.toSet()),
130                        Optional.ofNullable(conletInfo.opts())
131                            .orElse(Collections.emptyMap())),
132                    channel);
133            }
134            break;
135        }
136        case "consoleLayout": {
137            String[] previewLayout = request.param(0);
138            String[] tabsLayout = request.param(1);
139            Object xtraInfo = request.param(2);
140            fire(new ConsoleLayoutChanged(
141                previewLayout, tabsLayout, xtraInfo), channel);
142            break;
143        }
144        case "renderConlet": {
145            fire(new RenderConletRequest(view.renderSupport(),
146                request.param(0),
147                Arrays.stream((String[]) request.param(1))
148                    .map(RenderMode::valueOf).collect(Collectors.toSet())),
149                channel);
150            break;
151        }
152        case "setLocale": {
153            fire(new SetLocale(view.renderSupport(),
154                Locale.forLanguageTag(request.param(0)),
155                request.param(1)), channel);
156            break;
157        }
158        case "notifyConletModel": {
159            fire(new NotifyConletModel(view.renderSupport(),
160                request.param(0), request.param(1),
161                request.params().length <= 2 ? new Object[0]
162                    : request.param(2)),
163                channel);
164            break;
165        }
166        default:
167            // Ignore unknown
168            break;
169        }
170    }
171
172    /**
173     * Handle network configured condition.
174     *
175     * @param event the event
176     * @param channel the channel
177     * @throws InterruptedException the interrupted exception
178     * @throws IOException Signals that an I/O exception has occurred.
179     */
180    @Handler
181    public void onConsoleConfigured(
182            ConsoleConfigured event, ConsoleConnection channel)
183            throws InterruptedException, IOException {
184        channel.respond(new SimpleConsoleCommand("consoleConfigured"));
185    }
186
187    /**
188     * Fallback handler that sends a {@link DeleteConlet} event 
189     * if the {@link RenderConletRequest} event has not been handled
190     * successfully.
191     *
192     * @param event the event
193     * @param channel the channel
194     */
195    @Handler(priority = -1_000_000)
196    public void onRenderConlet(
197            RenderConletRequest event, ConsoleConnection channel) {
198        if (!event.hasBeenRendered()) {
199            channel.respond(
200                new DeleteConlet(event.conletId(), Collections.emptySet()));
201        }
202    }
203
204    /**
205     * Discard all console connections on stop.
206     *
207     * @param event the event
208     */
209    @Handler
210    public void onStop(Stop event) {
211        for (ConsoleConnection ps : ConsoleConnection.byConsole(this)) {
212            ps.close();
213        }
214    }
215
216    /**
217     * The MBeans view of a console.
218     */
219    @SuppressWarnings({ "PMD.CommentRequired", "PMD.AvoidDuplicateLiterals" })
220    public interface ConsoleMXBean {
221
222        @SuppressWarnings("PMD.CommentRequired")
223        class ConsoleConnectionInfo {
224
225            private final ConsoleConnection connection;
226
227            public ConsoleConnectionInfo(ConsoleConnection connection) {
228                super();
229                this.connection = connection;
230            }
231
232            public String getChannel() {
233                return connection.upstreamChannel().toString();
234            }
235
236            public String getExpiresAt() {
237                return connection.expiresAt().atZone(ZoneId.systemDefault())
238                    .toString();
239            }
240        }
241
242        String getComponentPath();
243
244        String getPrefix();
245
246        boolean isUseMinifiedResources();
247
248        void setUseMinifiedResources(boolean useMinifiedResources);
249
250        SortedMap<String, ConsoleConnectionInfo> getConsoleConnections();
251    }
252
253    @SuppressWarnings("PMD.CommentRequired")
254    public static class WebConsoleInfo implements ConsoleMXBean {
255
256        private static MBeanServer mbs
257            = ManagementFactory.getPlatformMBeanServer();
258
259        private ObjectName mbeanName;
260        private final WeakReference<WebConsole> consoleRef;
261
262        @SuppressWarnings("PMD.GuardLogStatement")
263        public WebConsoleInfo(WebConsole console) {
264            try {
265                mbeanName = new ObjectName("org.jgrapes.webconsole:type="
266                    + WebConsole.class.getSimpleName() + ",name="
267                    + ObjectName.quote(Components.simpleObjectName(console)
268                        + " (" + console.view.prefix().toString() + ")"));
269            } catch (MalformedObjectNameException e) {
270                // Should not happen
271                logger.log(Level.WARNING, e.getMessage(), e);
272            }
273            consoleRef = new WeakReference<>(console);
274            try {
275                mbs.unregisterMBean(mbeanName);
276            } catch (Exception e) { // NOPMD
277                // Just in case, should not work
278            }
279            try {
280                mbs.registerMBean(this, mbeanName);
281            } catch (InstanceAlreadyExistsException | MBeanRegistrationException
282                    | NotCompliantMBeanException e) {
283                // Should not happen
284                logger.log(Level.WARNING, e.getMessage(), e);
285            }
286        }
287
288        public Optional<WebConsole> console() {
289            WebConsole console = consoleRef.get();
290            if (console == null) {
291                try {
292                    mbs.unregisterMBean(mbeanName);
293                } catch (Exception e) { // NOPMD
294                    // Should work.
295                }
296            }
297            return Optional.ofNullable(console);
298        }
299
300        @Override
301        public String getComponentPath() {
302            return console().map(Component::componentPath)
303                .orElse("<removed>");
304        }
305
306        @Override
307        public String getPrefix() {
308            return console().map(
309                console -> console.view.prefix().toString())
310                .orElse("<unknown>");
311        }
312
313        @Override
314        public boolean isUseMinifiedResources() {
315            return console().map(
316                console -> console.view.useMinifiedResources())
317                .orElse(false);
318        }
319
320        @Override
321        public void setUseMinifiedResources(boolean useMinifiedResources) {
322            console().ifPresent(console -> console.view.setUseMinifiedResources(
323                useMinifiedResources));
324        }
325
326        @Override
327        @SuppressWarnings("PMD.AvoidInstantiatingObjectsInLoops")
328        public SortedMap<String, ConsoleConnectionInfo>
329                getConsoleConnections() {
330            SortedMap<String, ConsoleConnectionInfo> result = new TreeMap<>();
331            console().ifPresent(console -> {
332                for (ConsoleConnection ps : ConsoleConnection
333                    .byConsole(console)) {
334                    result.put(Components.simpleObjectName(ps),
335                        new ConsoleConnectionInfo(ps));
336                }
337            });
338            return result;
339        }
340    }
341
342    /**
343     * An MBean interface for getting information about all consoles.
344     * 
345     * There is currently no summary information. However, the (periodic)
346     * invocation of {@link WebConsoleSummaryMXBean#getConsoles()} ensures
347     * that entries for removed {@link WebConsole}s are unregistered.
348     */
349    @SuppressWarnings({ "PMD.CommentRequired",
350        "PMD.ImplicitFunctionalInterface" })
351    public interface WebConsoleSummaryMXBean {
352
353        Set<ConsoleMXBean> getConsoles();
354
355    }
356
357    /**
358     * Provides an MBean view of the console.
359     */
360    @SuppressWarnings({ "PMD.AvoidSynchronizedStatement",
361        "PMD.PublicMemberInNonPublicType", "PMD.CommentRequired" })
362    private static final class MBeanView implements WebConsoleSummaryMXBean {
363
364        private static Set<WebConsoleInfo> consoleInfos = new HashSet<>();
365
366        public static void addConsole(WebConsole console) {
367            synchronized (consoleInfos) {
368                consoleInfos.add(new WebConsoleInfo(console));
369            }
370        }
371
372        @Override
373        public Set<ConsoleMXBean> getConsoles() {
374            Set<WebConsoleInfo> expired = new HashSet<>();
375            synchronized (consoleInfos) {
376                for (WebConsoleInfo consoleInfo : consoleInfos) {
377                    if (!consoleInfo.console().isPresent()) {
378                        expired.add(consoleInfo);
379                    }
380                }
381                consoleInfos.removeAll(expired);
382            }
383            @SuppressWarnings("unchecked")
384            Set<ConsoleMXBean> result
385                = (Set<ConsoleMXBean>) (Object) consoleInfos;
386            return result;
387        }
388    }
389
390    static {
391        try {
392            MBeanServer mbs = ManagementFactory.getPlatformMBeanServer();
393            ObjectName mxbeanName
394                = new ObjectName("org.jgrapes.webconsole:type="
395                    + WebConsole.class.getSimpleName() + "s");
396            mbs.registerMBean(new MBeanView(), mxbeanName);
397        } catch (MalformedObjectNameException | InstanceAlreadyExistsException
398                | MBeanRegistrationException | NotCompliantMBeanException e) {
399            // Should not happen
400            logger.log(Level.WARNING, e.getMessage(), e);
401        }
402    }
403}