mirror of
https://github.com/ClusterCockpit/cc-backend
synced 2026-02-11 13:31:45 +01:00
Add simple log viewer in web frontend
This commit is contained in:
@@ -245,6 +245,7 @@ func (s *Server) init() error {
|
|||||||
s.restAPIHandle.MountAPIRoutes(securedapi)
|
s.restAPIHandle.MountAPIRoutes(securedapi)
|
||||||
s.restAPIHandle.MountUserAPIRoutes(userapi)
|
s.restAPIHandle.MountUserAPIRoutes(userapi)
|
||||||
s.restAPIHandle.MountConfigAPIRoutes(configapi)
|
s.restAPIHandle.MountConfigAPIRoutes(configapi)
|
||||||
|
s.restAPIHandle.MountLogAPIRoutes(configapi)
|
||||||
s.restAPIHandle.MountFrontendAPIRoutes(frontendapi)
|
s.restAPIHandle.MountFrontendAPIRoutes(frontendapi)
|
||||||
|
|
||||||
if config.Keys.APISubjects != nil {
|
if config.Keys.APISubjects != nil {
|
||||||
|
|||||||
167
internal/api/log.go
Normal file
167
internal/api/log.go
Normal file
@@ -0,0 +1,167 @@
|
|||||||
|
// Copyright (C) NHR@FAU, University Erlangen-Nuremberg.
|
||||||
|
// All rights reserved. This file is part of cc-backend.
|
||||||
|
// Use of this source code is governed by a MIT-style
|
||||||
|
// license that can be found in the LICENSE file.
|
||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"os/exec"
|
||||||
|
"regexp"
|
||||||
|
"strconv"
|
||||||
|
|
||||||
|
"github.com/ClusterCockpit/cc-backend/internal/config"
|
||||||
|
"github.com/ClusterCockpit/cc-backend/internal/repository"
|
||||||
|
cclog "github.com/ClusterCockpit/cc-lib/v2/ccLogger"
|
||||||
|
"github.com/ClusterCockpit/cc-lib/v2/schema"
|
||||||
|
"github.com/gorilla/mux"
|
||||||
|
)
|
||||||
|
|
||||||
|
type LogEntry struct {
|
||||||
|
Timestamp string `json:"timestamp"`
|
||||||
|
Priority int `json:"priority"`
|
||||||
|
Message string `json:"message"`
|
||||||
|
Unit string `json:"unit"`
|
||||||
|
}
|
||||||
|
|
||||||
|
var safePattern = regexp.MustCompile(`^[a-zA-Z0-9 :\-\.]+$`)
|
||||||
|
|
||||||
|
func (api *RestAPI) getJournalLog(rw http.ResponseWriter, r *http.Request) {
|
||||||
|
user := repository.GetUserFromContext(r.Context())
|
||||||
|
if !user.HasRole(schema.RoleAdmin) {
|
||||||
|
handleError(fmt.Errorf("only admins are allowed to view logs"), http.StatusForbidden, rw)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
since := r.URL.Query().Get("since")
|
||||||
|
if since == "" {
|
||||||
|
since = "1 hour ago"
|
||||||
|
}
|
||||||
|
if !safePattern.MatchString(since) {
|
||||||
|
handleError(fmt.Errorf("invalid 'since' parameter"), http.StatusBadRequest, rw)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
lines := 200
|
||||||
|
if l := r.URL.Query().Get("lines"); l != "" {
|
||||||
|
n, err := strconv.Atoi(l)
|
||||||
|
if err != nil || n < 1 {
|
||||||
|
handleError(fmt.Errorf("invalid 'lines' parameter"), http.StatusBadRequest, rw)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if n > 1000 {
|
||||||
|
n = 1000
|
||||||
|
}
|
||||||
|
lines = n
|
||||||
|
}
|
||||||
|
|
||||||
|
unit := config.Keys.SystemdUnit
|
||||||
|
if unit == "" {
|
||||||
|
unit = "clustercockpit"
|
||||||
|
}
|
||||||
|
|
||||||
|
args := []string{
|
||||||
|
"--output=json",
|
||||||
|
"--no-pager",
|
||||||
|
fmt.Sprintf("-n %d", lines),
|
||||||
|
fmt.Sprintf("--since=%s", since),
|
||||||
|
fmt.Sprintf("-u %s", unit),
|
||||||
|
}
|
||||||
|
|
||||||
|
if level := r.URL.Query().Get("level"); level != "" {
|
||||||
|
n, err := strconv.Atoi(level)
|
||||||
|
if err != nil || n < 0 || n > 7 {
|
||||||
|
handleError(fmt.Errorf("invalid 'level' parameter (must be 0-7)"), http.StatusBadRequest, rw)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
args = append(args, fmt.Sprintf("--priority=%d", n))
|
||||||
|
}
|
||||||
|
|
||||||
|
if search := r.URL.Query().Get("search"); search != "" {
|
||||||
|
if !safePattern.MatchString(search) {
|
||||||
|
handleError(fmt.Errorf("invalid 'search' parameter"), http.StatusBadRequest, rw)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
args = append(args, fmt.Sprintf("--grep=%s", search))
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd := exec.CommandContext(r.Context(), "journalctl", args...)
|
||||||
|
stdout, err := cmd.StdoutPipe()
|
||||||
|
if err != nil {
|
||||||
|
handleError(fmt.Errorf("failed to create pipe: %w", err), http.StatusInternalServerError, rw)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := cmd.Start(); err != nil {
|
||||||
|
handleError(fmt.Errorf("failed to start journalctl: %w", err), http.StatusInternalServerError, rw)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
entries := make([]LogEntry, 0, lines)
|
||||||
|
scanner := bufio.NewScanner(stdout)
|
||||||
|
for scanner.Scan() {
|
||||||
|
var raw map[string]any
|
||||||
|
if err := json.Unmarshal(scanner.Bytes(), &raw); err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
priority := 6 // default info
|
||||||
|
if p, ok := raw["PRIORITY"]; ok {
|
||||||
|
switch v := p.(type) {
|
||||||
|
case string:
|
||||||
|
if n, err := strconv.Atoi(v); err == nil {
|
||||||
|
priority = n
|
||||||
|
}
|
||||||
|
case float64:
|
||||||
|
priority = int(v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
msg := ""
|
||||||
|
if m, ok := raw["MESSAGE"]; ok {
|
||||||
|
if s, ok := m.(string); ok {
|
||||||
|
msg = s
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ts := ""
|
||||||
|
if t, ok := raw["__REALTIME_TIMESTAMP"]; ok {
|
||||||
|
if s, ok := t.(string); ok {
|
||||||
|
ts = s
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
unitName := ""
|
||||||
|
if u, ok := raw["_SYSTEMD_UNIT"]; ok {
|
||||||
|
if s, ok := u.(string); ok {
|
||||||
|
unitName = s
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
entries = append(entries, LogEntry{
|
||||||
|
Timestamp: ts,
|
||||||
|
Priority: priority,
|
||||||
|
Message: msg,
|
||||||
|
Unit: unitName,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := cmd.Wait(); err != nil {
|
||||||
|
// journalctl returns exit code 1 when --grep matches nothing
|
||||||
|
if len(entries) == 0 {
|
||||||
|
cclog.Debugf("journalctl exited with: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
rw.Header().Set("Content-Type", "application/json")
|
||||||
|
if err := json.NewEncoder(rw).Encode(entries); err != nil {
|
||||||
|
cclog.Errorf("Failed to encode log entries: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (api *RestAPI) MountLogAPIRoutes(r *mux.Router) {
|
||||||
|
r.HandleFunc("/logs/", api.getJournalLog).Methods(http.MethodGet)
|
||||||
|
}
|
||||||
@@ -71,6 +71,9 @@ type ProgramConfig struct {
|
|||||||
|
|
||||||
// If exists, will enable dynamic zoom in frontend metric plots using the configured values
|
// If exists, will enable dynamic zoom in frontend metric plots using the configured values
|
||||||
EnableResampling *ResampleConfig `json:"resampling"`
|
EnableResampling *ResampleConfig `json:"resampling"`
|
||||||
|
|
||||||
|
// Systemd unit name for log viewer (default: "clustercockpit")
|
||||||
|
SystemdUnit string `json:"systemd-unit"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type ResampleConfig struct {
|
type ResampleConfig struct {
|
||||||
|
|||||||
@@ -50,6 +50,7 @@ var routes []Route = []Route{
|
|||||||
{"/monitoring/status/{cluster}", "monitoring/status.tmpl", "<ID> Dashboard - ClusterCockpit", false, setupClusterStatusRoute},
|
{"/monitoring/status/{cluster}", "monitoring/status.tmpl", "<ID> Dashboard - ClusterCockpit", false, setupClusterStatusRoute},
|
||||||
{"/monitoring/status/detail/{cluster}", "monitoring/status.tmpl", "Status of <ID> - ClusterCockpit", false, setupClusterDetailRoute},
|
{"/monitoring/status/detail/{cluster}", "monitoring/status.tmpl", "Status of <ID> - ClusterCockpit", false, setupClusterDetailRoute},
|
||||||
{"/monitoring/dashboard/{cluster}", "monitoring/dashboard.tmpl", "<ID> Dashboard - ClusterCockpit", false, setupDashboardRoute},
|
{"/monitoring/dashboard/{cluster}", "monitoring/dashboard.tmpl", "<ID> Dashboard - ClusterCockpit", false, setupDashboardRoute},
|
||||||
|
{"/monitoring/logs", "monitoring/logs.tmpl", "Logs - ClusterCockpit", false, func(i InfoType, r *http.Request) InfoType { return i }},
|
||||||
}
|
}
|
||||||
|
|
||||||
func setupHomeRoute(i InfoType, r *http.Request) InfoType {
|
func setupHomeRoute(i InfoType, r *http.Request) InfoType {
|
||||||
|
|||||||
26
web/frontend/package-lock.json
generated
26
web/frontend/package-lock.json
generated
@@ -250,7 +250,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm"
|
"arm"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -264,7 +263,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -278,7 +276,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -292,7 +289,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -306,7 +302,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -320,7 +315,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -334,7 +328,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm"
|
"arm"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -348,7 +341,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm"
|
"arm"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -362,7 +354,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -376,7 +367,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -390,7 +380,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"loong64"
|
"loong64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -404,7 +393,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"loong64"
|
"loong64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -418,7 +406,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"ppc64"
|
"ppc64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -432,7 +419,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"ppc64"
|
"ppc64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -446,7 +432,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"riscv64"
|
"riscv64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -460,7 +445,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"riscv64"
|
"riscv64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -474,7 +458,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"s390x"
|
"s390x"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -488,7 +471,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -502,7 +484,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -516,7 +497,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -530,7 +510,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -544,7 +523,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -558,7 +536,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"ia32"
|
"ia32"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -572,7 +549,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -586,7 +562,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -837,7 +812,6 @@
|
|||||||
"version": "2.3.3",
|
"version": "2.3.3",
|
||||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
|
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
|
||||||
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
|
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
|
||||||
"dev": true,
|
|
||||||
"hasInstallScript": true,
|
"hasInstallScript": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
|
|||||||
@@ -75,5 +75,6 @@ export default [
|
|||||||
entrypoint('analysis', 'src/analysis.entrypoint.js'),
|
entrypoint('analysis', 'src/analysis.entrypoint.js'),
|
||||||
entrypoint('status', 'src/status.entrypoint.js'),
|
entrypoint('status', 'src/status.entrypoint.js'),
|
||||||
entrypoint('dashpublic', 'src/dashpublic.entrypoint.js'),
|
entrypoint('dashpublic', 'src/dashpublic.entrypoint.js'),
|
||||||
entrypoint('config', 'src/config.entrypoint.js')
|
entrypoint('config', 'src/config.entrypoint.js'),
|
||||||
|
entrypoint('logs', 'src/logs.entrypoint.js')
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -135,6 +135,16 @@
|
|||||||
listOptions: true,
|
listOptions: true,
|
||||||
menu: "Info",
|
menu: "Info",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
title: "Logs",
|
||||||
|
// svelte-ignore state_referenced_locally
|
||||||
|
requiredRole: roles.admin,
|
||||||
|
href: "/monitoring/logs",
|
||||||
|
icon: "journal-text",
|
||||||
|
perCluster: false,
|
||||||
|
listOptions: false,
|
||||||
|
menu: "Info",
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
/* State Init */
|
/* State Init */
|
||||||
|
|||||||
228
web/frontend/src/Logs.root.svelte
Normal file
228
web/frontend/src/Logs.root.svelte
Normal file
@@ -0,0 +1,228 @@
|
|||||||
|
<!--
|
||||||
|
@component Systemd Journal Log Viewer (Admin only)
|
||||||
|
|
||||||
|
Properties:
|
||||||
|
- `isAdmin Bool!`: Is currently logged in user admin authority
|
||||||
|
-->
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardHeader,
|
||||||
|
CardBody,
|
||||||
|
Table,
|
||||||
|
Input,
|
||||||
|
Button,
|
||||||
|
Badge,
|
||||||
|
Spinner,
|
||||||
|
InputGroup,
|
||||||
|
InputGroupText,
|
||||||
|
Icon,
|
||||||
|
} from "@sveltestrap/sveltestrap";
|
||||||
|
|
||||||
|
let { isAdmin } = $props();
|
||||||
|
|
||||||
|
const timeRanges = [
|
||||||
|
{ label: "Last 15 minutes", value: "15 min ago" },
|
||||||
|
{ label: "Last 1 hour", value: "1 hour ago" },
|
||||||
|
{ label: "Last 6 hours", value: "6 hours ago" },
|
||||||
|
{ label: "Last 24 hours", value: "24 hours ago" },
|
||||||
|
{ label: "Last 7 days", value: "7 days ago" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const levels = [
|
||||||
|
{ label: "All levels", value: "" },
|
||||||
|
{ label: "Emergency (0)", value: "0" },
|
||||||
|
{ label: "Alert (1)", value: "1" },
|
||||||
|
{ label: "Critical (2)", value: "2" },
|
||||||
|
{ label: "Error (3)", value: "3" },
|
||||||
|
{ label: "Warning (4)", value: "4" },
|
||||||
|
{ label: "Notice (5)", value: "5" },
|
||||||
|
{ label: "Info (6)", value: "6" },
|
||||||
|
{ label: "Debug (7)", value: "7" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const refreshIntervals = [
|
||||||
|
{ label: "Off", value: 0 },
|
||||||
|
{ label: "5s", value: 5000 },
|
||||||
|
{ label: "10s", value: 10000 },
|
||||||
|
{ label: "30s", value: 30000 },
|
||||||
|
];
|
||||||
|
|
||||||
|
let since = $state("1 hour ago");
|
||||||
|
let level = $state("");
|
||||||
|
let search = $state("");
|
||||||
|
let linesParam = $state("200");
|
||||||
|
let refreshInterval = $state(0);
|
||||||
|
let entries = $state([]);
|
||||||
|
let loading = $state(false);
|
||||||
|
let error = $state(null);
|
||||||
|
let timer = $state(null);
|
||||||
|
|
||||||
|
function levelColor(priority) {
|
||||||
|
if (priority <= 2) return "danger";
|
||||||
|
if (priority === 3) return "warning";
|
||||||
|
if (priority === 4) return "info";
|
||||||
|
if (priority <= 6) return "secondary";
|
||||||
|
return "light";
|
||||||
|
}
|
||||||
|
|
||||||
|
function levelName(priority) {
|
||||||
|
const names = ["EMERG", "ALERT", "CRIT", "ERR", "WARN", "NOTICE", "INFO", "DEBUG"];
|
||||||
|
return names[priority] || "UNKNOWN";
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTimestamp(usec) {
|
||||||
|
if (!usec) return "";
|
||||||
|
const ms = parseInt(usec) / 1000;
|
||||||
|
const d = new Date(ms);
|
||||||
|
return d.toLocaleString();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchLogs() {
|
||||||
|
loading = true;
|
||||||
|
error = null;
|
||||||
|
try {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
params.set("since", since);
|
||||||
|
params.set("lines", linesParam);
|
||||||
|
if (level) params.set("level", level);
|
||||||
|
if (search.trim()) params.set("search", search.trim());
|
||||||
|
|
||||||
|
const resp = await fetch(`/config/logs/?${params.toString()}`);
|
||||||
|
if (!resp.ok) {
|
||||||
|
const body = await resp.json();
|
||||||
|
throw new Error(body.error || `HTTP ${resp.status}`);
|
||||||
|
}
|
||||||
|
entries = await resp.json();
|
||||||
|
} catch (e) {
|
||||||
|
error = e.message;
|
||||||
|
entries = [];
|
||||||
|
} finally {
|
||||||
|
loading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function setupAutoRefresh(interval) {
|
||||||
|
if (timer) {
|
||||||
|
clearInterval(timer);
|
||||||
|
timer = null;
|
||||||
|
}
|
||||||
|
if (interval > 0) {
|
||||||
|
timer = setInterval(fetchLogs, interval);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
setupAutoRefresh(refreshInterval);
|
||||||
|
return () => {
|
||||||
|
if (timer) clearInterval(timer);
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// Fetch on mount
|
||||||
|
$effect(() => {
|
||||||
|
fetchLogs();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if !isAdmin}
|
||||||
|
<Card>
|
||||||
|
<CardBody>
|
||||||
|
<p>Access denied. Admin privileges required.</p>
|
||||||
|
</CardBody>
|
||||||
|
</Card>
|
||||||
|
{:else}
|
||||||
|
<Card class="mb-3">
|
||||||
|
<CardHeader>
|
||||||
|
<div class="d-flex flex-wrap align-items-center gap-2">
|
||||||
|
<InputGroup size="sm" style="max-width: 200px;">
|
||||||
|
<Input type="select" bind:value={since}>
|
||||||
|
{#each timeRanges as tr}
|
||||||
|
<option value={tr.value}>{tr.label}</option>
|
||||||
|
{/each}
|
||||||
|
</Input>
|
||||||
|
</InputGroup>
|
||||||
|
|
||||||
|
<InputGroup size="sm" style="max-width: 180px;">
|
||||||
|
<Input type="select" bind:value={level}>
|
||||||
|
{#each levels as lv}
|
||||||
|
<option value={lv.value}>{lv.label}</option>
|
||||||
|
{/each}
|
||||||
|
</Input>
|
||||||
|
</InputGroup>
|
||||||
|
|
||||||
|
<InputGroup size="sm" style="max-width: 150px;">
|
||||||
|
<InputGroupText>Lines</InputGroupText>
|
||||||
|
<Input type="select" bind:value={linesParam}>
|
||||||
|
<option value="100">100</option>
|
||||||
|
<option value="200">200</option>
|
||||||
|
<option value="500">500</option>
|
||||||
|
<option value="1000">1000</option>
|
||||||
|
</Input>
|
||||||
|
</InputGroup>
|
||||||
|
|
||||||
|
<InputGroup size="sm" style="max-width: 250px;">
|
||||||
|
<Input
|
||||||
|
type="text"
|
||||||
|
placeholder="Search..."
|
||||||
|
bind:value={search}
|
||||||
|
onkeydown={(e) => { if (e.key === "Enter") fetchLogs(); }}
|
||||||
|
/>
|
||||||
|
</InputGroup>
|
||||||
|
|
||||||
|
<Button size="sm" color="primary" onclick={fetchLogs} disabled={loading}>
|
||||||
|
{#if loading}
|
||||||
|
<Spinner size="sm" />
|
||||||
|
{:else}
|
||||||
|
<Icon name="arrow-clockwise" />
|
||||||
|
{/if}
|
||||||
|
Refresh
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<InputGroup size="sm" style="max-width: 140px;">
|
||||||
|
<InputGroupText>Auto</InputGroupText>
|
||||||
|
<Input type="select" bind:value={refreshInterval}>
|
||||||
|
{#each refreshIntervals as ri}
|
||||||
|
<option value={ri.value}>{ri.label}</option>
|
||||||
|
{/each}
|
||||||
|
</Input>
|
||||||
|
</InputGroup>
|
||||||
|
|
||||||
|
{#if entries.length > 0}
|
||||||
|
<small class="text-muted ms-auto">{entries.length} entries</small>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardBody style="padding: 0;">
|
||||||
|
{#if error}
|
||||||
|
<div class="alert alert-danger m-3">{error}</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div style="max-height: 75vh; overflow-y: auto;">
|
||||||
|
<Table size="sm" striped hover responsive class="mb-0">
|
||||||
|
<thead class="sticky-top bg-white">
|
||||||
|
<tr>
|
||||||
|
<th style="width: 170px;">Timestamp</th>
|
||||||
|
<th style="width: 80px;">Level</th>
|
||||||
|
<th>Message</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody style="font-family: monospace; font-size: 0.85rem;">
|
||||||
|
{#each entries as entry}
|
||||||
|
<tr>
|
||||||
|
<td class="text-nowrap">{formatTimestamp(entry.timestamp)}</td>
|
||||||
|
<td><Badge color={levelColor(entry.priority)}>{levelName(entry.priority)}</Badge></td>
|
||||||
|
<td style="white-space: pre-wrap; word-break: break-all;">{entry.message}</td>
|
||||||
|
</tr>
|
||||||
|
{:else}
|
||||||
|
{#if !loading && !error}
|
||||||
|
<tr><td colspan="3" class="text-center text-muted py-3">No log entries found</td></tr>
|
||||||
|
{/if}
|
||||||
|
{/each}
|
||||||
|
</tbody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
</CardBody>
|
||||||
|
</Card>
|
||||||
|
{/if}
|
||||||
10
web/frontend/src/logs.entrypoint.js
Normal file
10
web/frontend/src/logs.entrypoint.js
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import { mount } from 'svelte';
|
||||||
|
import {} from './header.entrypoint.js'
|
||||||
|
import Logs from './Logs.root.svelte'
|
||||||
|
|
||||||
|
mount(Logs, {
|
||||||
|
target: document.getElementById('svelte-app'),
|
||||||
|
props: {
|
||||||
|
isAdmin: isAdmin,
|
||||||
|
}
|
||||||
|
})
|
||||||
13
web/templates/monitoring/logs.tmpl
Normal file
13
web/templates/monitoring/logs.tmpl
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
{{define "content"}}
|
||||||
|
<div id="svelte-app"></div>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
{{define "stylesheets"}}
|
||||||
|
<link rel='stylesheet' href='/build/logs.css'>
|
||||||
|
{{end}}
|
||||||
|
{{define "javascript"}}
|
||||||
|
<script>
|
||||||
|
const isAdmin = {{ .User.HasRole .Roles.admin }};
|
||||||
|
</script>
|
||||||
|
<script src='/build/logs.js'></script>
|
||||||
|
{{end}}
|
||||||
Reference in New Issue
Block a user