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.dev;
018
019import static java.nio.charset.StandardCharsets.*;
020import static org.apache.juneau.commons.utils.Shorts.*;
021
022import java.io.*;
023import java.time.*;
024import java.time.format.*;
025import java.util.*;
026import java.util.concurrent.atomic.*;
027
028import org.apache.juneau.commons.*;
029import org.apache.juneau.http.*;
030import org.apache.juneau.http.response.*;
031import org.apache.juneau.marshall.collections.*;
032import org.apache.juneau.petstore.console.*;
033import org.apache.juneau.petstore.console.pages.*;
034import org.apache.juneau.rest.server.*;
035import org.apache.juneau.rest.server.view.*;
036
037/**
038 * Dashboard: the dashboard-chrome demo page, built with {@link DevPageBuilders#dashboard(String, int)} only.
039 *
040 * <p>
041 * {@code ?variant=} picks how the {@code orders} source behaves ({@code ok}, {@code slow}, {@code truncated} or
042 * {@code error}; {@code right} behaves as {@code ok} and puts the rail on the right) and {@code ?v=} sets the persist version.  {@code /series/<variant>} serves one CSV row per UTC day in
043 * {@code [start, end)}, filtered by {@code reasons}, {@code q} and {@code lateOnly}; {@code /requests} counts the series
044 * requests, so a test can see Refresh and Clear cache reach the server.
045 *
046 * <h5 class='section'>Example:</h5>
047 * <p class='bcode'>
048 *    GET /console/dev/dashboard?variant=slow
049 *    GET /console/dev/dashboard/series/ok?start=2026-10-01T00:00:00Z&amp;end=2026-10-08T00:00:00Z&amp;reasons=late
050 * </p>
051 */
052@Rest(path="/dashboard", title="Dashboard")
053@SuppressWarnings({
054   "java:S110", // Inheritance depth comes from the BasicRestServlet hierarchy, not this page.
055   "resource" // The response writer is owned by the servlet container; Eclipse JDT @Owning warning is by design.
056})
057public class DashboardRest extends PetstoreConsolePage {
058
059   private static final long serialVersionUID = 1L;
060
061   /** Series requests served since startup, for the Refresh and Clear cache checks. */
062   static final AtomicInteger SERIES_REQUESTS = new AtomicInteger();
063
064   private static final List<String> VARIANTS = List.of("ok", "slow", "truncated", "error", "right");
065   private static final List<String> REASONS = List.of("late", "lost", "damaged", "wrong-item");
066   private static final String[] WEEKDAYS = {"Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"};
067   private static final LocalDate FIXED_START = LocalDate.of(2026, 9, 1);
068   private static final int FIXED_DAYS = 28;
069   private static final int TRUNCATE_AT = 20;
070   private static final long SLOW_MS = 2000;
071   private static final Duration MAX_WINDOW = Duration.ofDays(400);
072
073   /**
074    * Renders the Dashboard page.
075    *
076    * @param req The request.
077    * @param variant The source variant; anything unknown is {@code ok}.
078    * @param v The persist version; below 1 (or absent) is 1.
079    * @return The page.
080    */
081   @RestGet(path="/")
082   public View page(RestRequest req, @Query("variant") String variant, @Query("v") Integer v) {
083      return twin(req, DevPageBuilders.dashboard(variant != null && VARIANTS.contains(variant) ? variant : "ok", v == null || v < 1 ? 1 : v));
084   }
085
086   /**
087    * The {@code orders} source: {@code day,reason,orders}, one row per UTC day.
088    *
089    * @param req The request (for the repeated {@code reasons} parameter).
090    * @param res The response.
091    * @param variant {@code ok}, {@code slow} (half the rows, a pause, the rest), {@code truncated} (at most 20 rows and
092    *    the {@code #truncated,20} sentinel) or {@code error} (a 500).
093    * @param q Keeps rows whose reason contains this text, case-insensitively; empty keeps every row.
094    * @param start The window start, ISO-8601; defaults to 30 days before {@code end}.
095    * @param end The window end, ISO-8601, exclusive; defaults to the start of tomorrow (UTC).
096    * @param lateOnly {@code true} keeps only {@code late}.
097    * @throws IOException If the response can't be written.
098    */
099   @RestGet(path="/series/{variant}")
100   @SuppressWarnings({
101      "java:S3776" // Branching is inherent to the demo source's variants.
102   })
103   public void series(RestRequest req, RestResponse res, @Path("variant") String variant, @Query("q") @Schema(aev=true) String q,
104         @Query("start") String start, @Query("end") String end, @Query("lateOnly") String lateOnly) throws IOException {
105      SERIES_REQUESTS.incrementAndGet();
106      if ("error".equals(variant))
107         throw new InternalServerError("The demo source failed on purpose (variant=error).");
108      var reasons = new HashSet<String>();
109      req.getQueryParams().getAll("reasons").forEach(p -> reasons.add(p.getValue()));
110      var e = instant(end, LocalDate.now(ZoneOffset.UTC).plusDays(1).atStartOfDay(ZoneOffset.UTC).toInstant());
111      var s = instant(start, e.minus(Duration.ofDays(30)));
112      if (s.isBefore(e.minus(MAX_WINDOW)))
113         s = e.minus(MAX_WINDOW);
114      var rows = rows(s, e, reasons, q == null || q.isEmpty() ? null : q.toLowerCase(Locale.ROOT), "true".equals(lateOnly));
115      var truncated = "truncated".equals(variant) && rows.size() > TRUNCATE_AT;
116      if (truncated)
117         rows = rows.subList(0, TRUNCATE_AT);
118      res.setContentType("text/csv;charset=utf-8");
119      res.setHeader("X-Content-Type-Options", "nosniff");
120      var w = res.getWriter();
121      w.write("day,reason,orders\n");
122      var half = "slow".equals(variant) ? rows.size() / 2 : rows.size();
123      for (var i = 0; i < rows.size(); i++) {
124         if (i == half && i > 0) {
125            w.flush();
126            res.flushBuffer();
127            try {
128               Thread.sleep(SLOW_MS);
129            } catch (InterruptedException x) {
130               Thread.currentThread().interrupt();
131               return;
132            }
133         }
134         w.write(rows.get(i));
135      }
136      if (truncated)
137         w.write("#truncated," + TRUNCATE_AT + "\n");
138   }
139
140   /**
141    * Orders by reason over a fixed 28 days, for the {@code by-reason} bar and the {@code share} donut.
142    *
143    * @return One row per reason.
144    */
145   @RestGet(path="/reasons")
146   public List<JsonMap> reasons() {
147      var totals = new LinkedHashMap<String,Integer>();
148      REASONS.forEach(r -> totals.put(r, 0));
149      for (var i = 0; i < FIXED_DAYS; i++) {
150         var n = FIXED_START.plusDays(i).toEpochDay();
151         totals.merge(reason(n), orders(n), Integer::sum);
152      }
153      var l = new ArrayList<JsonMap>();
154      totals.forEach((r, o) -> l.add(JsonMap.of("reason", r, "orders", o)));
155      return l;
156   }
157
158   /**
159    * Orders by weekday over the same fixed 28 days, for the {@code by-weekday} bar.
160    *
161    * @return One row per weekday, Monday first.
162    */
163   @RestGet(path="/weekdays")
164   public List<JsonMap> weekdays() {
165      var totals = new int[7];
166      for (var i = 0; i < FIXED_DAYS; i++) {
167         var day = FIXED_START.plusDays(i);
168         totals[day.getDayOfWeek().getValue() - 1] += orders(day.toEpochDay());
169      }
170      var l = new ArrayList<JsonMap>();
171      for (var i = 0; i < 7; i++)
172         l.add(JsonMap.of("weekday", WEEKDAYS[i], "orders", totals[i]));
173      return l;
174   }
175
176   /**
177    * The series request counter.
178    *
179    * @return {@code {series: n}}.
180    */
181   @RestGet(path="/requests")
182   public JsonMap requests() {
183      return JsonMap.of("series", SERIES_REQUESTS.get());
184   }
185
186   /**
187    * Serves the page-local script (the {@code petstore.late} rail section).
188    *
189    * @param res The response.
190    * @throws IOException If the bundled script can't be read.
191    */
192   @RestGet(path="/dashboard.js")
193   public void script(RestResponse res) throws IOException {
194      try (var in = DashboardRest.class.getResourceAsStream("dashboard.js")) {
195         if (in == null)
196            throw new IllegalStateException("Missing classpath resource 'dashboard.js' beside " + DashboardRest.class.getName());
197         res.setContentType("text/javascript;charset=utf-8");
198         res.getWriter().write(new String(in.readAllBytes(), UTF_8));
199      }
200   }
201
202   private static List<String> rows(Instant start, Instant end, Set<String> reasons, String q, boolean lateOnly) {
203      var out = new ArrayList<String>();
204      for (var day = start.atZone(ZoneOffset.UTC).toLocalDate(); day.atStartOfDay(ZoneOffset.UTC).toInstant().isBefore(end); day = day.plusDays(1)) {
205         var n = day.toEpochDay();
206         var r = reason(n);
207         if ((reasons.isEmpty() || reasons.contains(r)) && (q == null || r.contains(q)) && (! lateOnly || "late".equals(r)))
208            out.add(day.atStartOfDay(ZoneOffset.UTC).toInstant() + "," + r + "," + orders(n) + "\n");
209      }
210      return out;
211   }
212
213   private static String reason(long epochDay) {
214      return REASONS.get((int) Math.floorMod(epochDay, 4L));
215   }
216
217   private static int orders(long epochDay) {
218      return 10 + (int) Math.floorMod(epochDay * 7, 23L);
219   }
220
221   private static Instant instant(String v, Instant def) {
222      if (ib(v))
223         return def;
224      try {
225         return Instant.parse(v);
226      } catch (DateTimeParseException x) {
227         return def;
228      }
229   }
230}