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}