SLING-4897 - Web console log tail plugin, contributed by Varun Nagpal, thanks!

git-svn-id: https://svn.apache.org/repos/asf/sling/trunk@1694685 13f79535-47bb-0310-9956-ffa450edef68
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..849d6f9
--- /dev/null
+++ b/README.md
@@ -0,0 +1,5 @@
+sling-logtail

+=============

+

+Sling bundle encapsulating a Web Console for tailing logs over a browser

+Install the bundle via System Console or mvn sling:install on any running sling instance and navigate to /system/console/tail

diff --git a/pom.xml b/pom.xml
new file mode 100644
index 0000000..26653e5
--- /dev/null
+++ b/pom.xml
@@ -0,0 +1,125 @@
+<?xml version="1.0" encoding="UTF-8"?>
+
+<!-- 
+	Licensed to the Apache Software Foundation (ASF) under one
+	or more contributor license agreements.  See the NOTICE file
+	distributed with this work for additional information
+	regarding copyright ownership.  The ASF licenses this file
+	to you under the Apache License, Version 2.0 (the
+	"License"); you may not use this file except in compliance
+	with the License.  You may obtain a copy of the License at
+
+	http://www.apache.org/licenses/LICENSE-2.0
+
+	Unless required by applicable law or agreed to in writing,
+	software distributed under the License is distributed on an
+	"AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+	KIND, either express or implied.  See the License for the
+	specific language governing permissions and limitations
+	under the License.
+-->
+
+<project xmlns="http://maven.apache.org/POM/4.0.0"
+         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
+    <modelVersion>4.0.0</modelVersion>
+    <parent>
+        <groupId>org.apache.sling</groupId>
+        <artifactId>sling</artifactId>
+        <version>24</version>
+    </parent>
+
+    <artifactId>org.apache.sling.tail</artifactId>
+    <version>0.0.1-SNAPSHOT</version>
+    <packaging>bundle</packaging>
+
+    <name>Apache Sling Log Tail Implementation</name>
+    <description>
+        This bundle enables a web tail view of the system log files.
+    </description>
+
+    <build>
+        <plugins>
+            <plugin>
+                <groupId>org.apache.felix</groupId>
+                <artifactId>maven-bundle-plugin</artifactId>
+                <extensions>true</extensions>
+                <configuration>
+                    <instructions>
+                        <Sling-Bundle-Resources>
+                            /libs/tail/css, /libs/tail/js
+                        </Sling-Bundle-Resources>
+                    </instructions>
+                </configuration>
+            </plugin>
+            <plugin>
+                <groupId>org.apache.felix</groupId>
+                <artifactId>maven-scr-plugin</artifactId>
+                <extensions>true</extensions>
+                <executions>
+                    <execution>
+                        <id>generate-scr-descriptor</id>
+                        <goals>
+                            <goal>scr</goal>
+                        </goals>
+                    </execution>
+                </executions>
+            </plugin>
+        </plugins>
+    </build>
+
+    <dependencies>
+        <dependency>
+            <groupId>biz.aQute.bnd</groupId>
+            <artifactId>bnd</artifactId>
+            <version>2.1.0</version>
+            <scope>provided</scope>
+        </dependency>
+        <dependency>
+            <groupId>org.slf4j</groupId>
+            <artifactId>slf4j-api</artifactId>
+            <scope>provided</scope>
+        </dependency>
+
+        <!-- OSGi Libraries not included here -->
+        <dependency>
+            <groupId>org.osgi</groupId>
+            <artifactId>org.osgi.core</artifactId>
+            <version>4.2.0</version>
+            <scope>provided</scope>
+        </dependency>
+        <dependency>
+            <groupId>org.osgi</groupId>
+            <artifactId>org.osgi.compendium</artifactId>
+            <version>4.2.0</version>
+            <scope>provided</scope>
+        </dependency>
+
+        <!-- servlet API for the web console plugin -->
+        <dependency>
+            <groupId>javax.servlet</groupId>
+            <artifactId>servlet-api</artifactId>
+            <version>2.3</version>
+        </dependency>
+
+        <!-- Required for log tail plugin -->
+        <dependency>
+            <groupId>org.apache.felix</groupId>
+            <artifactId>org.apache.felix.webconsole</artifactId>
+            <version>3.1.8</version>
+            <scope>provided</scope>
+        </dependency>
+        <dependency>
+            <groupId>org.apache.sling</groupId>
+            <artifactId>org.apache.sling.commons.json</artifactId>
+            <version>2.0.6</version>
+        </dependency>
+        <dependency>
+            <groupId>org.apache.sling</groupId>
+            <artifactId>org.apache.sling.commons.log</artifactId>
+            <version>4.0.0</version>
+        </dependency>
+
+    </dependencies>
+
+</project>
diff --git a/src/main/java/org/apache/sling/tail/LogFilter.java b/src/main/java/org/apache/sling/tail/LogFilter.java
new file mode 100644
index 0000000..030cd8d
--- /dev/null
+++ b/src/main/java/org/apache/sling/tail/LogFilter.java
@@ -0,0 +1,27 @@
+package org.apache.sling.tail;

+

+/*

+ * Licensed to the Apache Software Foundation (ASF) under one

+ * or more contributor license agreements.  See the NOTICE file

+ * distributed with this work for additional information

+ * regarding copyright ownership.  The ASF licenses this file

+ * to you under the Apache License, Version 2.0 (the

+ * "License"); you may not use this file except in compliance

+ * with the License.  You may obtain a copy of the License at

+ *

+ *   http://www.apache.org/licenses/LICENSE-2.0

+ *

+ * Unless required by applicable law or agreed to in writing,

+ * software distributed under the License is distributed on an

+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY

+ * KIND, either express or implied.  See the License for the

+ * specific language governing permissions and limitations

+ * under the License.

+ */

+

+/**

+ *

+ */

+public interface LogFilter {

+    boolean eval(String input);

+}

diff --git a/src/main/java/org/apache/sling/tail/impl/LogTailerWebConsolePlugin.java b/src/main/java/org/apache/sling/tail/impl/LogTailerWebConsolePlugin.java
new file mode 100644
index 0000000..78d8668
--- /dev/null
+++ b/src/main/java/org/apache/sling/tail/impl/LogTailerWebConsolePlugin.java
@@ -0,0 +1,458 @@
+package org.apache.sling.tail.impl;

+

+/*

+ * Licensed to the Apache Software Foundation (ASF) under one

+ * or more contributor license agreements.  See the NOTICE file

+ * distributed with this work for additional information

+ * regarding copyright ownership.  The ASF licenses this file

+ * to you under the Apache License, Version 2.0 (the

+ * "License"); you may not use this file except in compliance

+ * with the License.  You may obtain a copy of the License at

+ *

+ *   http://www.apache.org/licenses/LICENSE-2.0

+ *

+ * Unless required by applicable law or agreed to in writing,

+ * software distributed under the License is distributed on an

+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY

+ * KIND, either express or implied.  See the License for the

+ * specific language governing permissions and limitations

+ * under the License.

+ */

+

+import ch.qos.logback.classic.LoggerContext;

+import ch.qos.logback.classic.spi.ILoggingEvent;

+import ch.qos.logback.core.Appender;

+import ch.qos.logback.core.FileAppender;

+import org.apache.felix.scr.annotations.*;

+import org.apache.felix.scr.annotations.Properties;

+import org.apache.felix.webconsole.AbstractWebConsolePlugin;

+import org.apache.felix.webconsole.WebConsoleConstants;

+import org.apache.sling.commons.json.io.JSONWriter;

+import org.apache.sling.tail.LogFilter;

+import org.slf4j.Logger;

+import org.slf4j.LoggerFactory;

+

+import javax.servlet.Servlet;

+import javax.servlet.ServletException;

+import javax.servlet.http.Cookie;

+import javax.servlet.http.HttpServletRequest;

+import javax.servlet.http.HttpServletResponse;

+import java.io.File;

+import java.io.IOException;

+import java.io.PrintWriter;

+import java.io.RandomAccessFile;

+import java.util.*;

+

+/**

+ *

+ */

+@Component

+@Service(value = { Servlet.class })

+@Properties({

+        @Property(name=org.osgi.framework.Constants.SERVICE_DESCRIPTION,

+                value="Apache Sling Web Console Plugin to tail log(s) of this Sling instance"),

+        @Property(name= WebConsoleConstants.PLUGIN_LABEL, value=LogTailerWebConsolePlugin.LABEL),

+        @Property(name=WebConsoleConstants.PLUGIN_TITLE, value=LogTailerWebConsolePlugin.TITLE),

+        @Property(name="felix.webconsole.configprinter.modes", value={"always"})

+})

+public class LogTailerWebConsolePlugin extends AbstractWebConsolePlugin {

+    public static final String LABEL = "tail";

+    public static final String TITLE = "Tail Logs";

+

+    private final Logger log = LoggerFactory.getLogger(this.getClass());

+

+    private static final int LINES_TO_TAIL = 100;

+    private static final String POSITION_COOKIE = "log.tail.position";

+    private static final String FILTER_COOKIE = "log.tail.filter";

+    private static final String MODIFIED_COOKIE = "log.modified";

+    private static final String CREATED_COOKIE = "log.created";

+

+    private String fileName = "";

+    private File errLog;

+

+    @Override

+    protected void renderContent(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException {

+        if(isAjaxRequest(request)) {

+

+            parseCommand(request, response);

+

+            RandomAccessFile randomAccessFile = null;

+

+            try {

+

+                try {

+                    randomAccessFile = new RandomAccessFile(errLog, "r");

+                    log.debug("Tailing file " + fileName + " of length " + randomAccessFile.length());

+                } catch (Exception e) {

+                    log.error("Error reading " + fileName, e);

+                    response.setStatus(HttpServletResponse.SC_INTERNAL_SERVER_ERROR);

+                    return;

+                }

+

+                JSONWriter json = new JSONWriter(response.getWriter());

+                json.setTidy(true);

+                json.object();

+

+                boolean reverse = false;

+                //long created = getCreatedTimestampFromCookie(request);

+            /*long modified = getModifiedTimestampFromCookie(request);

+            if(errLog.lastModified() == modified) {

+                json.endObject();

+                return;

+            }

+            else {

+                 persistCookie(response, MODIFIED_COOKIE, String.valueOf(errLog.lastModified()));

+            }*/

+                long pos = getPositionFromCookie(request);

+                if(pos < 0) {

+                    pos = randomAccessFile.length()-1;

+                    reverse = true;

+                }

+                else if(pos > randomAccessFile.length()) {//file rotated

+                    pos = 0;

+                }

+                LogFilter[] query = getQueryFromCookie(request);

+

+                if(reverse) {

+                    randomAccessFile.seek(pos);

+                    if(randomAccessFile.read() == '\n') {

+                        pos--;

+                        randomAccessFile.seek(pos);

+                        if(randomAccessFile.read() == '\r') {

+                            pos--;

+                        }

+                    }

+

+                    json.key("content").array();

+                    int found = 0;

+                    StringBuilder sb = new StringBuilder();

+                    String line = null;

+                    List<String> lines = new ArrayList<String>();

+                    while(found != LINES_TO_TAIL && pos > 0) {

+                        boolean eol = false;

+                        randomAccessFile.seek(pos);

+                        int c = randomAccessFile.read();

+                        if(c == '\n') {

+                            found++;

+                            sb = sb.reverse();

+                            line = sb.toString();

+                            sb = new StringBuilder();

+                            eol = true;

+                            pos--;

+                            if(pos > 0) {

+                                randomAccessFile.seek(pos);

+                                if(randomAccessFile.read() == '\r') {

+                                    pos--;

+                                }

+                            }

+                        }

+                        else {

+                            sb.append((char)c);

+                            pos--;

+                        }

+

+                        if(eol) {

+                            if(filter(line, query)){

+                                lines.add(line);

+                            }

+                        }

+                    }

+

+                    if(pos < 0) {

+                        if(filter(line, query)){

+                            lines.add(line);

+                        }

+                    }

+                    for(int i=lines.size()-1; i > -1; i--) {

+                        json.object().key("line").value(lines.get(i)).endObject();

+                    }

+                    json.endArray();

+                    json.endObject();

+                }

+                else {

+                    randomAccessFile.seek(pos);

+                    String line = null;

+                    int lineCount = 0;

+                    json.key("content").array();

+                    boolean read = true;

+                    while(read) {

+                        StringBuilder input = new StringBuilder();

+                        int c = -1;

+                        boolean eol = false;

+

+                        while (!eol) {

+                            switch (c = randomAccessFile.read()) {

+                                case -1:

+                                case '\n':

+                                    eol = true;

+                                    break;

+                                case '\r':

+                                    eol = true;

+                                    long cur = randomAccessFile.getFilePointer();

+                                    if ((randomAccessFile.read()) != '\n') {

+                                        randomAccessFile.seek(cur);

+                                    }

+                                    break;

+                                default:

+                                    input.append((char)c);

+                                    break;

+                            }

+                        }

+

+                        if ((c == -1) && (input.length() == 0)) {

+                            read = false;

+                            continue;

+                        }

+                        line = input.toString();

+                        lineCount++;

+                        if(lineCount == LINES_TO_TAIL) {

+                            read = false;

+                        }

+                        pos = randomAccessFile.getFilePointer();

+

+                        if(filter(line, query)){

+                            json.object().key("line").value(line).endObject();

+                        }

+                    }

+                    json.endArray();

+                    json.endObject();

+                }

+

+                persistCookie(response, POSITION_COOKIE, String.valueOf(randomAccessFile.getFilePointer()));

+

+            } catch (Exception e) {

+                log.error("Error tailing " + fileName, e);

+                response.setStatus(HttpServletResponse.SC_INTERNAL_SERVER_ERROR);

+            }

+            finally {

+                try {

+                    if(randomAccessFile != null) {

+                        randomAccessFile.close();

+                    }

+                }

+                catch (Exception e) {

+                    log.error("Error closing " + fileName, e);

+                }

+            }

+        }

+        else {

+            PrintWriter printWriter = response.getWriter();

+            printWriter.println("<script type=\"text/javascript\" src=\"/libs/tail/js/tail.js\"></script>");

+            printWriter.println("<link href=\"/libs/tail/css/tail.css\" rel=\"stylesheet\" type=\"text/css\"></link>");

+            printWriter.println("<div class=\"header-cont\">");

+            printWriter.println("   <div class=\"header\" style=\"display:none;\">");

+            printWriter.println("       <table>");

+            printWriter.println("           <tr>");

+            printWriter.println("               <td><button class=\"numbering\" title=\"Show Line Numbers\" data-numbers=\"false\">Show Line No.</button></td>");

+            printWriter.println("               <td><button class=\"pause\" title=\"Pause\">Pause</button></td>");

+            printWriter.println("               <td class=\"longer\"><label>Sync frequency(msec)</label>");

+            printWriter.println("                   <button class=\"faster\" title=\"Sync Faster\">-</button>");

+            printWriter.println("                   <input id=\"speed\" type=\"text\" value=\"3000\"/>");

+            printWriter.println("                   <button class=\"slower\" title=\"Sync Slower\">+</button></td>");

+            printWriter.println("               <td><button class=\"tail\" title=\"Unfollow Tail\" data-following=\"true\">Unfollow</button></td>");

+            printWriter.println("               <td><button class=\"highlighting\" title=\"Highlight\">Highlight</button></td>");

+            printWriter.println("               <td><button class=\"clear\" title=\"Clear Display\">Clear</button></td>");

+            printWriter.println("               <td class=\"longer\"><input id=\"filter\" type=\"text\"/><span class=\"filterClear ui-icon ui-icon-close\" title=\"Clear Filter\">&nbsp;</span><button class=\"filter\" title=\"Filter Logs\">Filter</button></td>");

+            printWriter.println("               <td><button class=\"refresh\" title=\"Reload Logs\">Reload</button></td>");

+            printWriter.println("               <td><button class=\"sizeplus\" title=\"Bigger\">a->A</button></td>");

+            printWriter.println("               <td><button class=\"sizeminus\" title=\"Smaller\">A->a</button></td>");

+            printWriter.println("               <td><button class=\"top\" title=\"Scroll to Top\">Top</button></td>");

+            printWriter.println("               <td><button class=\"bottom\" title=\"Scroll to Bottom\">Bottom</button></td>");

+            printWriter.println("           </tr>");

+            printWriter.println("           <tr>");

+            printWriter.println("               <td class=\"loadingstatus\" colspan=\"2\" data-status=\"inactive\"><ul><li></li></ul></td>");

+            printWriter.println("               <td>Tailing &nbsp; <select id=\"logfiles\">" + getOptions() + "</select></td>");

+            printWriter.println("           </tr>");

+            printWriter.println("       </table>");

+            printWriter.println("   </div>");

+            printWriter.println("   <div class=\"pulldown\" title=\"Click to show options\">&nbsp;==&nbsp;</div>");

+            printWriter.println("</div>");

+            printWriter.println("");

+            printWriter.println("   <div class=\"content\">");

+            printWriter.println("");

+            printWriter.println("       <div id=\"logarea\"></div>");

+            printWriter.println("");

+            printWriter.println("   </div>");

+        }

+    }

+

+    protected void parseCommand(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException {

+        String cmd = request.getParameter("command");

+        if(cmd == null) {

+            return;

+        }

+

+        if(cmd.equals("reset")) {

+            deleteCookie(response, FILTER_COOKIE);

+        }

+        else if(cmd.startsWith("filter:")) {

+            String queryStr = cmd.substring(7);

+            if(queryStr.length()==0) {

+                deleteCookie(response, FILTER_COOKIE);

+            }

+            else {

+                persistCookie(response, FILTER_COOKIE, queryStr);

+                log.info("Filtering on : " + queryStr);

+            }

+        }

+        else if(cmd.startsWith("file:")) {

+            if(!fileName.equals(cmd.substring(5))) {

+                deleteCookie(response, FILTER_COOKIE);

+                deleteCookie(response, POSITION_COOKIE);

+                fileName = cmd.substring(5);

+                errLog = new File(filePathMap.get(fileName));

+                if(!errLog.exists()) {

+                    throw new ServletException("File " + fileName + " doesn't exist");

+                }

+                if(!errLog.canRead()) {

+                    throw new ServletException("Cannot read file " + fileName);

+                }

+            }

+        }

+    }

+

+    @Override

+    public String getLabel() {

+        return LABEL;

+    }

+

+    @Override

+    public String getTitle() {

+        return TITLE;

+    }

+

+    private HashMap<String, String> filePathMap = new HashMap<String, String>();

+

+    private String getKey(File file) {

+        if(!filePathMap.containsKey(file.getName())) {

+            filePathMap.put(file.getName(), file.getAbsolutePath());

+        }

+        return file.getName();

+    }

+

+    private String getOptions() {

+        Set<String> logFiles = new HashSet<String>();

+        LoggerContext context = (LoggerContext)LoggerFactory.getILoggerFactory();

+        for (ch.qos.logback.classic.Logger logger : context.getLoggerList()) {

+            for (Iterator<Appender<ILoggingEvent>> index = logger.iteratorForAppenders(); index.hasNext();) {

+                Appender<ILoggingEvent> appender = index.next();

+                if(appender instanceof FileAppender) {

+                    FileAppender fileAppender = (FileAppender) appender;

+                    String logfilePath = fileAppender.getFile();

+                    logFiles.add(logfilePath);

+                }

+            }

+        }

+

+        String logFilesHtml = "<option value=\"\"> - Select file - </option>";

+        for(String logFile : logFiles) {

+            File file = new File(logFile);

+            logFilesHtml += "<option value=\"" + getKey(file) + "\">" + file.getName() + "</option>";

+        }

+        return logFilesHtml;

+    }

+

+    @Override

+    protected boolean isHtmlRequest( final HttpServletRequest request ) {

+        return !isAjaxRequest(request);

+    }

+

+    private boolean isAjaxRequest( final HttpServletRequest request) {

+        return "XMLHttpRequest".equals(request.getHeader("X-Requested-With"));

+    }

+

+    private boolean filter(String str, LogFilter[] query) {

+        if(query != null) {

+            for(LogFilter q : query) {

+                if(!q.eval(str)) {

+                    return false;

+                }

+            }

+        }

+        return true;

+    }

+

+    private void deleteCookie(HttpServletResponse response, String name) {

+        Cookie cookie = new Cookie(name, "");

+        cookie.setMaxAge(0);

+        response.addCookie(cookie);

+        log.debug("Deleting cookie :: " + cookie.getName());

+    }

+

+    private void persistCookie(HttpServletResponse response, String name, String value) {

+        Cookie cookie = new Cookie(name , value);

+        //cookie.setPath("/system/console/" + LABEL);

+        response.addCookie(cookie);

+        log.debug("Adding cookie :: " + cookie.getName() + " " + cookie.getValue());

+    }

+

+    private LogFilter[] getQueryFromCookie(HttpServletRequest request) {

+        try {

+            for(Cookie cookie : request.getCookies()) {

+                if(cookie.getName().equals(FILTER_COOKIE)) {

+                    String[] parts = cookie.getValue().split("&&");

+                    LogFilter[] conditions = new LogFilter[parts.length];

+                    for(int i=0; i<parts.length; i++) {

+                        final String part = parts[i];

+                        conditions[i] = new LogFilter() {

+                            public boolean eval(String input) {

+                                return input.contains(part);

+                            }

+

+                            public String toString() {

+                                return part;

+                            }

+                        };

+                    }

+                    return conditions;

+                }

+            }

+        }

+        catch (Exception e) {

+

+        }

+        return null;

+    }

+

+    private long getCreatedTimestampFromCookie(HttpServletRequest request) {

+        try {

+            for(Cookie cookie : request.getCookies()) {

+                if(cookie.getName().equals(CREATED_COOKIE)) {

+                    return Long.parseLong(cookie.getValue());

+                }

+            }

+        }

+        catch (Exception e) {

+

+        }

+        return -1;

+    }

+

+    private long getModifiedTimestampFromCookie(HttpServletRequest request) {

+        try {

+            for(Cookie cookie : request.getCookies()) {

+                if(cookie.getName().equals(MODIFIED_COOKIE)) {

+                    return Long.parseLong(cookie.getValue());

+                }

+            }

+        }

+        catch (Exception e) {

+

+        }

+        return -1;

+    }

+

+    private long getPositionFromCookie(HttpServletRequest request) {

+        try {

+            for(Cookie cookie : request.getCookies()) {

+                if(cookie.getName().equals(POSITION_COOKIE)) {

+                    return Long.parseLong(cookie.getValue());

+                }

+            }

+        }

+        catch (Exception e) {

+            log.debug("Position specified is invalid, Tailing from beginning of the file.", e);

+        }

+        return -1;

+    }

+}

diff --git a/src/main/resources/libs/tail/css/tail.css b/src/main/resources/libs/tail/css/tail.css
new file mode 100644
index 0000000..062210e
--- /dev/null
+++ b/src/main/resources/libs/tail/css/tail.css
@@ -0,0 +1,121 @@
+/*

+ * Licensed to the Apache Software Foundation (ASF) under one

+ * or more contributor license agreements.  See the NOTICE file

+ * distributed with this work for additional information

+ * regarding copyright ownership.  The ASF licenses this file

+ * to you under the Apache License, Version 2.0 (the

+ * "License"); you may not use this file except in compliance

+ * with the License.  You may obtain a copy of the License at

+ *

+ *   http://www.apache.org/licenses/LICENSE-2.0

+ *

+ * Unless required by applicable law or agreed to in writing,

+ * software distributed under the License is distributed on an

+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY

+ * KIND, either express or implied.  See the License for the

+ * specific language governing permissions and limitations

+ * under the License.

+ */

+

+.header-cont {

+    position:fixed;

+    top:0;

+    left:0;

+}

+

+.pulldown {

+    height:20px;

+    cursor:pointer;

+    background:#CCCCCC;

+    margin: 0 auto;

+    border-radius: 0 0 25px 25px;

+    text-align: center;

+}

+

+.header {

+    background:#F0F0F0;

+    border:1px solid #CCC;

+    margin:0px auto;

+    height:50px;

+    vertical-align:middle;

+}

+

+.header table td {

+	width:5%;

+    text-align:center;

+    vertical-align:middle;

+}

+

+.header table td.longer {

+    width:20%;

+}

+

+.header table td button {

+	cursor:pointer;

+}

+

+.content {

+	font:Courier-New;

+    font-size:9px;

+}

+

+.criteria-item {

+	cursor:pointer;

+}

+

+.criteria-item.selected {

+	background-color: #2B60DE;

+    color:white;

+}

+

+.highlight-content-inner-div {

+	float:left;

+    clear:both;

+    width:95%;

+    margin-bottom: 10px;

+}

+

+span.box {

+    min-width:50px;

+    text-overflow:clip;

+    border:1px solid black;

+    margin-right: 5px;

+    margin-left: 5px;

+    margin-top:5px;

+    margin-bottom:5px;

+}

+

+.criteria-list {

+	padding: 0;

+    list-style:none;

+    margin:0;

+}

+

+#criteria {

+	min-height: 200px;

+    border:1px solid;

+}

+

+.lineNumberCol {

+	background-color: grey;

+	color: white;

+}

+

+#speed {

+    width:50px;

+}

+

+.loadingstatus li {

+	font-size:30px;

+	color:grey;

+	list-style: inherit !important;

+}

+

+.hide {

+    display: none;

+}

+

+.filterClear {

+  display: inline-block;

+  vertical-align: middle;

+}
\ No newline at end of file
diff --git a/src/main/resources/libs/tail/js/tail.js b/src/main/resources/libs/tail/js/tail.js
new file mode 100644
index 0000000..fc41594
--- /dev/null
+++ b/src/main/resources/libs/tail/js/tail.js
@@ -0,0 +1,425 @@
+/*

+ * Licensed to the Apache Software Foundation (ASF) under one

+ * or more contributor license agreements.  See the NOTICE file

+ * distributed with this work for additional information

+ * regarding copyright ownership.  The ASF licenses this file

+ * to you under the Apache License, Version 2.0 (the

+ * "License"); you may not use this file except in compliance

+ * with the License.  You may obtain a copy of the License at

+ *

+ *   http://www.apache.org/licenses/LICENSE-2.0

+ *

+ * Unless required by applicable law or agreed to in writing,

+ * software distributed under the License is distributed on an

+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY

+ * KIND, either express or implied.  See the License for the

+ * specific language governing permissions and limitations

+ * under the License.

+ */

+

+    var logarea;

+    var follow = true;

+    var searchCriteria = [];

+    var logfontsize = 9;

+    var refreshInterval = 3000;

+    var _load = false;

+    var _lineNum = 0;

+    var _isLineNumShowing = false;

+

+    var modal;

+

+    var showLine = function(text) {

+        logarea.append("<br/>");

+

+        if(text.indexOf("\t") == 0) {

+            text = "<span style='padding-left:5%;'>" + text + "</span>";

+        }

+        else {

+            text = "<span>" + text + "</span>"

+        }

+

+        for(var i=0; i < searchCriteria.length; i++) {

+            if(text.indexOf(searchCriteria[i]["string"]) >=0) {

+                if(searchCriteria[i]["bold"]) {text = "<b>" + text + "</b>";}

+                if(searchCriteria[i]["italic"]) {text = "<i>" + text + "</i>";}

+                var color = "red"; if(searchCriteria[i]["forecolor"]) {color = searchCriteria[i]["forecolor"];}

+                var bg = ""; if(searchCriteria[i]["backcolor"]) {bg = searchCriteria[i]["backcolor"];}

+                text = "<span style='color:" + color + ";background-color:" + bg + "'>" + text + "</span>";

+            }

+        }

+		_lineNum++;

+        logarea.append("<a class='lineNumberCol " + (_isLineNumShowing?"":"hide") + "' name='line_" + _lineNum + "'>&nbsp;&nbsp;&nbsp;" + _lineNum + "&nbsp;&nbsp;&nbsp;</a>" + text);

+

+    };

+

+    var loadTail = function() {

+		_load = false;

+

+        $.ajax({

+            url: "/system/console/tail",

+            data: {},

+            dataType: "json",

+            method: "GET",

+            async: true,

+            success: function(s) {

+                if(s.content) {

+                    $(".loadingstatus").data("status", "active");

+                    for(var i = 0; i < s.content.length; i++) {

+						var line = s.content[i].line;

+                        showLine(line);

+                    }

+                    if(follow) {

+			            $("html,body").scrollTop(logarea[0].scrollHeight);

+        			}

+                }

+                else {

+					$(".loadingstatus").data("status", "inactive");

+                }

+            },

+            error: function(e) {

+				$(".loadingstatus").data("status", "error");

+        	},

+            complete: function(d) {

+				_load = true;

+            }

+        });

+    };

+

+    var sendCmd = function(cmd, callback) {

+        $.ajax({

+            url: "/system/console/tail",

+            data: {command: cmd},

+            dataType: "json",

+            method: "POST",

+            async: true,

+            success: function(s) {},

+            error: function(e) {},

+            complete: function(d) {

+                if(callback) {

+                    callback();

+                }

+            }

+        });

+    }

+

+    var clearAll = function() {

+        logarea.find("span").css({"color":"black", "background-color":"white"});

+        var b = logarea[0].getElementsByTagName('b');

+

+        while(b.length) {

+            var parent = b[ 0 ].parentNode;

+            while( b[ 0 ].firstChild ) {

+                parent.insertBefore(  b[ 0 ].firstChild, b[ 0 ] );

+            }

+            parent.removeChild( b[ 0 ] );

+        }

+

+        var i = logarea[0].getElementsByTagName('i');

+

+        while(i.length) {

+            var parent = i[ 0 ].parentNode;

+            while( i[ 0 ].firstChild ) {

+                parent.insertBefore(  i[ 0 ].firstChild, i[ 0 ] );

+            }

+            parent.removeChild( i[ 0 ] );

+        }

+

+    };

+

+    $(document).ready(function(e){

+		logarea = $("#logarea");

+

+		if ($("#highlighting").length === 0) {

+	        var insertModal = $("<div>", {"class": "", "id": "highlighting", "style": "width:30rem", "title": "Highlighting"}).hide();

+			$(document.body).append(insertModal);

+			var criteria = "";

+            for(var i=0; i < searchCriteria.length; i++) {

+                criteria = criteria + "<li class='criteria-item'><div class='box'>" + searchCriteria[i]["string"] + "</div></li>";

+        	}

+

+            var content = "<div id='criteria' class='highlight-content-inner-div'><ul class='criteria-list'>" + criteria + "</ul></div>" +

+				"<div class='highlight-content-inner-div'><button class='add'>Add</button><button class='delete'>Delete</button></div>" +

+                "<div class='highlight-content-inner-div'><input id='search'>String</input></div>" +

+                "<div class='highlight-content-inner-div'><input type='checkbox' id='bold' value='off'>Bold</input> <input type='checkbox' id='italic' value='off'>Italic</input></div> " +

+                "<div class='highlight-content-inner-div'><input type='color' id='forecolor' value='#FFFFFF'>Foreground Color</input> &nbsp;<input type='color' id='backcolor' value='#ff0000'>Background Color</input></div> ";

+            $("#highlighting").append(content);

+			var buttonsArr = [];

+			buttonsArr.push({

+				 text : "OK",

+				 click : function() {

+				    clearAll();

+                    if(searchCriteria.length > 0) {

+                    	var logEntries = logarea[0].getElementsByTagName("span");

+                        for(var j=0; j<logEntries.length; j++) {

+                            var text = logEntries[j].innerHTML;

+                            for(var i=0; i < searchCriteria.length; i++) {

+                                if(text.indexOf(searchCriteria[i]["string"]) >=0) {

+                                    if(searchCriteria[i]["bold"]) {text = "<b>" + text + "</b>";}

+                                    if(searchCriteria[i]["italic"]) {text = "<i>" + text + "</i>";}

+                                    var color = "red"; if(searchCriteria[i]["forecolor"]) {color = searchCriteria[i]["forecolor"];}

+                                    var bg = ""; if(searchCriteria[i]["backcolor"]) {bg = searchCriteria[i]["backcolor"];}

+                                    logEntries[j].innerHTML = text;

+                                    logEntries[j].style.color = color;

+                                    logEntries[j].style.backgroundColor = bg;

+                                }

+                            }

+                        }

+                    }

+                    $(this).dialog("close");

+				 }

+			});

+			buttonsArr.push({

+				 text : "Cancel",

+				 click : function() {

+					$(this).dialog("close");

+				}

+			});

+

+            var modal = $("#highlighting").dialog({

+                autoOpen: false,

+                width: "30rem",

+                modal: true,

+                buttons: buttonsArr,

+                open: function(event, ui) {

+                    $(this).data("bkp-load-val", _load);

+                    if(_load) {

+                        $(".pause").click();

+                    }

+                },

+                close: function(event, ui) {

+                    if($(this).data("bkp-load-val") && !_load) {

+                        $(".pause").click();

+                    }

+                    $(this).removeData("bkp-load-val");

+                }

+            });

+

+            $("#highlighting").find(".add").click(function(e) {

+                var val = $("#highlighting").find("#search").val();

+                var color = $("#forecolor").val();

+                var bg = $("#backcolor").val();

+                var b = $("#bold").is(":checked");

+                var i = $("#italic").is(":checked");

+                var index = searchCriteria.length;

+                searchCriteria.push({"string":val, "bold":b, "italic":i, "forecolor":color, "backcolor":bg});

+                $("#highlighting").find(".criteria-list").append("<li class='criteria-item' data-index='" + index + "'><span class='box' style='color:"+color+";background-color:"+bg+";font:" + (b?" bold ":"") + (i?" italic ":"") + ";'>" + val + "</span>" + val + "</li>");

+                $("#highlighting").find("#search").val("");

+                $("#highlighting").find("#bold")[0].checked = false;

+                $("#highlighting").find("#italic")[0].checked = false;

+            });

+

+            $("#highlighting").find(".delete").click(function(e) {

+                var $selected = $("#highlighting").find(".criteria-item.selected");

+                if($selected.length == 0) return;

+

+				var index = parseInt($(e.target).data("index"));

+				searchCriteria.splice(index, 1);

+

+				$selected.remove();

+            });

+

+            $("#highlighting").on("click", ".criteria-item", function(e) {

+            	$(e.target).toggleClass("selected").siblings().removeClass("selected");

+            });

+

+		}

+

+        $(".highlighting").click(function(e) {

+			$("#highlighting").dialog("open");

+        });

+

+        $(".clear").click(function(e) {

+            logarea.empty();

+        });

+

+        $(".refresh").click(function(e) {

+			sendCmd("reset");

+            _load = false;

+            logarea.empty();

+			$("#filter").val("");

+            document.cookie = "log.tail.position=0";

+            _load = true;

+        });

+

+        $(".filter").click(function(e) {

+            var filterVal = $("#filter").val();

+            sendCmd("filter:"+filterVal);

+        });

+

+        $(".filterClear").click(function(e) {

+            $("#filter").val("");

+            $(".filter").click();

+        });

+

+		$(".tail").click(function(e) {

+            var $elem = $(e.target);

+            var currStatus = $elem.data("following");

+            $elem.data("following", !currStatus);

+            follow = $elem.data("following");

+            if(follow) {

+				$elem.attr("title", "Unfollow Tail");

+				$elem.html("Unfollow");

+            }

+            else {

+                $elem.attr("title", "Follow Tail");

+                $elem.html("Follow");

+            }

+        });

+

+        $(".sizeplus").click(function(e) {

+            logfontsize++;

+			$(".content").css("font-size", logfontsize+"px");

+        });

+

+        $(".sizeminus").click(function(e) {

+            logfontsize--;

+			$(".content").css("font-size", logfontsize+"px");

+        });

+

+        $(".pause").click(function(e) {

+            var $elem = $(e.target);

+            if(_load) {

+                $elem.attr("title", "Click to Resume");

+                $elem.html("Resume");

+

+                $(".loadingstatus").data("status", "inactive");

+                _load = false;

+	        }

+            else {

+				$elem.attr("title", "Click to Pause");

+                $elem.html("Pause");

+                _load = true;

+            }

+        });

+

+        $(".top").click(function(e) {

+			$("html,body").scrollTop(0);

+            if(follow) {

+                $(".tail").click();

+            }

+        });

+

+        $(".bottom").click(function(e) {

+			$("html,body").scrollTop(logarea[0].scrollHeight);

+            follow = true;

+        });

+

+        $(".numbering").click(function(e) {

+			var $elem = $(e.target);

+			var currStatus = $elem.data("numbers");

+			$elem.data("numbers", !currStatus);

+			_isLineNumShowing = $elem.data("numbers");

+            if(_isLineNumShowing) {

+                $(".lineNumberCol").removeClass("hide");

+                $elem.attr("title", "Hide Line Numbers");

+                $elem.html("Hide Line No.");

+            }

+            else {

+                $(".lineNumberCol").addClass("hide");

+                $elem.attr("title", "Show Line Numbers");

+                $elem.html("Show Line No.");

+            }

+        });

+

+        $(".slower").click(function(e) {

+            refreshInterval += 500;

+			$("#speed").val(refreshInterval);

+        });

+

+        $(".faster").click(function(e) {

+            if(refreshInterval >= 1500) {

+            	refreshInterval -= 500;

+            }

+			$("#speed").val(refreshInterval);

+        });

+

+        var timerFunc = function(){

+            if(_load) {

+                loadTail();

+            }

+        };

+

+        $("#speed").change(function(e) {

+			refreshInterval = parseInt($(e.target).val());

+            clearInterval(intervalObj);

+            intervalObj = setInterval(timerFunc, refreshInterval);

+        });

+

+        var intervalObj = setInterval(timerFunc, refreshInterval);

+

+        var getScrollbarWidth = function() {

+            var outer = document.createElement("div");

+            outer.style.visibility = "hidden";

+            outer.style.width = "100px";

+            outer.style.msOverflowStyle = "scrollbar"; // needed for WinJS apps

+

+            document.body.appendChild(outer);

+

+            var widthNoScroll = outer.offsetWidth;

+            // force scrollbars

+            outer.style.overflow = "scroll";

+

+            // add innerdiv

+            var inner = document.createElement("div");

+            inner.style.width = "100%";

+            outer.appendChild(inner);

+

+            var widthWithScroll = inner.offsetWidth;

+

+            // remove divs

+            outer.parentNode.removeChild(outer);

+

+            return widthNoScroll - widthWithScroll;

+        };

+

+        var w = Math.max(document.documentElement.clientWidth, window.innerWidth || 0) - getScrollbarWidth();

+

+        $(".pulldown").width(w);

+        $(".header").width(w);

+        $(".pulldown").click(function() {

+             if($(".header").is(":visible")) {

+                $(".header").slideUp();

+             }

+             else {

+                $(".header").slideDown();

+                $(".pulldown").attr("title", "Click to hide options");

+             }

+        });

+

+        var statusOpacity = 0.2;

+

+        setInterval(function() {

+            var status = $(".loadingstatus").data("status");

+            var color = "grey";

+            switch(status) {

+                case "error":color="red";break;

+                case "inactive":color="grey";break;

+                case "active":color="green";break;

+                default:color="grey";

+            }

+            $(".loadingstatus").find("li").css("color", color);

+            $(".loadingstatus").find("li").html("<span style='border-radius:10px;'>"+status+"</span>");

+

+            if(status == "active") {

+                $(".loadingstatus").fadeTo(1000, statusOpacity);

+                if(statusOpacity == 0.2) {

+                    statusOpacity = 1.0;

+                }

+                else {

+                    statusOpacity = 0.2;

+                }

+            }

+            else {

+                statusOpacity = 1.0;

+                $(".loadingstatus").css("opacity", statusOpacity);

+            }

+        }, 1000);

+

+        $("#logfiles").change(function() {

+            var selected = $("#logfiles").val();

+            if(selected != "") {

+                _load = false;

+                sendCmd("file:"+selected, function(){_load=true;});

+            }

+        });

+	});
\ No newline at end of file