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&end=2026-10-08T00:00:00Z&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}