001// ***************************************************************************************************************************
002// * Licensed to the Apache Software Foundation (ASF) under one or more contributor license agreements.  See the NOTICE file *
003// * distributed with this work for additional information regarding copyright ownership.  The ASF licenses this file        *
004// * to you under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance            *
005// * with the License.  You may obtain a copy of the License at                                                              *
006// *                                                                                                                         *
007// *  http://www.apache.org/licenses/LICENSE-2.0                                                                             *
008// *                                                                                                                         *
009// * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an  *
010// * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.  See the License for the        *
011// * specific language governing permissions and limitations under the License.                                              *
012// ***************************************************************************************************************************
013package org.apache.juneau.microservice.resources;
014
015import static org.apache.juneau.html.HtmlSerializer.*;
016import static org.apache.juneau.rest.annotation.HookEvent.*;
017import static org.apache.juneau.http.HttpMethodName.*;
018import static org.apache.juneau.internal.StringUtils.*;
019
020import java.io.*;
021import java.nio.charset.*;
022import java.util.*;
023
024import org.apache.juneau.annotation.*;
025import org.apache.juneau.dto.*;
026import org.apache.juneau.html.annotation.*;
027import org.apache.juneau.http.annotation.Path;
028import org.apache.juneau.http.annotation.Query;
029import org.apache.juneau.http.annotation.Response;
030import org.apache.juneau.jsonschema.annotation.*;
031import org.apache.juneau.rest.*;
032import org.apache.juneau.rest.annotation.*;
033import org.apache.juneau.rest.converters.*;
034import org.apache.juneau.rest.exception.*;
035import org.apache.juneau.rest.helper.*;
036import org.apache.juneau.transforms.*;
037
038/**
039 * REST resource for viewing and accessing log files.
040 */
041@RestResource(
042   path="/logs",
043   title="Log files",
044   description="Log files from this service",
045   properties={
046      @Property(name=HTML_uriAnchorText, value="PROPERTY_NAME"),
047      @Property(name=LogsResource.LOGS_RESOURCE_logDir, value="$C{Logging/logDir}"),
048      @Property(name=LogsResource.LOGS_RESOURCE_allowDeletes, value="$C{Logging/allowDeletes,true}"),
049      @Property(name=LogsResource.LOGS_RESOURCE_logFormat, value="$C{Logging/format}"),
050      @Property(name=LogsResource.LOGS_RESOURCE_dateFormat, value="$C{Logging/dateFormat}"),
051      @Property(name=LogsResource.LOGS_RESOURCE_useStackTraceHashes, value="$C{Logging/useStackTraceHashes}")
052   },
053   allowedMethodParams="*",
054   pojoSwaps={
055      IteratorSwap.class,       // Allows Iterators and Iterables to be serialized.
056      DateSwap.ISO8601DT.class  // Serialize Date objects as ISO8601 strings.
057   }
058)
059@SuppressWarnings("javadoc")
060public class LogsResource extends BasicRestServlet {
061   private static final long serialVersionUID = 1L;
062
063   //-------------------------------------------------------------------------------------------------------------------
064   // Configurable properties
065   //-------------------------------------------------------------------------------------------------------------------
066
067   private static final String PREFIX = "LogsResource.";
068
069   /**
070    * Configuration property:  Root directory.
071    */
072   public static final String LOGS_RESOURCE_logDir = PREFIX + "logDir.s";
073
074   /**
075    * Configuration property:  Allow deletes on files.
076    */
077   public static final String LOGS_RESOURCE_allowDeletes = PREFIX + "allowDeletes.b";
078
079   /**
080    * Configuration property:  Log entry format.
081    */
082   public static final String LOGS_RESOURCE_logFormat = PREFIX + "logFormat.s";
083
084   /**
085    * Configuration property:  Log entry format.
086    */
087   public static final String LOGS_RESOURCE_dateFormat = PREFIX + "dateFormat.s";
088
089   /**
090    * Configuration property:  Log entry format.
091    */
092   public static final String LOGS_RESOURCE_useStackTraceHashes = PREFIX + "useStackTraceHashes.b";
093
094   //-------------------------------------------------------------------------------------------------------------------
095   // Instance
096   //-------------------------------------------------------------------------------------------------------------------
097
098   private File logDir;
099   private LogEntryFormatter leFormatter;
100   boolean allowDeletes;
101
102
103   @RestHook(INIT)
104   public void init(RestContextBuilder b) throws Exception {
105      RestContextProperties p = b.getProperties();
106      logDir = new File(p.getString(LOGS_RESOURCE_logDir));
107      allowDeletes = p.getBoolean(LOGS_RESOURCE_allowDeletes);
108      leFormatter = new LogEntryFormatter(
109         p.getString(LOGS_RESOURCE_logFormat, "[{date} {level}] {msg}%n"),
110         p.getString(LOGS_RESOURCE_dateFormat, "yyyy.MM.dd hh:mm:ss"),
111         p.getBoolean(LOGS_RESOURCE_useStackTraceHashes, true)
112      );
113   }
114
115   @RestMethod(
116      name=GET,
117      path="/*",
118      summary="View information on file or directory",
119      description="Returns information about the specified file or directory.",
120      htmldoc=@HtmlDoc(
121         nav={"<h5>Folder:  $RA{fullPath}</h5>"}
122      )
123   )
124   public FileResource getFile(RestRequest req, @Path("/*") String path) throws NotFound, Exception {
125
126      File dir = getFile(path);
127      req.setAttribute("fullPath", dir.getAbsolutePath());
128
129      return new FileResource(dir, path, allowDeletes, true);
130   }
131
132   @RestMethod(
133      name="VIEW",
134      path="/*",
135      summary="View contents of log file",
136      description="View the contents of a log file."
137   )
138   public void viewFile(
139         RestResponse res,
140         @Path("/*") String path,
141         @Query(name="highlight", description="Add severity color highlighting.", example="true") boolean highlight,
142         @Query(name="start", description="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", example="2014-01-23T11:25:47") String start,
143         @Query(name="end", description="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", example="2014-01-24") String end,
144         @Query(name="thread", description="Thread name filter.\nOnly show log entries with the specified thread name.", example="thread-pool-33-thread-1") String thread,
145         @Query(name="loggers", description="Logger filter (simple class name).\nOnly show log entries if they were produced by one of the specified loggers.", example="['LinkIndexService','LinkIndexRestService']") String[] loggers,
146         @Query(name="severity", description="Severity filter.\nOnly show log entries with the specified severity.", example="['ERROR','WARN']") String[] severity
147      ) throws NotFound, MethodNotAllowed, IOException {
148
149      File f = getFile(path);
150
151      Date startDate = parseIsoDate(start), endDate = parseIsoDate(end);
152
153      if (! highlight) {
154         Object o = getReader(f, startDate, endDate, thread, loggers, severity);
155         res.setContentType("text/plain");
156         if (o instanceof Reader)
157            res.setOutput(o);
158         else {
159            try (LogParser p = (LogParser)o; Writer w = res.getNegotiatedWriter()) {
160               p.writeTo(w);
161            }
162         }
163         return;
164      }
165
166      res.setContentType("text/html");
167      try (PrintWriter w = res.getNegotiatedWriter()) {
168         w.println("<html><body style='font-family:monospace;font-size:8pt;white-space:pre;'>");
169         try (LogParser lp = getLogParser(f, startDate, endDate, thread, loggers, severity)) {
170            if (! lp.hasNext())
171               w.append("<span style='color:gray'>[EMPTY]</span>");
172            else for (LogParser.Entry le : lp) {
173               char s = le.severity.charAt(0);
174               String color = "black";
175               //SEVERE|WARNING|INFO|CONFIG|FINE|FINER|FINEST
176               if (s == 'I')
177                  color = "#006400";
178               else if (s == 'W')
179                  color = "#CC8400";
180               else if (s == 'E' || s == 'S')
181                  color = "#DD0000";
182               else if (s == 'D' || s == 'F' || s == 'T')
183                  color = "#000064";
184               w.append("<span style='color:").append(color).append("'>");
185               le.appendHtml(w).append("</span>");
186            }
187            w.append("</body></html>");
188         }
189      }
190   }
191
192   @RestMethod(
193      name="PARSE",
194      path="/*",
195      converters=Queryable.class,
196      summary="View parsed contents of file",
197      description="View the parsed contents of a file.",
198      htmldoc=@HtmlDoc(
199         nav={"<h5>Folder:  $RA{fullPath}</h5>"}
200      ),
201      swagger=@MethodSwagger(
202         parameters={
203             Queryable.SWAGGER_PARAMS
204         }
205      )
206   )
207   public LogParser viewParsedEntries(
208         RestRequest req,
209         @Path("/*") String path,
210         @Query(name="start", description="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", example="2014-01-23T11:25:47") String start,
211         @Query(name="end", description="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", example="2014-01-24") String end,
212         @Query(name="thread", description="Thread name filter.\nOnly show log entries with the specified thread name.", example="thread-pool-33-thread-1") String thread,
213         @Query(name="loggers", description="Logger filter (simple class name).\nOnly show log entries if they were produced by one of the specified loggers.", example="['LinkIndexService','LinkIndexRestService']") String[] loggers,
214         @Query(name="severity", description="Severity filter.\nOnly show log entries with the specified severity.", example="['ERROR','WARN']") String[] severity
215      ) throws NotFound, IOException {
216
217      File f = getFile(path);
218      req.setAttribute("fullPath", f.getAbsolutePath());
219
220      Date startDate = parseIsoDate(start), endDate = parseIsoDate(end);
221
222      return getLogParser(f, startDate, endDate, thread, loggers, severity);
223   }
224
225   @RestMethod(
226      name="DOWNLOAD",
227      path="/*",
228      summary="Download file",
229      description="Download the contents of a file.\nContent-Type is set to 'application/octet-stream'."
230   )
231   public FileContents downloadFile(RestResponse res, @Path("/*") String path) throws NotFound, MethodNotAllowed {
232      res.setContentType("application/octet-stream");
233      try {
234         return new FileContents(getFile(path));
235      } catch (FileNotFoundException e) {
236         throw new NotFound("File not found");
237      }
238   }
239
240   @RestMethod(
241      name=DELETE,
242      path="/*",
243      summary="Delete log file",
244      description="Delete a log file on the file system."
245   )
246   public RedirectToRoot deleteFile(@Path("/*") String path) throws MethodNotAllowed {
247      deleteFile(getFile(path));
248      return new RedirectToRoot();
249   }
250
251
252   //-----------------------------------------------------------------------------------------------------------------
253   // Helper beans
254   //-----------------------------------------------------------------------------------------------------------------
255
256   @Response(schema=@Schema(type="string",format="binary"), description="Contents of file")
257   static class FileContents extends FileInputStream {
258      public FileContents(File file) throws FileNotFoundException {
259         super(file);
260      }
261   }
262
263   @Response(description="Redirect to root page on success")
264   static class RedirectToRoot extends SeeOtherRoot {}
265
266   @Response(description="File action")
267   public static class Action extends LinkString {
268      public Action(String name, String uri, Object...uriArgs) {
269         super(name, uri, uriArgs);
270      }
271   }
272
273   @Response(description="File or directory details")
274   @Bean(properties="type,name,size,lastModified,actions,files")
275   public static class FileResource {
276      private final File f;
277      private final String path;
278      private final String uri;
279      private final boolean includeChildren, allowDeletes;
280
281      public FileResource(File f, String path, boolean allowDeletes, boolean includeChildren) {
282         this.f = f;
283         this.path = path;
284         this.uri = "servlet:/"+(path == null ? "" : path);
285         this.includeChildren = includeChildren;
286         this.allowDeletes = allowDeletes;
287      }
288
289      public String getType() {
290         return (f.isDirectory() ? "dir" : "file");
291      }
292
293      public LinkString getName() {
294         return new LinkString(f.getName(), uri);
295      }
296
297      public long getSize() {
298         return f.isDirectory() ? f.listFiles().length : f.length();
299      }
300
301      @Swap(DateSwap.ISO8601DTP.class)
302      public Date getLastModified() {
303         return new Date(f.lastModified());
304      }
305
306      @Html(format=HtmlFormat.HTML_CDC)
307      public List<Action> getActions() throws Exception {
308         List<Action> l = new ArrayList<>();
309         if (f.canRead() && ! f.isDirectory()) {
310            l.add(new Action("view", uri + "?method=VIEW"));
311            l.add(new Action("highlighted", uri + "?method=VIEW&highlight=true"));
312            l.add(new Action("parsed", uri + "?method=PARSE"));
313            l.add(new Action("download", uri + "?method=DOWNLOAD"));
314            if (allowDeletes)
315               l.add(new Action("delete", uri + "?method=DELETE"));
316         }
317         return l;
318      }
319
320      public Set<FileResource> getFiles() {
321         if (f.isFile() || ! includeChildren)
322            return null;
323         Set<FileResource> s = new TreeSet<>(FILE_COMPARATOR);
324         for (File fc : f.listFiles(FILE_FILTER))
325            s.add(new FileResource(fc, (path != null ? (path + '/') : "") + urlEncode(fc.getName()), allowDeletes, false));
326         return s;
327      }
328
329      static final FileFilter FILE_FILTER = new FileFilter() {
330         @Override /* FileFilter */
331         public boolean accept(File f) {
332            return f.isDirectory() || f.getName().endsWith(".log");
333         }
334      };
335
336      static final Comparator<FileResource> FILE_COMPARATOR = new Comparator<FileResource>() {
337         @Override /* Comparator */
338         public int compare(FileResource o1, FileResource o2) {
339            int c = o1.getType().compareTo(o2.getType());
340            return c != 0 ? c : o1.getName().compareTo(o2.getName());
341         }
342      };
343   }
344
345
346   //-----------------------------------------------------------------------------------------------------------------
347   // Helper methods
348   //-----------------------------------------------------------------------------------------------------------------
349
350   private File getFile(String path) throws NotFound {
351      if (path == null)
352         return logDir;
353      File f = new File(logDir.getAbsolutePath() + '/' + path);
354      if (f.exists())
355         return f;
356      throw new NotFound("File not found.");
357   }
358
359   private void deleteFile(File f) {
360      if (! allowDeletes)
361         throw new MethodNotAllowed("DELETE not enabled");
362      if (f.isDirectory()) {
363         File[] files = f.listFiles();
364         if (files != null) {
365            for (File fc : files)
366               deleteFile(fc);
367         }
368      }
369      if (! f.delete())
370         throw new Forbidden("Could not delete file {0}", f.getAbsolutePath()) ;
371   }
372
373   private static BufferedReader getReader(File f) throws IOException {
374      return new BufferedReader(new InputStreamReader(new FileInputStream(f), Charset.defaultCharset()));
375   }
376
377   private Object getReader(File f, final Date start, final Date end, final String thread, final String[] loggers, final String[] severity) throws IOException {
378      if (start == null && end == null && thread == null && loggers == null)
379         return getReader(f);
380      return getLogParser(f, start, end, thread, loggers, severity);
381   }
382
383   private LogParser getLogParser(File f, final Date start, final Date end, final String thread, final String[] loggers, final String[] severity) throws IOException {
384      return new LogParser(leFormatter, f, start, end, thread, loggers, severity);
385   }
386}