001/*
002 * Licensed to the Apache Software Foundation (ASF) under one or more
003 * contributor license agreements.  See the NOTICE file distributed with
004 * this work for additional information regarding copyright ownership.
005 * The ASF licenses this file to You under the Apache License, Version 2.0
006 * (the "License"); you may not use this file except in compliance with
007 * the License.  You may obtain a copy of the License at
008 *
009 *      http://www.apache.org/licenses/LICENSE-2.0
010 *
011 * Unless required by applicable law or agreed to in writing, software
012 * distributed under the License is distributed on an "AS IS" BASIS,
013 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
014 * See the License for the specific language governing permissions and
015 * limitations under the License.
016 */
017package org.apache.juneau.petstore.console.ops;
018
019import static java.nio.charset.StandardCharsets.*;
020import static org.apache.juneau.commons.utils.Shorts.*;
021
022import java.io.*;
023import java.nio.file.*;
024import java.time.*;
025import java.util.*;
026import java.util.concurrent.*;
027import java.util.concurrent.atomic.*;
028
029import org.apache.juneau.marshall.collections.*;
030import org.apache.juneau.petstore.console.data.*;
031import org.apache.juneau.terminal.region.*;
032
033/**
034 * The groom-pet demo runs behind the Jobs page's terminal cards.
035 *
036 * <p>
037 * A run writes about 60 ANSI-styled lines to a {@link MemoryTerminalSource}, one step per tick: INFO and FINE lines
038 * (FINE is dim), a WARNING, {@code check}/{@code cross} marks, a progress strip of coloured blocks, a multi-line
039 * SEVERE entry, bold marker lines, and a canned ANSI transcript written as-is.  Each tick also appends one ANSI line to
040 * a temp file, read through one {@link FileTerminalSource} per run.  A verbose run first writes {@value #VERBOSE_LINES}
041 * FINE lines.
042 *
043 * <p>
044 * Run ids are random 128-bit hex strings, so a terminal can be read only by someone who was given its id.
045 *
046 * <h5 class='section'>Example:</h5>
047 * <p class='bjava'>
048 *    <jk>try</jk> (<jk>var</jk> <jv>runs</jv> = <jk>new</jk> GroomRuns(650)) {
049 *       <jk>var</jk> <jv>run</jv> = <jv>runs</jv>.start(<js>"Rex"</js>, <jk>false</jk>, <jk>null</jk>);
050 *       <jk>var</jk> <jv>source</jv> = <jv>runs</jv>.source(<jv>run</jv>.id()).orElseThrow();
051 *    }
052 * </p>
053 */
054@SuppressWarnings({
055   "java:S1192", // The script's literals are demo text; constants would obscure it.
056   "resource" // Each Run owns its file-backed source, closed by drop()/close(); seeded() hands the new GroomRuns to its caller.
057})
058public final class GroomRuns implements AutoCloseable {
059
060   /** System property: milliseconds between steps. */
061   public static final String PERIOD_PROPERTY = "petstore.groom.periodMs";
062
063   /** The default step period; 53 steps take about 35 seconds. */
064   public static final long DEFAULT_PERIOD_MS = 650;
065
066   /** Suffix of the log id that selects a run's file-backed source. */
067   public static final String FILE_SUFFIX = "-file";
068
069   /** The in-memory terminal's ring capacity in bytes; it holds a verbose run whole. */
070   static final int RING_BYTES = 1 << 20;
071
072   /** The terminal size, in columns and rows, of both of a run's terminals. */
073   static final int COLS = 100;
074
075   /** The terminal height, in rows, of both of a run's terminals. */
076   static final int ROWS = 24;
077
078   private static final String RESET = "\u001b[0m";
079   private static final String BOLD = "\u001b[1m";
080   private static final String DIM = "\u001b[2m";
081   private static final String RED = "\u001b[31m";
082   private static final String GREEN = "\u001b[32m";
083   private static final String YELLOW = "\u001b[33m";
084
085   /** How many runs are kept; the oldest is dropped, and its file deleted, beyond this. */
086   static final int MAX_RUNS = 20;
087
088   /** FINE lines a verbose run writes up front. */
089   static final int VERBOSE_LINES = 8000;
090
091   /** Wash passes; the progress strip has one block per pass. */
092   static final int WASH_PASSES = 10;
093
094   /** The canned ANSI transcript: an OSC title (stripped), bold, colours, and a carriage-return counter. */
095   static final List<String> TRANSCRIPT = List.of(
096      "\u001b]0;groom-pet\u0007\u001b[1mgroomer 2.1\u001b[0m starting",
097      "\u001b[34mfetch\u001b[0m shampoo \u001b[32mok\u001b[0m",
098      "\u001b[34mfetch\u001b[0m towels \u001b[32mok\u001b[0m",
099      "drying  10%\rdrying  40%\rdrying  70%\rdrying 100%",
100      "\u001b[33mwarn\u001b[0m the clippers are dull",
101      "\u001b[1;31merror\u001b[0m no bow in stock; using a ribbon",
102      "\u001b[1;32mready\u001b[0m"
103   );
104
105   /** One step of the script. */
106   @FunctionalInterface
107   interface Step {
108      void run(Run r) throws IOException;
109   }
110
111   /** The script, one step per tick. */
112   static final List<Step> SCRIPT = script();
113
114   /** One groom run. */
115   public static final class Run {
116      final String id;
117      final String pet;
118      final boolean verbose;
119      final Instant submitted = PetstoreSeed.now();
120      final MemoryTerminalSource terminal = MemoryTerminalSource.create(RING_BYTES, COLS, ROWS);
121      final Path file;
122      final Path sizeFile;
123      final AtomicBoolean fileDone = new AtomicBoolean();
124      final FileTerminalSource fileSource;
125      final AtomicInteger lines = new AtomicInteger();
126      final AtomicInteger step = new AtomicInteger();
127      volatile boolean done;
128      final AtomicReference<ScheduledFuture<?>> task = new AtomicReference<>();
129      volatile String key;
130
131      Run(String id, String pet, boolean verbose, Path file) {
132         this.id = id;
133         this.pet = pet;
134         this.verbose = verbose;
135         this.file = file;
136         sizeFile = file.resolveSibling(file.getFileName() + ".size");
137         fileSource = FileTerminalSource.create(file).terminal(fileDone::get).build();
138      }
139
140      /** @return The run id, which is also its in-memory terminal id. */
141      public String id() { return id; }
142
143      /** @return The pet name. */
144      public String pet() { return pet; }
145
146      /** @return The in-memory terminal. */
147      public MemoryTerminalSource terminal() { return terminal; }
148
149      /** @return The cached file-backed source over this run's transcript. */
150      public FileTerminalSource fileSource() { return fileSource; }
151
152      /** @return The temp file the run appends to. */
153      public Path file() { return file; }
154
155      /** @return Whether the run has written its last line. */
156      public boolean done() { return done; }
157
158      /** @return The Groom runs table row. */
159      public JsonMap row() {
160         return JsonMap.of("id", id, "pet", pet, "submitted", submitted.toString(), "state", done ? "DONE" : "RUNNING", "lines", lines.get(), "verbose", verbose);
161      }
162
163      void line(String text) {
164         var b = (text + "\r\n").getBytes(UTF_8);
165         terminal.write(b, 0, b.length);
166         lines.incrementAndGet();
167      }
168
169      void raw(String text) {
170         for (var l : text.split("\n"))
171            line(l);
172      }
173   }
174
175   private final long periodMs;
176   private final Map<String,Run> runs = new ConcurrentHashMap<>();
177   private final Map<String,String> keys = new ConcurrentHashMap<>();
178   private final Deque<Run> order = new ArrayDeque<>(); // Newest first; guarded by itself.
179   private final ScheduledExecutorService ticker = Executors.newSingleThreadScheduledExecutor(r -> {
180      var t = new Thread(r, "petstore-groom");
181      t.setDaemon(true);
182      return t;
183   });
184
185   /**
186    * Constructor.
187    *
188    * @param periodMs Milliseconds between steps, at least 1.
189    */
190   public GroomRuns(long periodMs) {
191      if (periodMs < 1)
192         throw new IllegalArgumentException(f("periodMs must be at least 1; got %s", periodMs));
193      this.periodMs = periodMs;
194   }
195
196   /**
197    * Creates the runs and starts one verbose run, so the Jobs page always has a log to show.
198    *
199    * @param periodMs Milliseconds between steps.
200    * @return The runs.
201    */
202   public static GroomRuns seeded(long periodMs) {
203      var g = new GroomRuns(periodMs);
204      try {
205         g.start("Biscuit", true, null);
206      } catch (IOException e) {
207         g.close();
208         throw new UncheckedIOException(e);
209      }
210      return g;
211   }
212
213   /**
214    * Starts a run, or returns the run already started with the same idempotency key.
215    *
216    * @param pet The pet name; blank means {@code "Rex"}.
217    * @param verbose Whether to write {@value #VERBOSE_LINES} FINE lines first.
218    * @param idempotencyKey The client's key, or <jk>null</jk>.
219    * @return The run.
220    * @throws IOException If the temp file cannot be created.
221    */
222   @SuppressWarnings({
223      "java:S5443" // Files.createTempFile creates the file owner-only (rw-------) on POSIX
224   })
225   public Run start(String pet, boolean verbose, String idempotencyKey) throws IOException {
226      synchronized (order) {
227         if (idempotencyKey != null) {
228            var prior = keys.get(idempotencyKey);
229            if (prior != null && runs.containsKey(prior))
230               return runs.get(prior);
231         }
232         var name = pet == null || pet.isBlank() ? "Rex" : pet.strip();
233         if (name.length() > 40)
234            name = name.substring(0, 40);
235         var file = Files.createTempFile("petstore-groom-", ".log");
236         file.toFile().deleteOnExit();
237         var r = new Run(UUID.randomUUID().toString().replace("-", ""), name, verbose, file);
238         Files.writeString(r.sizeFile, "{\"cols\":" + COLS + ",\"rows\":" + ROWS + "}", UTF_8);
239         r.sizeFile.toFile().deleteOnExit();
240         if (verbose)
241            verboseLines().forEach(r::line);
242         runs.put(r.id, r);
243         order.addFirst(r);
244         if (idempotencyKey != null) {
245            r.key = idempotencyKey;
246            keys.put(idempotencyKey, r.id);
247         }
248         while (order.size() > MAX_RUNS)
249            drop(order.removeLast());
250         r.task.set(ticker.scheduleAtFixedRate(() -> tick(r), periodMs, periodMs, TimeUnit.MILLISECONDS));
251         return r;
252      }
253   }
254
255   /**
256    * @param id A run id, or <jk>null</jk>.
257    * @return The run.
258    */
259   public Optional<Run> get(String id) {
260      return id == null ? Optional.empty() : Optional.ofNullable(runs.get(id));
261   }
262
263   /** @return The newest run, or <jk>null</jk> after {@link #close()}. */
264   public Run latest() {
265      synchronized (order) {
266         return order.peekFirst();
267      }
268   }
269
270   /** @return The runs, newest first. */
271   public List<Run> list() {
272      synchronized (order) {
273         return List.copyOf(order);
274      }
275   }
276
277   /**
278    * Resolves a terminal id: {@code <runId>} is the in-memory terminal, {@code <runId>-file} the file source.
279    *
280    * @param terminalId The terminal id from the URL.
281    * @return The source, or empty for an unknown id.
282    */
283   public Optional<TerminalSource> source(String terminalId) {
284      if (terminalId == null)
285         return Optional.empty();
286      if (terminalId.endsWith(FILE_SUFFIX))
287         return get(terminalId.substring(0, terminalId.length() - FILE_SUFFIX.length())).<TerminalSource>map(Run::fileSource);
288      return get(terminalId).<TerminalSource>map(Run::terminal);
289   }
290
291   /** Stops the ticker and deletes every run's file. */
292   @Override
293   public void close() {
294      ticker.shutdownNow();
295      synchronized (order) {
296         order.forEach(this::drop);
297         order.clear();
298      }
299   }
300
301   void tick(Run r) {
302      var i = r.step.getAndIncrement();
303      if (i >= SCRIPT.size())
304         return;
305      try {
306         SCRIPT.get(i).run(r);
307         Files.writeString(r.file, fileLine(i) + "\r\n", UTF_8, StandardOpenOption.APPEND);
308      } catch (IOException | RuntimeException e) { // A failed step must not cancel the schedule silently.
309         r.line(BOLD + RED + f("Step %s failed: %s", i + 1, e.getMessage()) + RESET);
310      }
311      if (i == SCRIPT.size() - 1)
312         finish(r);
313   }
314
315   private static void finish(Run r) {
316      r.done = true;
317      r.terminal.finish();
318      r.fileDone.set(true);
319      var t = r.task.get();
320      if (t != null)
321         t.cancel(false);
322   }
323
324   private void drop(Run r) {
325      var t = r.task.get();
326      if (t != null)
327         t.cancel(false);
328      runs.remove(r.id);
329      if (r.key != null)
330         keys.remove(r.key);
331      try {
332         Files.deleteIfExists(r.file);
333         Files.deleteIfExists(r.sizeFile);
334      } catch (IOException e) {
335         // Best effort: the file is also marked deleteOnExit.
336      }
337   }
338
339   private static String fileLine(int i) {
340      if (i < TRANSCRIPT.size())
341         return TRANSCRIPT.get(i);
342      if (i == SCRIPT.size() - 1)
343         return "\u001b[1;32mgroom finished\u001b[0m";
344      return f("\u001b[1m[%02d/%s]\u001b[0m step done", i + 1, SCRIPT.size());
345   }
346
347   private static List<String> verboseLines() {
348      var out = new ArrayList<String>(VERBOSE_LINES);
349      for (var i = 1; i <= VERBOSE_LINES; i++)
350         out.add(DIM + "verbose: inspected coat patch " + i + " of " + VERBOSE_LINES + RESET);
351      return out;
352   }
353
354   static String progress() {
355      var sb = new StringBuilder("Wash passes ");
356      for (var i = 0; i < WASH_PASSES; i++)
357         sb.append(i < WASH_PASSES - 2 ? GREEN : YELLOW).append("\u25a0").append(RESET);
358      return sb.append(" (the last two need another rinse)").toString();
359   }
360
361   private static String info(String text) { return text; }
362   private static String fine(String text) { return DIM + text + RESET; }
363   private static String marker(String text) { return BOLD + text + RESET; }
364   private static String mark(boolean ok, String text) { return (ok ? GREEN + "\u2713 " : RED + "\u2717 ") + text + RESET; }
365
366   private static List<Step> script() {
367      var s = new ArrayList<Step>();
368      s.add(r -> r.line(marker("Grooming " + r.pet)));
369      s.add(r -> r.line(fine("Reading the grooming profile")));
370      for (var tool : List.of("brush", "comb", "shampoo", "conditioner", "towel", "dryer", "clippers", "nail file"))
371         s.add(r -> r.line(fine("Checked out the " + tool)));
372      s.add(r -> r.line(mark(true, "All tools ready")));
373      s.add(r -> r.line(marker("Wash")));
374      for (var i = 1; i <= WASH_PASSES; i++) {
375         var n = i;
376         s.add(r -> r.line(info(f("Wash pass %s of %s", n, WASH_PASSES))));
377      }
378      s.add(r -> r.line(YELLOW + "The water is 2 degrees cooler than the profile asks for" + RESET));
379      s.add(r -> r.line(progress()));
380      s.add(r -> r.line(marker("Dry")));
381      s.add(r -> r.raw(String.join("\n", TRANSCRIPT)));
382      for (var i = 1; i <= 10; i++) {
383         var n = i;
384         s.add(r -> r.line(info(f("Brushed section %s of 10", n))));
385      }
386      s.add(r -> r.raw(BOLD + RED + String.join("\n",
387         "Dryer overheated; switching to the spare",
388         "    at Dryer.heat(Dryer.java:42)",
389         "    at Groomer.dry(Groomer.java:17)",
390         "    at GroomRun.step(GroomRun.java:88)") + RESET));
391      s.add(r -> r.line(mark(false, "Bow tie: none in stock")));
392      s.add(r -> r.line(marker("Trim")));
393      for (var i = 1; i <= 12; i++) {
394         var n = i;
395         s.add(r -> r.line(fine(f("Trimmed nail %s of 12", n))));
396      }
397      s.add(r -> r.line(mark(true, r.pet + " is groomed")));
398      return List.copyOf(s);
399   }
400}