added log level selection via query parameter and paths as well as setting a limit for lines of the query

This commit is contained in:
Stefan Etringer 2025-05-16 13:35:31 +00:00
parent 1ce6d2e676
commit 97c77506c8
4 changed files with 110 additions and 44 deletions

52
main.go
View File

@ -12,6 +12,7 @@ import (
"html/template" "html/template"
"log" "log"
"net/http" "net/http"
"strconv"
"sync" "sync"
"syscall" "syscall"
@ -126,6 +127,17 @@ func listAgents(w http.ResponseWriter, r *http.Request) {
} }
} }
if strings.Contains(r.Header.Get("Accept"), "application") {
w.Header().Set("Content-Type", "application/json")
jsonData, err := json.Marshal(agents)
if err != nil {
http.Error(w, "Failed to encode agents to JSON", http.StatusInternalServerError)
return
}
w.Write(jsonData)
return
}
if err != nil { if err != nil {
http.Error(w, "Failed to fetch agents", http.StatusInternalServerError) http.Error(w, "Failed to fetch agents", http.StatusInternalServerError)
return return
@ -162,12 +174,31 @@ func getAgentIds(w http.ResponseWriter, r *http.Request) {
} }
func logsHandler(w http.ResponseWriter, r *http.Request) { func logsHandler(w http.ResponseWriter, r *http.Request) {
var logs[] logger.LogEntry // Warning this bit me in the nose: var count is []string, but
logs, err := logger.FetchLogs(10) // variable = count[0] is casted to int automatically
// when the string is a number. Jesus Christ, this is odd behavior!
levels := r.URL.Query()["level"]
countStr := r.URL.Query()["limit"]
var limit int = 10
if len(countStr) > 0 {
parsedCount, err := strconv.Atoi(countStr[0])
if err == nil {
limit = parsedCount
} else {
http.Error(w, "Invalid count value", http.StatusBadRequest)
return
}
}
// for i, logLine := range logs { // This enables not only `level` GET parameters but also selecting by paths
// logs[i].Message = strings.ReplaceAll(logLine.Message, `"`, `\"`) // For example /logs/error is now identical to /logs?level=error
// } parts := strings.Split(strings.TrimPrefix(r.URL.Path, "/logs/"), "/")
if (len(levels) == 0) && len(parts) > 0 && parts[0] != "" {
levels = []string{parts[0]}
}
// Call the police... I mean logger
logs, err := logger.FetchLogs(limit, levels)
if err != nil { if err != nil {
http.Error(w, "Error fetching logs", http.StatusInternalServerError) http.Error(w, "Error fetching logs", http.StatusInternalServerError)
@ -176,16 +207,6 @@ func logsHandler(w http.ResponseWriter, r *http.Request) {
renderTemplate(w, "templates/partials/logs_partial.html", logs) renderTemplate(w, "templates/partials/logs_partial.html", logs)
// fmt.Fprintf(w, "<div>")
// // for _, logEntry := range logsToSend {
// for _, logEntry := range logs {
// fmt.Fprintf(w, "<p><strong>[%s] [%s]</strong> %s</p>", logEntry.Timestamp, logEntry.Level, logEntry.Message)
// }
// fmt.Fprintf(w, "</div>")
// w.Header().Set("Content-Type", "application/json")
// json.NewEncoder(w).Encode(logs)
} }
@ -211,6 +232,7 @@ func main() {
webMux.HandleFunc("/agentIds", getAgentIds) webMux.HandleFunc("/agentIds", getAgentIds)
webMux.HandleFunc("/agents/{agentId}", agentsHandler) webMux.HandleFunc("/agents/{agentId}", agentsHandler)
webMux.HandleFunc("/logs", logsHandler) webMux.HandleFunc("/logs", logsHandler)
webMux.HandleFunc("/logs/{level}", logsHandler)
db = database.InitDB (cfg.Database.Username, cfg.Database.Password, cfg.Database.Host, cfg.Database.Port, cfg.Database.Name) db = database.InitDB (cfg.Database.Username, cfg.Database.Password, cfg.Database.Host, cfg.Database.Port, cfg.Database.Name)
defer db.Close() defer db.Close()

View File

@ -95,21 +95,40 @@ func InsertLog(level LogLevel, message string) error {
return nil return nil
} }
func FetchLogs(limit int) ([]LogEntry, error) { func FetchLogs(limit int, levels []string) ([]LogEntry, error) {
lite_dbMutex.Lock() lite_dbMutex.Lock()
defer lite_dbMutex.Unlock() defer lite_dbMutex.Unlock()
query := `SELECT timestamp, level, message FROM logs ORDER BY timestamp DESC LIMIT ?` if len(levels) == 0 {
rows, err := Lite_db.Query(query, limit) levels = []string{"%"}
}
var args []interface{}
placeholders := make([]string, len(levels))
for i, level := range levels {
placeholders[i] = "level LIKE ?"
args = append(args, level)
}
query := fmt.Sprintf(`
SELECT timestamp, level, message
FROM logs
WHERE %s
ORDER BY timestamp DESC
LIMIT ?`, strings.Join(placeholders, " OR "))
args = append(args, limit)
// rows, err := Lite_db.Query(query, level, limit)
rows, err := Lite_db.Query(query, args...)
if err != nil { if err != nil {
return nil, fmt.Errorf("Error fetching logs: %w", err) return nil, fmt.Errorf("Error fetching logs: %w", err)
} }
defer rows.Close() defer rows.Close()
// var logs []string
var logs []LogEntry var logs []LogEntry
for rows.Next() { for rows.Next() {
// var message string
var logEntry LogEntry var logEntry LogEntry
if err := rows.Scan( &logEntry.Timestamp, &logEntry.Level, &logEntry.Message); err != nil { if err := rows.Scan( &logEntry.Timestamp, &logEntry.Level, &logEntry.Message); err != nil {
return nil, fmt.Errorf("Error scanning row: %w", err) return nil, fmt.Errorf("Error scanning row: %w", err)

View File

@ -90,20 +90,36 @@
</script> </script>
<style> <style>
:root{
--grey-color: #1B2B34;
--error-color: #EC5f67;
--warning-color: #F99157;
--yellow-color: #FAC863;
--info-color: #99C794;
--teal-color: #5FB3B3;
--blue-color: #6699CC;
--debug-color: #C594C5;
--fatal-color: #AB7967;
}
.log-info, .log-warning, .log-error, .log-fatal, .log-debug{
font-family: "Lucida Console", Monaco, monospace;
}
.log-info { .log-info {
color: green; color: var(--info-color);
} }
.log-warning { .log-warning {
color: orange; color: var(--warning-color);
} }
.log-error { .log-error {
color: red; color: var(--error-color);
} }
.log-fatal { .log-fatal {
color: blue; color: var(--fatal-color);
} }
.log-debug { .log-debug {
color: violet; color: var(--debug-color);
} }
</style> </style>
@ -140,24 +156,33 @@
<pre id="commandOutput"></pre> <pre id="commandOutput"></pre>
</div> </div>
<!-- Logs Section -->
<h3>Logs</h3>
<div id="logs-container" hx-get="/logs" hx-target="#logs-container" hx-swap="innerHTML" hx-trigger="every 3s">
<!-- Logs will be injected here -->
<!-- </div> -->
<!-- <button hx-get="/logs" hx-target="#logs-container" hx-swap="innerHTML"> -->
<!-- Load Logs -->
<!-- </button> -->
<!-- <button hx-get="/logs" hx-target="#logs-container" hx-swap="innerHTML" hx-trigger="every 8s"> -->
<!-- Auto-Refresh Logs -->
<!-- </button> -->
<!-- </div> -->
<!-- Agent Details --> <!-- Agent Details -->
<div class="col" id="agentDetails"> <div class="col" id="agentDetails">
<h3>Details</h3> <h3>Details</h3>
<p>Select an agent to view details.</p> <p>Select an agent to view details.</p>
</div> </div>
<!-- Logs Section -->
<h3>Logs</h3>
<form id="log-filter-form"
hx-get="/logs"
hx-target="#logs-container"
hx-swap="innerHTML"
hx-trigger="change from:.log-filter, every 3s"
hx-include="#log-filter-form">
<label><input type="checkbox" class="log-filter" name="level" value="info" checked> Info</label>
<label><input type="checkbox" class="log-filter" name="level" value="warning"> Warning</label>
<label><input type="checkbox" class="log-filter" name="level" value="error"> Error</label>
<label><input type="checkbox" class="log-filter" name="level" value="debug"> Debug</label>
<label><input type="checkbox" class="log-filter" name="level" value="fatal"> Fatal</label>
</form>
<div id="logs-container">
<!-- Logs will load here -->
</div>
</div> </div>
</div> </div>
</body> </body>

View File

@ -1,5 +1,5 @@
{{range .}} {{range .}}
<p class="log-{{.Level}}"> <div class="log-{{.Level}}">
<strong>ts={{.Timestamp}} level={{.Level}}</strong> msg="{{.Message}}" ts={{.Timestamp}} level={{.Level}}msg="{{.Message}}"
</p> </div>
{{end}} {{end}}