001/*
002 * JGrapes Event Driven Framework
003 * Copyright (C) 2017-2018 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.util.Collections;
023import java.util.HashMap;
024import java.util.List;
025import java.util.Map;
026import java.util.Optional;
027import org.jdrupes.json.JsonBeanDecoder;
028import org.jdrupes.json.JsonBeanEncoder;
029import org.jdrupes.json.JsonDecodeException;
030import org.jdrupes.json.JsonObject;
031import org.jgrapes.core.Channel;
032import org.jgrapes.core.Component;
033import org.jgrapes.core.Event;
034import org.jgrapes.core.annotation.Handler;
035import org.jgrapes.http.Session;
036import org.jgrapes.io.IOSubchannel;
037import org.jgrapes.util.events.KeyValueStoreQuery;
038import org.jgrapes.util.events.KeyValueStoreUpdate;
039import org.jgrapes.webconsole.base.Conlet.RenderMode;
040import org.jgrapes.webconsole.base.events.ConsoleLayoutChanged;
041import org.jgrapes.webconsole.base.events.ConsolePrepared;
042import org.jgrapes.webconsole.base.events.LastConsoleLayout;
043import org.jgrapes.webconsole.base.events.RenderConletRequest;
044
045/**
046 * A component that restores the console layout
047 * using key/value events for persisting the data between sessions.
048 * 
049 * <img src="KVPPBootSeq.svg" alt="Boot Event Sequence">
050 * 
051 * This component requires another component that handles the key/value
052 * store events ({@link KeyValueStoreUpdate}, {@link KeyValueStoreQuery})
053 * used by this component for implementing persistence. When the web console 
054 * becomesready, this policy sends a query for the persisted data.
055 * 
056 * When the web console has been prepared, the policy sends the last layout
057 * as retrieved from persistent storage to the web console and then generates
058 * render events for all web console components contained in this layout.
059 * 
060 * Each time the layout is changed in the web console, the web console sends 
061 * the new layout data and this component updates the persistent storage
062 * accordingly.
063 * 
064 * @startuml KVPPBootSeq.svg
065 * hide footbox
066 * 
067 * actor System
068 * System -> KVStoreBasedConsolePolicy: ConsolePrepared
069 * activate KVStoreBasedConsolePolicy
070 * KVStoreBasedConsolePolicy -> "KV Store": KeyValueStoreQuery
071 * deactivate KVStoreBasedConsolePolicy
072 * activate "KV Store"
073 * "KV Store" -> KVStoreBasedConsolePolicy: KeyValueStoreData
074 * deactivate "KV Store"
075 * activate KVStoreBasedConsolePolicy
076 * KVStoreBasedConsolePolicy -> WebConsole: LastConsoleLayout
077 * activate WebConsole
078 * WebConsole -> Browser: "lastConsoleLayout"
079 * deactivate WebConsole
080 * loop for all conlets to be displayed
081 *     KVStoreBasedConsolePolicy -> ConletX: RenderConletRequest
082 *     activate ConletX
083 *     ConletX -> WebConsole: RenderConlet
084 *     deactivate ConletX
085 *     activate WebConsole
086 *     WebConsole -> Browser: "renderConlet"
087 *     deactivate WebConsole
088 * end
089 * deactivate KVStoreBasedConsolePolicy
090 * 
091 * Browser -> WebConsole: "consoleLayout"
092 * activate WebConsole
093 * WebConsole -> KVStoreBasedConsolePolicy: ConsoleLayoutChanged
094 * deactivate WebConsole
095 * activate KVStoreBasedConsolePolicy
096 * KVStoreBasedConsolePolicy -> "KV Store": KeyValueStoreUpdate
097 * deactivate KVStoreBasedConsolePolicy
098 * activate "KV Store"
099 * deactivate "KV Store"
100 * 
101 * @enduml
102 */
103public class KVStoreBasedConsolePolicy extends Component {
104
105    /**
106     * Creates a new component with its channel set to
107     * itself.
108     */
109    public KVStoreBasedConsolePolicy() {
110        // Everything done by super.
111    }
112
113    /**
114     * Creates a new component with its channel set to the given channel.
115     * 
116     * @param componentChannel
117     */
118    public KVStoreBasedConsolePolicy(Channel componentChannel) {
119        super(componentChannel);
120    }
121
122    /**
123     * Create browser session scoped storage and forward event to it.
124     *
125     * @param event the event
126     * @param channel the channel
127     */
128    @Handler
129    public void onConsolePrepared(
130            ConsolePrepared event, ConsoleConnection channel) {
131        ((KVStoredLayoutData) channel.session().transientData()
132            .computeIfAbsent(KVStoredLayoutData.class,
133                key -> new KVStoredLayoutData(channel.session())))
134                    .onConsolePrepared(event, channel);
135    }
136
137    /**
138     * Forward layout changed event to browser session scoped storage.
139     *
140     * @param event the event
141     * @param channel the channel
142     * @throws IOException Signals that an I/O exception has occurred.
143     */
144    @Handler
145    public void onConsoleLayoutChanged(ConsoleLayoutChanged event,
146            ConsoleConnection channel) throws IOException {
147        Optional<KVStoredLayoutData> optDs = Optional.ofNullable(
148            (KVStoredLayoutData) channel.session().transientData()
149                .get(KVStoredLayoutData.class));
150        if (optDs.isPresent()) {
151            optDs.get().onConsoleLayoutChanged(event, channel);
152        }
153    }
154
155    /**
156     * Caches the data in the session.
157     */
158    @SuppressWarnings("PMD.CommentRequired")
159    private class KVStoredLayoutData {
160
161        private final String storagePath;
162        private Map<String, Object> persisted;
163
164        public KVStoredLayoutData(Session session) {
165            storagePath = "/"
166                + WebConsoleUtils.userFromSession(session)
167                    .map(ConsoleUser::getName).orElse("")
168                + "/" + KVStoreBasedConsolePolicy.class.getName();
169        }
170
171        @SuppressWarnings({ "PMD.DataflowAnomalyAnalysis",
172            "PMD.AvoidInstantiatingObjectsInLoops" })
173        public void onConsolePrepared(
174                ConsolePrepared event, IOSubchannel channel) {
175            KeyValueStoreQuery query = new KeyValueStoreQuery(
176                storagePath, channel);
177            Event.onCompletion(query, e -> onQueryCompleted(e, channel,
178                event.event().renderSupport()));
179            fire(query, channel);
180        }
181
182        @SuppressWarnings("PMD.AvoidInstantiatingObjectsInLoops")
183        public void onQueryCompleted(KeyValueStoreQuery query,
184                IOSubchannel channel, RenderSupport renderSupport) {
185            try {
186                String data = Optional.ofNullable(query.get())
187                    .flatMap(m -> Optional.ofNullable(m.get(storagePath)))
188                    .orElse(null);
189                if (data == null) {
190                    persisted = new HashMap<>();
191                } else {
192                    JsonBeanDecoder decoder = JsonBeanDecoder.create(data);
193                    @SuppressWarnings({ "unchecked", "PMD.LooseCoupling" })
194                    Class<Map<String, Object>> cls
195                        = (Class<Map<String, Object>>) (Class<?>) HashMap.class;
196                    persisted = decoder.readObject(cls);
197                }
198            } catch (InterruptedException | JsonDecodeException e) {
199                persisted = new HashMap<>();
200            }
201
202            // Make sure data is consistent
203            @SuppressWarnings("unchecked")
204            List<String> previewLayout = (List<String>) persisted
205                .computeIfAbsent("previewLayout",
206                    newKey -> Collections.emptyList());
207            @SuppressWarnings("unchecked")
208            List<String> tabsLayout = (List<String>) persisted.computeIfAbsent(
209                "tabsLayout", newKey -> Collections.emptyList());
210            JsonObject xtraInfo = (JsonObject) persisted.computeIfAbsent(
211                "xtraInfo", newKey -> JsonObject.create());
212
213            // Update (now consistent) layout
214            channel.respond(new LastConsoleLayout(
215                previewLayout, tabsLayout, xtraInfo));
216
217            // Restore conlets
218            for (String conletId : tabsLayout) {
219                fire(new RenderConletRequest(renderSupport, conletId,
220                    RenderMode.asSet(RenderMode.View)), channel);
221            }
222            for (String conletId : previewLayout) {
223                fire(new RenderConletRequest(renderSupport, conletId,
224                    RenderMode.asSet(RenderMode.Preview,
225                        RenderMode.Foreground)),
226                    channel);
227            }
228        }
229
230        public void onConsoleLayoutChanged(ConsoleLayoutChanged event,
231                IOSubchannel channel) throws IOException {
232            persisted.put("previewLayout", event.previewLayout());
233            persisted.put("tabsLayout", event.tabsLayout());
234            persisted.put("xtraInfo", event.xtraInfo());
235
236            // Now store.
237            JsonBeanEncoder encoder = JsonBeanEncoder.create();
238            encoder.writeObject(persisted);
239            fire(new KeyValueStoreUpdate()
240                .update(storagePath, encoder.toJson()), channel);
241        }
242
243    }
244}