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.microservice.resources; 018 019import static org.apache.juneau.commons.utils.StringUtils.*; 020import static org.apache.juneau.commons.utils.Utils.*; 021 022import java.io.*; 023import java.nio.charset.*; 024import java.util.*; 025 026import org.apache.juneau.annotation.*; 027import org.apache.juneau.bean.*; 028import org.apache.juneau.commons.time.*; 029import org.apache.juneau.config.*; 030import org.apache.juneau.html.annotation.*; 031import org.apache.juneau.http.annotation.*; 032import org.apache.juneau.http.response.*; 033import org.apache.juneau.rest.*; 034import org.apache.juneau.rest.annotation.*; 035import org.apache.juneau.rest.beans.*; 036import org.apache.juneau.rest.converter.*; 037import org.apache.juneau.rest.servlet.*; 038 039/** 040 * REST resource for viewing and accessing log files. 041 */ 042@Rest( 043 path="/logs", 044 title="Log files", 045 description="Log files from this service", 046 allowedMethodParams="*" 047) 048@HtmlConfig(uriAnchorText="PROPERTY_NAME") 049@SuppressWarnings("javadoc") 050public class LogsResource extends BasicRestServlet { 051 @Response(schema = @Schema(description = "File action")) 052 public static class Action extends LinkString { 053 public Action(String name, String uri, Object...uriArgs) { 054 super(name, uri, uriArgs); 055 } 056 057 @Override /* Overridden from LinkString */ 058 public Action setName(String value) { 059 super.setName(value); 060 return this; 061 } 062 063 @Override /* Overridden from LinkString */ 064 public Action setUri(java.net.URI value) { 065 super.setUri(value); 066 return this; 067 } 068 069 @Override /* Overridden from LinkString */ 070 public Action setUri(String value) { 071 super.setUri(value); 072 return this; 073 } 074 075 @Override /* Overridden from LinkString */ 076 public Action setUri(String value, Object...args) { 077 super.setUri(value, args); 078 return this; 079 } 080 } 081 082 @Response(schema = @Schema(description = "File or directory details")) 083 @Bean(properties = "type,name,size,lastModified,actions,files") 084 public static class FileResource { 085 static final FileFilter FILE_FILTER = f -> f.isDirectory() || f.getName().endsWith(".log"); 086 static final Comparator<FileResource> FILE_COMPARATOR = (o1, o2) -> { 087 int c = o1.getType().compareTo(o2.getType()); 088 return c != 0 ? c : o1.getName().compareTo(o2.getName()); 089 }; 090 private final File f; 091 private final String path; 092 093 private final String uri; 094 095 private final boolean includeChildren, allowDeletes; 096 097 public FileResource(File f, String path, boolean allowDeletes, boolean includeChildren) { 098 this.f = f; 099 this.path = path; 100 this.uri = "servlet:/" + (path == null ? "" : path); 101 this.includeChildren = includeChildren; 102 this.allowDeletes = allowDeletes; 103 } 104 105 @Html(format = HtmlFormat.HTML_CDC) 106 public List<Action> getActions() throws Exception { 107 var l = new ArrayList<Action>(); 108 if (f.canRead() && ! f.isDirectory()) { 109 l.add(new Action("view", uri + "?method=VIEW")); 110 l.add(new Action("highlighted", uri + "?method=VIEW&highlight=true")); 111 l.add(new Action("parsed", uri + "?method=PARSE")); 112 l.add(new Action("download", uri + "?method=DOWNLOAD")); 113 if (allowDeletes) 114 l.add(new Action("delete", uri + "?method=DELETE")); 115 } 116 return l; 117 } 118 119 public Set<FileResource> getFiles() { 120 if (f.isFile() || ! includeChildren) 121 return null; 122 var s = new TreeSet<>(FILE_COMPARATOR); 123 for (var fc : f.listFiles(FILE_FILTER)) 124 s.add(new FileResource(fc, (nn(path) ? (path + '/') : "") + urlEncode(fc.getName()), allowDeletes, false)); 125 return s; 126 } 127 128 public Date getLastModified() { return new Date(f.lastModified()); } 129 130 public LinkString getName() { return new LinkString(f.getName(), uri); } 131 132 public long getSize() { return f.isDirectory() ? f.listFiles().length : f.length(); } 133 134 public String getType() { return (f.isDirectory() ? "dir" : "file"); } 135 } 136 137 @Response(schema = @Schema(type = "string", format = "binary", description = "Contents of file")) 138 static class FileContents extends FileInputStream { 139 public FileContents(File file) throws FileNotFoundException { 140 super(file); 141 } 142 } 143 144 @Response(schema = @Schema(description = "Redirect to root page on success")) 145 static class RedirectToRoot extends SeeOtherRoot {} 146 147 private static final long serialVersionUID = 1L; 148 149 private static BufferedReader getReader(File f) throws IOException { 150 return new BufferedReader(new InputStreamReader(new FileInputStream(f), Charset.defaultCharset())); 151 } 152 153 private File logDir; 154 155 private LogEntryFormatter leFormatter; 156 157 boolean allowDeletes; 158 159 @RestDelete( 160 path="/*", 161 summary="Delete log file", 162 description="Delete a log file on the file system." 163 ) 164 public RedirectToRoot deleteFile(@Path("/*") String path) throws MethodNotAllowed { 165 deleteFile(getFile(path)); 166 return new RedirectToRoot(); 167 } 168 @RestOp( 169 method="DOWNLOAD", 170 path="/*", 171 summary="Download file", 172 description="Download the contents of a file.\nContent-Type is set to 'application/octet-stream'." 173 ) 174 public FileContents downloadFile(RestResponse res, @Path("/*") String path) throws NotFound, MethodNotAllowed { 175 res.setContentType("application/octet-stream"); 176 try { 177 return new FileContents(getFile(path)); 178 } catch (@SuppressWarnings("unused") FileNotFoundException e) { 179 throw new NotFound("File not found"); 180 } 181 } 182 183 @RestGet( 184 path="/*", 185 summary="View information on file or directory", 186 description="Returns information about the specified file or directory." 187 ) 188 @HtmlDocConfig( 189 nav={"<h5>Folder: $RA{fullPath}</h5>"} 190 ) 191 public FileResource getFile(RestRequest req, @Path("/*") String path) throws NotFound, Exception { 192 193 var dir = getFile(path); 194 req.setAttribute("fullPath", dir.getAbsolutePath()); 195 196 return new FileResource(dir, path, allowDeletes, true); 197 } 198 199 @RestInit 200 public void init(Config config) throws Exception { 201 logDir = new File(config.get("Logging/logDir").asString().orElse("logs")); 202 allowDeletes = config.get("Logging/allowDeletes").asBoolean().orElse(true); 203 leFormatter = new LogEntryFormatter( 204 config.get("Logging/format").asString().orElse("[{date} {level}] {msg}%n"), 205 config.get("Logging/dateFormat").asString().orElse("yyyy.MM.dd hh:mm:ss"), 206 config.get("Logging/useStackTraceHashes").asBoolean().orElse(true) 207 ); 208 } 209 210 @SuppressWarnings("resource") 211 @RestOp( 212 method="VIEW", 213 path="/*", 214 summary="View contents of log file", 215 description="View the contents of a log file." 216 ) 217 public void viewFile( 218 RestResponse res, 219 @Path("/*") String path, 220 @Query(name="highlight", schema=@Schema(d="Add severity color highlighting.")) boolean highlight, 221 @Query(name="start", schema=@Schema(d="Start timestamp (ISO8601, full or partial).\nDon't print lines logged before the specified timestamp.\nUse any of the following formats: yyyy, yyyy-MM, yyyy-MM-dd, yyyy-MM-ddThh, yyyy-MM-ddThh:mm, yyyy-MM-ddThh:mm:ss, yyyy-MM-ddThh:mm:ss.SSS")) String start, 222 @Query(name="end", schema=@Schema(d="End timestamp (ISO8601, full or partial).\nDon't print lines logged after the specified timestamp.\nUse any of the following formats: yyyy, yyyy-MM, yyyy-MM-dd, yyyy-MM-ddThh, yyyy-MM-ddThh:mm, yyyy-MM-ddThh:mm:ss, yyyy-MM-ddThh:mm:ss.SSS")) String end, 223 @Query(name="thread", schema=@Schema(d="Thread name filter.\nOnly show log entries with the specified thread name.")) String thread, 224 @Query(name="loggers", schema=@Schema(d="Logger filter (simple class name).\nOnly show log entries if they were produced by one of the specified loggers.")) String[] loggers, 225 @Query(name="severity",schema=@Schema( d="Severity filter.\nOnly show log entries with the specified severity.")) String[] severity 226 ) throws NotFound, MethodNotAllowed, IOException { 227 228 var f = getFile(path); 229 230 var startDate = opt(start).filter(x1 -> ! isBlank(x1)).map(x2 -> GranularZonedDateTime.of(start).getZonedDateTime()).map(GregorianCalendar::from).map(x -> x.getTime()).orElse(null); 231 var endDate = opt(end).filter(x11 -> ! isBlank(x11)).map(x4 -> GranularZonedDateTime.of(end).getZonedDateTime()).map(GregorianCalendar::from).map(x3 -> x3.getTime()).orElse(null); 232 233 if (! highlight) { 234 var o = getReader(f, startDate, endDate, thread, loggers, severity); 235 res.setContentType("text/plain"); 236 if (o instanceof Reader) 237 res.setContent(o); 238 else { 239 try (var p = (LogParser)o; Writer w = res.getNegotiatedWriter()) { 240 p.writeTo(w); 241 } 242 } 243 return; 244 } 245 246 res.setContentType("text/html"); 247 try (var w = res.getNegotiatedWriter()) { 248 w.println("<html><body style='font-family:monospace;font-size:8pt;white-space:pre;'>"); 249 try (var lp = getLogParser(f, startDate, endDate, thread, loggers, severity)) { 250 if (! lp.hasNext()) 251 w.append("<span style='color:gray'>[EMPTY]</span>"); 252 else 253 for (var le : lp) { 254 var s = le.severity.charAt(0); 255 String color = "black"; 256 //SEVERE|WARNING|INFO|CONFIG|FINE|FINER|FINEST 257 if (s == 'I') 258 color = "#006400"; 259 else if (s == 'W') 260 color = "#CC8400"; 261 else if (s == 'E' || s == 'S') 262 color = "#DD0000"; 263 else if (s == 'D' || s == 'F' || s == 'T') 264 color = "#000064"; 265 w.append("<span style='color:").append(color).append("'>"); 266 le.appendHtml(w).append("</span>"); 267 } 268 w.append("</body></html>"); 269 } 270 } 271 } 272 @RestOp( 273 method="PARSE", 274 path="/*", 275 converters=Queryable.class, 276 summary="View parsed contents of file", 277 description="View the parsed contents of a file.", 278 swagger=@OpSwagger( 279 parameters={ 280 Queryable.SWAGGER_PARAMS 281 } 282 ) 283 ) 284 @HtmlDocConfig( 285 nav={"<h5>Folder: $RA{fullPath}</h5>"} 286 ) 287 public LogParser viewParsedEntries( 288 RestRequest req, 289 @Path("/*") String path, 290 @Query(name="start", schema=@Schema(d="Start timestamp (ISO8601, full or partial).\nDon't print lines logged before the specified timestamp.\nUse any of the following formats: yyyy, yyyy-MM, yyyy-MM-dd, yyyy-MM-ddThh, yyyy-MM-ddThh:mm, yyyy-MM-ddThh:mm:ss, yyyy-MM-ddThh:mm:ss.SSS")) String start, 291 @Query(name="end", schema=@Schema(d="End timestamp (ISO8601, full or partial).\nDon't print lines logged after the specified timestamp.\nUse any of the following formats: yyyy, yyyy-MM, yyyy-MM-dd, yyyy-MM-ddThh, yyyy-MM-ddThh:mm, yyyy-MM-ddThh:mm:ss, yyyy-MM-ddThh:mm:ss.SSS")) String end, 292 @Query(name="thread", schema=@Schema(d="Thread name filter.\nOnly show log entries with the specified thread name.")) String thread, 293 @Query(name="loggers", schema=@Schema(d="Logger filter (simple class name).\nOnly show log entries if they were produced by one of the specified loggers.")) String[] loggers, 294 @Query(name="severity", schema=@Schema(d="Severity filter.\nOnly show log entries with the specified severity.")) String[] severity 295 ) throws NotFound, IOException { 296 297 var f = getFile(path); 298 req.setAttribute("fullPath", f.getAbsolutePath()); 299 300 var startDate = opt(start).filter(x1 -> ! isBlank(x1)).map(x2 -> GranularZonedDateTime.of(start).getZonedDateTime()).map(GregorianCalendar::from).map(x -> x.getTime()).orElse(null); 301 var endDate = opt(end).filter(x11 -> ! isBlank(x11)).map(x4 -> GranularZonedDateTime.of(end).getZonedDateTime()).map(GregorianCalendar::from).map(x3 -> x3.getTime()).orElse(null); 302 303 return getLogParser(f, startDate, endDate, thread, loggers, severity); 304 } 305 306 private void deleteFile(File f) { 307 if (! allowDeletes) 308 throw new MethodNotAllowed("DELETE not enabled"); 309 if (f.isDirectory()) { 310 var files = f.listFiles(); 311 if (nn(files)) { 312 for (var fc : files) 313 deleteFile(fc); 314 } 315 } 316 if (! f.delete()) 317 throw new Forbidden("Could not delete file {0}", f.getAbsolutePath()); 318 } 319 320 private File getFile(String path) throws NotFound { 321 if (path == null) 322 return logDir; 323 var f = new File(logDir.getAbsolutePath() + '/' + path); 324 if (f.exists()) 325 return f; 326 throw new NotFound("File not found."); 327 } 328 329 private LogParser getLogParser(File f, Date start, Date end, String thread, String[] loggers, String[] severity) throws IOException { 330 return new LogParser(leFormatter, f, start, end, thread, loggers, severity); 331 } 332 333 private Object getReader(File f, Date start, Date end, String thread, String[] loggers, String[] severity) throws IOException { 334 if (start == null && end == null && thread == null && loggers == null) 335 return getReader(f); 336 return getLogParser(f, start, end, thread, loggers, severity); 337 } 338}