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}