Added wip log rotate feature

- Added log rotate function interface
- Added darwin amd64 support in make file (Intel Macs)
- Added log summary and error API
This commit is contained in:
Toby Chui
2025-08-31 22:22:45 +08:00
parent d9fd38260f
commit c3afdefe45
6 changed files with 503 additions and 400 deletions

View File

@@ -1,5 +1,5 @@
# PLATFORMS := darwin/amd64 darwin/arm64 freebsd/amd64 linux/386 linux/amd64 linux/arm linux/arm64 linux/mipsle windows/386 windows/amd64 windows/arm windows/arm64
PLATFORMS := linux/amd64 linux/386 linux/arm linux/arm64 linux/mipsle linux/riscv64 windows/amd64 freebsd/amd64
PLATFORMS := linux/amd64 linux/386 linux/arm linux/arm64 linux/mipsle linux/riscv64 windows/amd64 freebsd/amd64 darwin/amd64
temp = $(subst /, ,$@)
os = $(word 1, $(temp))
arch = $(word 2, $(temp))

View File

@@ -383,7 +383,7 @@ func initAPIs(targetMux *http.ServeMux) {
authRouter.HandleFunc("/api/log/list", LogViewer.HandleListLog)
authRouter.HandleFunc("/api/log/read", LogViewer.HandleReadLog)
authRouter.HandleFunc("/api/log/summary", LogViewer.HandleReadLogSummary)
authRouter.HandleFunc("/api/log/errors", LogViewer.HandleLogErrorSummary)
//Debug
authRouter.HandleFunc("/api/info/pprof", pprof.Index)
}

View File

@@ -21,6 +21,7 @@ type Logger struct {
Prefix string //Prefix for log files
LogFolder string //Folder to store the log file
CurrentLogFile string //Current writing filename
RotateOption RotateOption //Options for log rotation, see rotate.go
logger *log.Logger
file *os.File
}

View File

@@ -0,0 +1,127 @@
package logger
import (
"archive/zip"
"fmt"
"io"
"os"
"path/filepath"
"sort"
"time"
)
type RotateOption struct {
Enabled bool //Whether log rotation is enabled
MaxSize int64 //Maximum size of the log file in bytes before rotation (e.g. 10 * 1024 * 1024 for 10MB)
MaxBackups int //Maximum number of backup files to keep
Compress bool //Whether to compress the rotated files
BackupDir string //Directory to store backup files, if empty, use the same directory as the log file
}
func (l *Logger) LogNeedRotate(filename string) bool {
if !l.RotateOption.Enabled {
return false
}
info, err := os.Stat(filename)
if err != nil {
return false
}
return info.Size() >= l.RotateOption.MaxSize
}
func (l *Logger) RotateLog() error {
if !l.RotateOption.Enabled {
return nil
}
needRotate := l.LogNeedRotate(l.CurrentLogFile)
if !needRotate {
return nil
}
//Close current file
if l.file != nil {
l.file.Close()
}
// Determine backup directory
backupDir := l.RotateOption.BackupDir
if backupDir == "" {
backupDir = filepath.Dir(l.CurrentLogFile)
}
// Ensure backup directory exists
if err := os.MkdirAll(backupDir, 0755); err != nil {
return err
}
// Generate rotated filename with timestamp
timestamp := time.Now().Format("20060102-150405")
baseName := filepath.Base(l.CurrentLogFile)
rotatedName := fmt.Sprintf("%s.%s", baseName, timestamp)
rotatedPath := filepath.Join(backupDir, rotatedName)
// Rename current log file to rotated file
if err := os.Rename(l.CurrentLogFile, rotatedPath); err != nil {
return err
}
// Optionally compress the rotated file
if l.RotateOption.Compress {
if err := compressFile(rotatedPath); err != nil {
return err
}
// Remove the uncompressed rotated file after compression
os.Remove(rotatedPath)
rotatedPath += ".gz"
}
// Remove old backups if exceeding MaxBackups
if l.RotateOption.MaxBackups > 0 {
files, err := filepath.Glob(filepath.Join(backupDir, baseName+".*"))
if err == nil && len(files) > l.RotateOption.MaxBackups {
sort.Slice(files, func(i, j int) bool {
return files[i] < files[j]
})
for _, old := range files[:len(files)-l.RotateOption.MaxBackups] {
os.Remove(old)
}
}
}
// Reopen a new log file
file, err := os.OpenFile(l.CurrentLogFile, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0644)
if err != nil {
return err
}
l.file = file
return nil
}
// compressFile compresses the given file using zip format and creates a .gz file.
func compressFile(filename string) error {
zipFilename := filename + ".gz"
outFile, err := os.Create(zipFilename)
if err != nil {
return err
}
defer outFile.Close()
zipWriter := zip.NewWriter(outFile)
defer zipWriter.Close()
fileToCompress, err := os.Open(filename)
if err != nil {
return err
}
defer fileToCompress.Close()
w, err := zipWriter.Create(filepath.Base(filename))
if err != nil {
return err
}
_, err = io.Copy(w, fileToCompress)
return err
}

View File

@@ -126,6 +126,52 @@ func (v *Viewer) HandleReadLogSummary(w http.ResponseWriter, r *http.Request) {
utils.SendJSONResponse(w, summary)
}
func (v *Viewer) HandleLogErrorSummary(w http.ResponseWriter, r *http.Request) {
filename, err := utils.GetPara(r, "file")
if err != nil {
utils.SendErrorResponse(w, "invalid filename given")
return
}
content, err := v.LoadLogFile(strings.TrimSpace(filepath.Base(filename)))
if err != nil {
utils.SendErrorResponse(w, err.Error())
return
}
//Generate the error summary for log that is request and non 100 - 200 range status code
errorLines := [][]string{}
lines := strings.Split(content, "\n")
for _, line := range lines {
if strings.TrimSpace(line) == "" {
continue
}
// Only process router logs with a status code not in 1xx or 2xx
if strings.Contains(line, "[router:") {
//Extract date time from the line
timestamp := ""
if strings.HasPrefix(line, "[") && strings.Contains(line, "]") {
timestamp = line[1:strings.Index(line, "]")]
}
//Trim out the request metadata
line = line[strings.LastIndex(line, "]")+1:]
fields := strings.Fields(strings.TrimSpace(line))
if len(fields) > 0 {
statusStr := fields[2]
if len(statusStr) == 3 && (statusStr[0] != '1' && statusStr[0] != '2' && statusStr[0] != '3') {
fieldsWithTimestamp := append([]string{timestamp}, strings.Fields(strings.TrimSpace(line))...)
errorLines = append(errorLines, fieldsWithTimestamp)
}
}
}
}
js, _ := json.Marshal(errorLines)
utils.SendJSONResponse(w, string(js))
}
/*
Log Access Functions
*/
@@ -322,104 +368,3 @@ func (v *Viewer) LoadLogSummary(filename string) (string, error) {
return "", errors.New("log file not found")
}
}
/*
Log examples:
[2025-08-18 21:02:15.664246] [router:host-http] [origin:test1.localhost] [client: 127.0.0.1] [useragent: Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:141.0) Gecko/20100101 Firefox/141.0] GET /system/file_system/listDirHash?dir=s2%3A%2FMusic%2FMusic%20Bank%2FYear%202025%2F08-2025%2F 200
[2025-08-18 21:02:20.682091] [router:host-http] [origin:test1.localhost] [client: 127.0.0.1] [useragent: Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:141.0) Gecko/20100101 Firefox/141.0] GET /system/file_system/listDirHash?dir=s2%3A%2FMusic%2FMusic%20Bank%2FYear%202025%2F08-2025%2F 200
[2025-08-18 21:02:20.725569] [internal] [system:info] mDNS scan result updated
[2025-08-17 20:24:38.669488] [uptime-monitor] [system:info] Uptime updated - 1755433478
[2025-08-17 20:25:08.247535] [internal] [system:info] mDNS scan result updated
[2025-08-17 20:29:38.669187] [uptime-monitor] [system:info] Uptime updated - 1755433778
[2025-08-17 20:34:38.669090] [uptime-monitor] [system:info] Uptime updated - 1755434078
[2025-08-17 20:39:38.668610] [uptime-monitor] [system:info] Uptime updated - 1755434378
[2025-08-17 20:40:08.248890] [internal] [system:info] mDNS scan result updated
[2025-08-17 20:44:38.669058] [uptime-monitor] [system:info] Uptime updated - 1755434678
[2025-08-17 20:49:38.669340] [uptime-monitor] [system:info] Uptime updated - 1755434978
[2025-08-17 20:54:38.668785] [uptime-monitor] [system:info] Uptime updated - 1755435278
[2025-08-17 20:55:08.247715] [internal] [system:info] mDNS scan result updated
[2025-08-17 20:59:38.668575] [uptime-monitor] [system:info] Uptime updated - 1755435578
[2025-08-17 21:04:38.669637] [uptime-monitor] [system:info] Uptime updated - 1755435878
[2025-08-17 21:09:38.669109] [uptime-monitor] [system:info] Uptime updated - 1755436178
[2025-08-17 21:10:08.247618] [internal] [system:info] mDNS scan result updated
[2025-08-17 21:14:38.668828] [uptime-monitor] [system:info] Uptime updated - 1755436478
[2025-08-17 21:19:38.669091] [uptime-monitor] [system:info] Uptime updated - 1755436778
[2025-08-17 21:24:38.668830] [uptime-monitor] [system:info] Uptime updated - 1755437078
[2025-08-17 21:25:08.246931] [internal] [system:info] mDNS scan result updated
[2025-08-17 21:29:38.673217] [uptime-monitor] [system:info] Uptime updated - 1755437378
[2025-08-17 21:34:38.668883] [uptime-monitor] [system:info] Uptime updated - 1755437678
[2025-08-17 21:39:38.668980] [uptime-monitor] [system:info] Uptime updated - 1755437978
[2025-08-17 21:40:08.266062] [internal] [system:info] mDNS scan result updated
[2025-08-17 21:44:38.669150] [uptime-monitor] [system:info] Uptime updated - 1755438278
[2025-08-17 21:49:38.668640] [uptime-monitor] [system:info] Uptime updated - 1755438578
[2025-08-17 21:54:38.669275] [uptime-monitor] [system:info] Uptime updated - 1755438878
[2025-08-17 21:55:08.266425] [internal] [system:info] mDNS scan result updated
[2025-08-17 21:59:38.668861] [uptime-monitor] [system:info] Uptime updated - 1755439178
[2025-08-17 22:04:38.668840] [uptime-monitor] [system:info] Uptime updated - 1755439478
[2025-08-17 22:09:38.668798] [uptime-monitor] [system:info] Uptime updated - 1755439778
[2025-08-17 22:10:08.266417] [internal] [system:info] mDNS scan result updated
[2025-08-17 22:14:38.669122] [uptime-monitor] [system:info] Uptime updated - 1755440078
[2025-08-17 22:19:38.668810] [uptime-monitor] [system:info] Uptime updated - 1755440378
[2025-08-17 22:21:35.947519] [netstat] [system:info] Netstats listener stopped
[2025-08-17 22:21:35.947519] [internal] [system:info] Shutting down Zoraxy
[2025-08-17 22:21:35.947519] [internal] [system:info] Closing Netstats Listener
[2025-08-17 22:21:35.970526] [plugin-manager] [system:error] plugin com.example.restful-example encounted a fatal error. Disabling plugin...: exit status 0xc000013a
[2025-08-17 22:21:35.970526] [plugin-manager] [system:error] plugin org.aroz.zoraxy.api_call_example encounted a fatal error. Disabling plugin...: exit status 0xc000013a
[2025-08-17 22:21:36.250929] [internal] [system:info] Closing Statistic Collector
[2025-08-17 22:21:36.318808] [internal] [system:info] Stopping mDNS Discoverer (might take a few minutes)
[2025-08-17 22:21:36.319829] [internal] [system:info] Shutting down load balancer
[2025-08-17 22:21:36.319829] [internal] [system:info] Closing Certificates Auto Renewer
[2025-08-17 22:21:36.319829] [internal] [system:info] Closing Access Controller
[2025-08-17 22:21:36.319829] [internal] [system:info] Shutting down plugin manager
[2025-08-17 22:21:36.319829] [internal] [system:info] Cleaning up tmp files
[2025-08-17 22:21:36.328033] [internal] [system:info] Stopping system database
[2025-08-18 20:31:49.673182] [database] [system:info] Using BoltDB as the database backend
[2025-08-18 20:31:49.784069] [auth] [system:info] Authentication session key loaded from database
[2025-08-18 20:31:50.290804] [LoadBalancer] [system:info] Upstream state cache ticker started
[2025-08-18 20:31:50.510300] [static-webserv] [system:info] Static Web Server started. Listeing on :5487
[2025-08-18 20:31:51.017433] [internal] [system:info] Starting ACME handler
[2025-08-18 20:31:51.022545] [cert-renew] [system:info] ACME early renew set to 30 days and check interval set to 86400 seconds
[2025-08-18 20:31:51.073031] [plugin-manager] [system:info] Hot reload ticker started
[2025-08-18 20:31:51.357203] [plugin-manager] [system:info] Loaded plugin: API Call Example Plugin
[2025-08-18 20:31:51.357782] [plugin-manager] [system:info] Generated API key for plugin API Call Example Plugin
[2025-08-18 20:31:51.358293] [plugin-manager] [system:info] Starting plugin API Call Example Plugin at :5974
[2025-08-18 20:31:51.406867] [plugin-manager] [system:info] [API Call Example Plugin:13316] Starting API Call Example Plugin on 127.0.0.1:5974
[2025-08-18 20:31:51.466380] [plugin-manager] [system:info] Plugin list synced from plugin store
[2025-08-18 20:31:51.662866] [plugin-manager] [system:info] Loaded plugin: Restful Example
[2025-08-18 20:31:51.663383] [plugin-manager] [system:info] Starting plugin Restful Example at :5874
[2025-08-18 20:31:51.688641] [plugin-manager] [system:info] [Restful Example:10500] Restful-example started at http://127.0.0.1:5874
[2025-08-18 20:31:51.721309] [plugin-manager] [system:info] Plugin hash generated for: org.aroz.zoraxy.api_call_example
[2025-08-18 20:31:51.777523] [plugin-manager] [system:info] Plugin hash generated for: com.example.restful-example
[2025-08-18 20:31:51.789497] [internal] [system:info] Inbound port not set. Using default (443)
[2025-08-18 20:31:51.789497] [internal] [system:info] TLS mode enabled. Serving proxy request with TLS
[2025-08-18 20:31:51.789497] [internal] [system:info] Development mode enabled. Using no-store Cache Control policy
[2025-08-18 20:31:51.790016] [internal] [system:info] Force latest TLS mode disabled. Minimum TLS version is set to v1.0
[2025-08-18 20:31:51.790016] [internal] [system:info] Port 80 listener enabled
[2025-08-18 20:31:51.790016] [internal] [system:info] Force HTTPS mode enabled
[2025-08-18 20:31:51.825385] [proxy-config] [system:info] *.yami.localhost -> 192.168.0.16:8080 routing rule loaded
[2025-08-18 20:31:51.833567] [proxy-config] [system:info] a.localhost -> imuslab.com routing rule loaded
[2025-08-18 20:31:51.849358] [proxy-config] [system:info] aroz.localhost -> 192.168.0.16:8080 routing rule loaded
[2025-08-18 20:31:51.852977] [proxy-config] [system:info] auth.localhost -> localhost:5488 routing rule loaded
[2025-08-18 20:31:51.866792] [proxy-config] [system:info] debug.localhost -> dc.imuslab.com:8080 routing rule loaded
[2025-08-18 20:31:51.878091] [proxy-config] [system:info] peer.localhost -> 192.168.0.16:8080 routing rule loaded
[2025-08-18 20:31:51.887843] [proxy-config] [system:info] / -> 127.0.0.1:5487 routing rule loaded
[2025-08-18 20:31:51.895039] [proxy-config] [system:info] test.imuslab.com -> 192.168.1.202:8443 routing rule loaded
[2025-08-18 20:31:51.909917] [proxy-config] [system:info] test.imuslab.internal -> 127.0.0.1:80 routing rule loaded
[2025-08-18 20:31:51.922685] [proxy-config] [system:info] test.localhost -> alanyeung.co routing rule loaded
[2025-08-18 20:31:51.937314] [proxy-config] [system:info] webdav.localhost -> 127.0.0.1:80/redirect routing rule loaded
[2025-08-18 20:31:52.239414] [dprouter] [system:info] Starting HTTP-to-HTTPS redirector (port 80)
[2025-08-18 20:31:52.239414] [internal] [system:info] Dynamic Reverse Proxy service started
[2025-08-18 20:31:52.239414] [dprouter] [system:info] Reverse proxy service started in the background (TLS mode)
[2025-08-18 20:31:52.289262] [internal] [system:info] Zoraxy started. Visit control panel at http://localhost:8000
[2025-08-18 20:31:52.289262] [internal] [system:info] Assigned temporary port:36951
[2025-08-18 20:31:54.513995] [internal] [system:info] Uptime Monitor background service started
[2025-08-18 20:32:20.725596] [internal] [system:info] mDNS Startup scan completed
[2025-08-18 20:36:52.239883] [uptime-monitor] [system:info] Uptime updated - 1755520612
[2025-08-18 20:56:14.160166] [router:host-http] [origin:test1.localhost] [client: 127.0.0.1] [useragent: Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:141.0) Gecko/20100101 Firefox/141.0] GET /system/file_system/preference?key=file_explorer/theme 200
[2025-08-18 20:56:14.160166] [router:host-http] [origin:test1.localhost] [client: 127.0.0.1] [useragent: Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:141.0) Gecko/20100101 Firefox/141.0] GET /system/file_system/preference?key=file_explorer/listmode 200
[2025-08-18 20:56:14.160166] [router:host-http] [origin:test1.localhost] [client: 127.0.0.1] [useragent: Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:141.0) Gecko/20100101 Firefox/141.0] GET /system/file_system/preference?key=file_explorer/listmode 200
[2025-08-18 20:56:14.170270] [router:host-http] [origin:test1.localhost] [client: 127.0.0.1] [useragent: Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:141.0) Gecko/20100101 Firefox/141.0] GET /system/file_system/listRoots?user=true 200
[2025-08-18 20:56:14.171752] [router:host-http] [origin:test1.localhost] [client: 127.0.0.1] [useragent: Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:141.0) Gecko/20100101 Firefox/141.0] GET /system/id/requestInfo 200
*/

View File

@@ -11,7 +11,6 @@
<script type="text/javascript" src="../script/jquery-3.6.0.min.js"></script>
<script type="text/javascript" src="../script/semantic/semantic.min.js"></script>
<link rel="stylesheet" href="main.css">
<link rel="stylesheet" href="darktheme.css">
<style>
.clickable{
cursor: pointer;
@@ -70,59 +69,16 @@
color:white;
}
body.darkTheme .loglist {
background-color: #1b1c1d;
color: #ffffff;
#errorHighlightWrapper{
background-color: #f9f9f9;
padding: 1em;
border-radius: 0.3em;
max-height:300px;
overflow-y:auto;
margin-bottom:1em;
border: 1px solid #b3b3b3;
}
body.darkTheme .loglist .ui.header .content .sub.header {
color: #bbbbbb;
}
body.darkTheme .loglist .ui.divider {
border-color: #333333;
}
body.darkTheme .loglist .ui.accordion .title,
body.darkTheme .loglist .ui.accordion .content {
background-color: #1b1c1d;
color: #ffffff;
}
body.darkTheme .loglist .ui.accordion .title:hover {
background-color: #333333;
}
body.darkTheme .loglist .ui.list .item {
color: #ffffff;
}
body.darkTheme .loglist .ui.list .item .content {
color: #ffffff;
}
body.darkTheme .loglist .ui.list .item .showing {
color: #ffffff;
}
body.darkTheme .loglist .ui.button.filterbtn {
background-color: #333333;
color: #ffffff;
}
body.darkTheme .loglist .ui.button.filterbtn:hover {
background-color: #555555;
}
body.darkTheme .loglist .ui.toggle.checkbox label {
color: #ffffff;
}
body.darkTheme .loglist small {
color: #bbbbbb;
}
@media (min-width: 768px) {
#logfileDropdown {
margin-left: 0.4em !important;
@@ -136,23 +92,35 @@
margin-left: 0.4em !important;
}
}
@media (max-width: 767px) {
#toggleFullscreenBtn {
display: none !important;
}
#logrenderWrapper{
margin-left: 0em !important;
margin-right: 0em !important;
}
}
</style>
</head>
<body>
<!-- Currently not darktheme ready -->
<link rel="stylesheet" href="../darktheme.css">
<script src="../script/darktheme.js"></script>
<!-- <script src="../script/darktheme.js"></script> -->
<br>
<div class="ui container">
<div class="ui stackable secondary menu">
<div class="item" style="font-weight: bold;">
Zoraxy LogView
</div>
<a class="item active panel_menu_btn" id="dashboardMenu">
Dashboard
</a>
<a class="item panel_menu_btn" id="logViewMenu">
<a class="item active panel_menu_btn" id="logViewMenu">
Log View
</a>
<a class="item panel_menu_btn" id="summaryMenu">
Summary
</a>
<a class="item panel_menu_btn" id="settingsMenu">
Settings
</a>
@@ -194,33 +162,84 @@
</div>
</div>
<br><br>
<!-- Dashboard View -->
<div id="dashboard" class="ui container subpanel">
<!-- Summary View -->
<div id="summary" class="ui container subpanel">
<h3 class="ui header">
Dashboard
Summary
</h3>
<div class="ui divider"></div>
<p>Welcome to LogVPro! Use the left menu to select a log file </p>
<div id="analyzer">
<div class="ui small statistics" style="display: flex; justify-content: center;">
<div class="statistic">
<div class="value" id="successRequestsCount"></div>
<div class="label">Success Requests</div>
</div>
<div class="statistic">
<div class="value" id="errorRequestsCount"></div>
<div class="label">Error Requests</div>
</div>
<div class="statistic">
<div class="value" id="totalRequestsCount"></div>
<div class="label">Total Requests</div>
</div>
</div>
<div class="ui divider"></div>
<!-- Error Highlight Section -->
<h4 class="ui header">Error Highlights</h4>
<div id="errorHighlightWrapper">
<p><i class="ui green circle check icon"></i> No error data loaded</p>
</div>
<div class="ui divider"></div>
<div id="analyzer">
<div class="ui info message">
<div class="header">
No log file selected
</div>
<p>Please select a log file from the dropdown menu to view analysis and summary.</p>
</div>
</div>
</div>
<!-- Log Viewer -->
<div id="logviewer" class="ui container subpanel" style="display:none;">
<div id="logviewer" class="subpanel" style="display:none;">
<div id="logrenderWrapper" class="ui container" style="position:relative;">
<textarea id="logrender" spellcheck="false" readonly="true">
Pick a log file from the menu to start debugging
</textarea>
<br><br>
<!-- Full Screen Button -->
<button id="toggleFullscreenBtn" class="ui icon black button" title="Toggle Full Screen" style="border: 1px solid white; position:absolute; top:0.5em; right:0.5em; z-index:999;">
<i class="expand arrows alternate icon"></i>
</button>
<!-- Scroll to Bottom Button -->
<button id="scrollBottomBtn" class="ui icon black button" title="Scroll to Bottom" style="border: 1px solid white; position:absolute; bottom:0.5em; right:0.5em; z-index:999;">
<i class="angle double down icon"></i>
</button>
<script>
$("#scrollBottomBtn").on("click", function() {
var textarea = document.getElementById('logrender');
textarea.scrollTop = textarea.scrollHeight;
});
</script>
</div>
<script>
// Toggle full screen for logrenderWrapper
$("#toggleFullscreenBtn").on("click", function() {
$("#logrenderWrapper").toggleClass("ui container");
$(this).find("i").toggleClass("expand arrows alternate compress arrows alternate");
});
</script>
<br>
<div class="ui container">
<div class="ui divider"></div>
<div class="ui toggle checkbox">
<input type="checkbox" id="enableAutoScroll" onchange="handleAutoScrollTicker(event, this.checked);">
<label>Auto Refresh<br>
<small>Refresh the viewing log every 10 seconds</small></label>
<small>Refresh the viewing log every 3 seconds</small></label>
</div>
<div class="ui divider"></div>
<small>Notes: Some log files might be huge. Make sure you have checked the log file size before opening</small>
</div>
</div>
<!-- Settings -->
<div id="settings" class="ui container subpanel" style="display:none;">
@@ -247,14 +266,14 @@ Pick a log file from the menu to start debugging
/* Menu Subpanel Switch */
$(".subpanel").hide();
$("#dashboard").show();
$("#logviewer").show();
$(".panel_menu_btn").on("click", function() {
var id = $(this).attr("id");
$(".subpanel").hide();
$(".ui.menu .item").removeClass("active");
$(this).addClass("active");
if (id === "dashboardMenu") {
$("#dashboard").show();
if (id === "summaryMenu") {
$("#summary").show();
} else if (id === "logViewMenu") {
$("#logviewer").show();
} else if (id === "settingsMenu") {
@@ -276,12 +295,18 @@ Pick a log file from the menu to start debugging
});
}
$('#logfileDropdown').dropdown('refresh');
//let firstItem = $menu.find('.item').first();
//if (firstItem.length) {
// $('#logfileDropdown').dropdown('set selected', firstItem.data('value'));
//}
});
}
$('#logfileDropdown').dropdown({
onChange: function(value, text, $choice) {
if (value) {
openLog(null, $choice.data('category'), value, currentFilter || "all");
loadLogSummary(value);
}
}
});
@@ -333,6 +358,198 @@ Pick a log file from the menu to start debugging
});
});
/* Analyzer */
function loadLogSummary(filename){
$.get("/api/log/summary?file=" + filename, function(data){
if (data.error !== undefined){
$("#analyzer").html("<div class='ui negative message'><div class='header'>Error</div><p>" + data.error + "</p></div>");
return;
}
$("#successRequestsCount").text(data.total_valid || 0);
$("#errorRequestsCount").text(data.total_errors || 0);
$("#totalRequestsCount").text(data.total_requests || 0);
renderSummaryToHTML(data);
});
$.get("/api/log/errors?file=" + encodeURIComponent(filename), function(data) {
renderErrorHighlights(data);
}).fail(function() {
$("#errorHighlightWrapper").html(
`<div class="ui error message">
<div class="header">Failed to load error highlights</div>
</div>`
);
});
}
function renderSummaryToHTML(data){
/* Render summary analysis to #analyzer */
let html = `
<div class="ui stackable grid">
<div class="eight wide column">
<h4 class="ui header">Request Methods</h4>
<canvas id="requestMethodsChart" height="180"></canvas>
</div>
<div class="eight wide column">
<h4 class="ui header">Hits Per Day</h4>
<canvas id="hitsPerDayChart" height="180"></canvas>
</div>
</div>
<div class="ui divider"></div>
<div class="ui stackable grid">
<div class="sixteen wide column">
<h4 class="ui header">Hits Per Site</h4>
<div id="siteCharts"></div>
</div>
</div>
<div class="ui divider"></div>
<div class="ui stackable grid">
<div class="eight wide column">
<h4 class="ui header">Unique IPs</h4>
<table class="ui celled table">
<thead><tr><th>IP</th><th>Requests</th></tr></thead>
<tbody>
${Object.entries(data.unique_ips)
.sort((a, b) => b[1] - a[1])
.map(([ip, count]) => `<tr><td>${ip}</td><td>${count}</td></tr>`)
.join("")}
</tbody>
</table>
</div>
<div class="eight wide column">
<h4 class="ui header">Top User Agents</h4>
<table class="ui celled table">
<thead><tr><th>User Agent</th><th>Requests</th></tr></thead>
<tbody>
${Object.entries(data.top_user_agents)
.sort((a, b) => b[1] - a[1])
.map(([ua, count]) => `<tr><td style="word-break:break-all;">${ua}</td><td>${count}</td></tr>`)
.join("")}
</tbody>
</table>
</div>
</div>
<div class="ui divider"></div>
<div class="ui stackable grid">
<div class="sixteen wide column">
<h4 class="ui header">Top Paths</h4>
<table class="ui celled table">
<thead><tr><th>Path</th><th>Requests</th></tr></thead>
<tbody>
${Object.entries(data.top_paths)
.sort((a, b) => b[1] - a[1])
.map(([path, count]) => `<tr><td style="word-break:break-all;">${path}</td><td>${count}</td></tr>`)
.join("")}
</tbody>
</table>
</div>
</div>
`;
$("#analyzer").html(html);
// Load Chart.js if not loaded
function loadChartJs(cb) {
if (window.Chart) { cb(); return; }
let s = document.createElement("script");
s.src = "https://cdn.jsdelivr.net/npm/chart.js";
s.onload = cb;
document.head.appendChild(s);
}
// Prepare data for charts
function getDateRangeLabels(hitPerDay) {
let dates = Object.keys(hitPerDay).sort();
if (dates.length === 0) return [];
let start = new Date(dates[0]);
let end = new Date(dates[dates.length - 1]);
let labels = [];
for (let d = new Date(start); d <= end; d.setDate(d.getDate() + 1)) {
let y = d.getFullYear(), m = (d.getMonth() + 1).toString().padStart(2, "0"), day = d.getDate().toString().padStart(2, "0");
labels.push(`${y}-${m}-${day}`);
}
return labels;
}
loadChartJs(function() {
// 1. Pie chart for request methods
let reqMethods = data.request_methods || {};
let reqLabels = Object.keys(reqMethods);
let reqCounts = Object.values(reqMethods);
new Chart(document.getElementById("requestMethodsChart"), {
type: "pie",
data: {
labels: reqLabels,
datasets: [{
data: reqCounts,
backgroundColor: ["#4b75ff", "#21ba45", "#db2828", "#fbbd08", "#b5cc18", "#a333c8"]
}]
},
options: { responsive: true, plugins: { legend: { position: "bottom" } } }
});
// 2. Histogram for hit_per_day
let hitPerDay = data.hit_per_day || {};
let dayLabels = getDateRangeLabels(hitPerDay);
let dayCounts = dayLabels.map(d => hitPerDay[d] || 0);
new Chart(document.getElementById("hitsPerDayChart"), {
type: "bar",
data: {
labels: dayLabels,
datasets: [{
label: "Hits",
data: dayCounts,
backgroundColor: "#4b75ff"
}]
},
options: {
responsive: true,
plugins: { legend: { display: false } },
scales: { x: { ticks: { autoSkip: false, maxRotation: 90, minRotation: 45 } } }
}
});
// 3. Line chart for each site in hit_per_site
let siteChartsDiv = $("#siteCharts");
let siteData = data.hit_per_site || {};
let i = 0;
for (let [site, counts] of Object.entries(siteData)) {
let canvasId = "siteChart_" + i;
siteChartsDiv.append(`
<div style="margin-bottom:2em;">
<b>${site}</b>
<canvas id="${canvasId}" height="100"></canvas>
</div>
`);
// If counts length doesn't match dayLabels, pad left with zeros
let paddedCounts = counts.slice(-dayLabels.length);
while (paddedCounts.length < dayLabels.length) paddedCounts.unshift(0);
new Chart(document.getElementById(canvasId), {
type: "line",
data: {
labels: dayLabels,
datasets: [{
label: "Hits",
data: paddedCounts,
fill: false,
borderColor: "#21ba45",
backgroundColor: "#21ba45",
tension: 0.2
}]
},
options: {
responsive: true,
plugins: { legend: { display: false } },
scales: { x: { ticks: { autoSkip: false, maxRotation: 90, minRotation: 45 } } }
}
});
i++;
}
});
}
function openLog(object, catergory, filename, filter="all"){
$(".logfile.active").removeClass('active');
@@ -347,48 +564,13 @@ Pick a log file from the menu to start debugging
renderLogWithCurrentFilter(data);
});
}
</script>
<script>
function openLogInNewTab(){
if (currentOpenedLogURL != ""){
window.open(currentOpenedLogURL);
}
}
function initLogList(){
$("#logList").html("");
$.get("/api/log/list", function(data){
//console.log(data);
for (let [key, value] of Object.entries(data)) {
console.log(key, value);
value.reverse(); //Default value was from oldest to newest
var fileItemList = "";
value.forEach(file => {
fileItemList += `<div class="item clickable logfile" onclick="openLog(this, '${key}','${file.Filename}');">
<i class="file outline icon"></i>
<div class="content">
${file.Title} (${formatBytes(file.Filesize)})
<div class="showing"><i class="green chevron right icon"></i></div>
</div>
</div>`;
})
$("#logList").append(`<div class="title">
<i class="dropdown icon"></i>
${key}
</div>
<div class="content">
<div class="ui list">
${fileItemList}
</div>
</div>`);
}
$(".ui.accordion").accordion();
});
}
initLogList();
function formatBytes(x){
var units = ['bytes', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB'];
let l = 0, n = parseInt(x, 10) || 0;
@@ -405,26 +587,6 @@ Pick a log file from the menu to start debugging
textarea.scrollTop = textarea.scrollHeight;
}
/* Filter related functions */
$(".filterbtn").on("click", function(evt){
//Set filter type
let filterType = $(this).attr("filter");
currentFilter = (filterType);
$(".filterbtn.active").removeClass("active");
$(this).addClass('active');
//Reload the log with filter
if (currentOpenedLogURL != ""){
$.get(currentOpenedLogURL, function(data){
if (data.error !== undefined){
alert(data.error);
return;
}
renderLogWithCurrentFilter(data);
});
}
});
/* Auto scroll function */
setInterval(function(){
if (autoscroll){
@@ -439,174 +601,42 @@ Pick a log file from the menu to start debugging
});
}
}
}, 10000);
}, 3000);
function handleAutoScrollTicker(event, checked){
autoscroll = checked;
}
/* Log analyzer */
/* --- Log Analyzer UI --- */
$("#analyzer").html(`
<div class="ui segment">
<h4 class="ui header">Log Analytics</h4>
<div class="ui divider"></div>
<div class="ui grid">
<div class="eight wide column">
<canvas id="requestTypePie"></canvas>
</div>
<div class="eight wide column" id="originCharts"></div>
</div>
<div class="ui divider"></div>
<h5>Client IP Table</h5>
<div style="max-height:300px;overflow:auto;">
<table class="ui celled table" id="clientIpTable">
<thead>
<tr>
<th>Client IP</th>
<th>Requests</th>
</tr>
</thead>
<tbody></tbody>
</table>
</div>
</div>
`);
// Load Chart.js
if (typeof Chart === "undefined") {
var chartjs = document.createElement("script");
chartjs.src = "https://cdn.jsdelivr.net/npm/chart.js";
chartjs.onload = function() {
setupLogAnalyzer();
};
document.head.appendChild(chartjs);
} else {
setupLogAnalyzer();
}
function setupLogAnalyzer() {
// Add button to analyze current log
if ($("#analyzeLogBtn").length === 0) {
$("<button class='ui blue button' id='analyzeLogBtn' style='margin-bottom:1em;'>Analyze Current Log</button>")
.insertBefore("#analyzer .ui.segment");
}
$("#analyzeLogBtn").off("click").on("click", function() {
let log = $("#logrender").val();
if (!log || log.indexOf("Pick a log file") !== -1) {
alert("Please open a log file first.");
// Error Highlights Rendering
function renderErrorHighlights(errors) {
if (!Array.isArray(errors) || errors.length === 0) {
$("#errorHighlightWrapper").html(
`<div class="ui positive message">
<div class="header">No errors found</div>
<p>This log file contains no errors.</p>
</div>`
);
return;
}
analyzeLog(log);
});
}
function analyzeLog(log) {
// Parse log lines
let lines = log.split("\n").filter(l => l.trim().length > 0 && l[0] === "[");
let reqTypeCount = {};
let originDayCount = {};
let clientIpCount = {};
let reqTypeRegex = /\] ([A-Z]+) /;
let originRegex = /\[origin:([^\]]+)\]/;
let dateRegex = /^\[([0-9\-]+) /;
let clientIpRegex = /\[client: ([^\]]+)\]/;
lines.forEach(line => {
// Request type
let reqTypeMatch = line.match(reqTypeRegex);
let reqType = reqTypeMatch ? reqTypeMatch[1] : "OTHER";
reqTypeCount[reqType] = (reqTypeCount[reqType] || 0) + 1;
// Origin
let originMatch = line.match(originRegex);
let origin = originMatch ? originMatch[1] : null;
// Date
let dateMatch = line.match(dateRegex);
let date = dateMatch ? dateMatch[1] : null;
// Skip if origin or date is unknown
if (!origin || !date) return;
// Per-origin per-day
if (!originDayCount[origin]) originDayCount[origin] = {};
originDayCount[origin][date] = (originDayCount[origin][date] || 0) + 1;
// Client IP
let ipMatch = line.match(clientIpRegex);
let ip = ipMatch ? ipMatch[1] : "unknown";
clientIpCount[ip] = (clientIpCount[ip] || 0) + 1;
});
// --- Pie Chart: Request Type Ratio ---
let pieCtx = document.getElementById("requestTypePie").getContext("2d");
if (window.requestTypePieChart) window.requestTypePieChart.destroy();
window.requestTypePieChart = new Chart(pieCtx, {
type: "pie",
data: {
labels: Object.keys(reqTypeCount),
datasets: [{
data: Object.values(reqTypeCount),
backgroundColor: [
"#2185d0", "#21ba45", "#db2828", "#fbbd08", "#a333c8", "#00b5ad", "#b5cc18"
]
}]
},
options: {
plugins: {
legend: { position: "bottom" }
}
}
});
// --- Histogram Charts: Requests per Host (Origin) per Day ---
let $originCharts = $("#originCharts");
$originCharts.html("");
Object.keys(originDayCount).forEach((origin, idx) => {
let chartId = "originChart_" + idx;
$originCharts.append(`<div style="margin-bottom:2em;"><b>${origin}</b><canvas id="${chartId}" height="120"></canvas></div>`);
let dayCounts = originDayCount[origin];
let days = Object.keys(dayCounts).sort();
let counts = days.map(d => dayCounts[d]);
let ctx = document.getElementById(chartId).getContext("2d");
new Chart(ctx, {
type: "bar",
data: {
labels: days,
datasets: [{
label: "Requests",
data: counts,
backgroundColor: "#2185d0"
}]
},
options: {
plugins: {
legend: { display: false }
},
scales: {
x: {
title: { display: true, text: "Date" },
grid: { display: false }
},
y: {
title: { display: true, text: "Requests" },
beginAtZero: true,
grid: { display: true }
}
}
}
});
});
// --- Table: Client IPs ---
let $tbody = $("#clientIpTable tbody");
$tbody.html("");
Object.entries(clientIpCount)
.sort((a, b) => b[1] - a[1])
.forEach(([ip, count]) => {
$tbody.append(`<tr><td>${ip}</td><td>${count}</td></tr>`);
let html = `<div class="ui list">`;
errors.forEach(function(err) {
let [timestamp, method, path, code] = err;
html += `
<div class="item">
<div class="ui ${code.startsWith('5') ? 'red' : 'yellow'} message" style="margin-bottom:0.7em;">
<div class="header">
<span style="font-family:monospace;">[${timestamp}]</span>
<span style="font-family:monospace;">${method}</span>
<span style="margin-left:1em;font-family:monospace;">${path}</span>
<span style="float:right;font-weight:bold;">${code}</span>
</div>
</div>
</div>
`;
});
html += `</div>`;
$("#errorHighlightWrapper").html(html);
//Scroll to bottom of the error highlight
$("#errorHighlightWrapper").scrollTop($("#errorHighlightWrapper")[0].scrollHeight);
}
</script>
</html>