Skip to content
Merged
Show file tree
Hide file tree
Changes from 34 commits
Commits
Show all changes
35 commits
Select commit Hold shift + click to select a range
f6f21fb
refactor(api-server): use struct instead of json string for logs action
stephdl Aug 11, 2026
86f621f
feat(api-server): add regexp field to LogsStartAction
stephdl Aug 11, 2026
ea1eba9
fix(socket): report logcli errors instead of dropping them
stephdl Aug 11, 2026
41ba333
docs(api-server): document instance and regexp filter params
stephdl Aug 11, 2026
08f2f91
feat(i18n): add regexp search and log lines count strings
stephdl Aug 11, 2026
0e76e8d
fix(LogHighlightMark): support RegExp searchTerm for exact-match high…
stephdl Aug 11, 2026
cef204c
fix(system-logs): allow RegExp for highlight prop and grow scrollbar …
stephdl Aug 11, 2026
f6a39de
feat(system-logs): add regexp search and query error feedback
stephdl Aug 11, 2026
6498aa7
feat(system-logs): replace filter menu with toggle buttons and stacke…
stephdl Aug 11, 2026
f97df0a
fix(system-logs): replace regexp tooltip with inline hint and add sub…
stephdl Aug 24, 2026
bbd222f
feat(system-logs): add a worker to locate search matches off the main…
stephdl Aug 24, 2026
a775249
refactor(LogHighlightMark): render precomputed match offsets
stephdl Aug 24, 2026
af9cc4d
fix(system-logs): render one mark per line and bound the highlight cost
stephdl Aug 24, 2026
6557971
feat(i18n): add the strings for the unavailable-highlight notice
stephdl Aug 24, 2026
2d77e1f
fix(system-logs): hide the timezone field in follow mode
stephdl Aug 25, 2026
4b839d7
fix(system-logs): translate the invalid regexp error
stephdl Aug 25, 2026
7e2af90
fix(system-logs): move the query error notice below the search button
stephdl Aug 25, 2026
7278244
docs(api-server): list the instance and regexp flags in the CLI help
stephdl Aug 25, 2026
926d565
fix(socket): report a logcli exit caused by an unexpected signal
stephdl Aug 25, 2026
25689ae
fix(i18n): quote the regexp hint examples
stephdl Aug 25, 2026
0897698
fix(system-logs): stop restarting a highlight worker that already gav…
stephdl Aug 25, 2026
4253b5c
docs(api-server): align the logs help block with the actual output
stephdl Aug 25, 2026
4d01b4e
fix(system-logs): keep the highlight notice out of substring searches
stephdl Aug 25, 2026
7cf9a2b
feat(system-logs): tell when the browser cannot run the search pattern
stephdl Aug 25, 2026
d401c95
perf(system-logs): build the highlighted line as markup
stephdl Aug 25, 2026
66cf23f
fix(system-logs): drop the highlight notice when the search found not…
stephdl Aug 25, 2026
1c4c8c7
fix(system-logs): fill the mark budget from the end of the buffer
stephdl Aug 25, 2026
f2065a4
fix(socket): raise the websocket message limit above a search payload
stephdl Aug 25, 2026
d751a27
docs(system-logs): trim the comments to what the code cannot say
stephdl Aug 25, 2026
96e2425
revert(system-logs): drop match highlighting in regexp mode
stephdl Aug 26, 2026
1890449
fix(socket): print a failed logs query once on the CLI
stephdl Aug 26, 2026
975657f
fix(system-logs): follow mode shows timestamps in the browser timezone
stephdl Aug 26, 2026
f228fd9
fix(i18n): keep the regexp helper text about the syntax only
stephdl Aug 26, 2026
1b014f2
refactor(system-logs): report a query failure as log text
DavidePrincipi Aug 27, 2026
0977d99
fix(socket): send logcli errors to the log output
DavidePrincipi Aug 28, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 17 additions & 10 deletions core/api-server/README.md
Comment thread
stephdl marked this conversation as resolved.
Original file line number Diff line number Diff line change
Expand Up @@ -248,7 +248,9 @@ INPUT
"to": "2021-01-19T20:00:00Z",
"entity" :"module",
"entity_name": "traefik1",
"timezone": "Europe/Rome"
"timezone": "Europe/Rome",
"instance": "loki1",
"regexp": false
}
}
```
Expand All @@ -264,6 +266,8 @@ INPUT
- `entity`: must be `cluster` or `node` or `module` - `string`
- `entity_name`: could be empty (`cluster` case) or name of the entity - `string` (ex. hostname of the node or module id like `traefik1`)
- `timezone`: could be empty (default UTC) or a specific valid timezone (eg. Europe/Rome)
- `instance`: could be empty (default loki instance) or the id of a specific loki instance - `string` (ex. `loki1`)
- `regexp`: could be empty (default `false`) - `bool` (when `true` the `filter` is a RE2 regular expression instead of a substring)
```json
OUTPUT
Expand All @@ -282,6 +286,7 @@ OUTPUT
- `payload`: contains the response logs
- `message`: contains the log message
- `pid`: is the pid of the process that actually reads log
- a failed query has no attribute of its own: the `logcli` error text is sent as `message`, and in `tail` mode a `logs-stop` follows it
- `timestamp`: timestamp of the action
- `type`: used to identify the websocket outputs
Expand Down Expand Up @@ -384,15 +389,17 @@ Use "api-server-logs [command] --help" for more information about a command.
api-server-logs logs [flags]
Flags:
-e, --entity string get logs for a specific entity: cluster, node, module (default "cluster")
-f, --from string get logs from a specific date. ISO8601 format
-h, --help help for logs
-l, --lines string get logs for a specific lines in dump mode (default "25")
-m, --mode string get logs in a specific mode: tail or dump (default "tail")
-n, --name string get logs for a specific entity name. used in node or module
-s, --search string get logs for a specific search string
-t, --to string get logs to a specific date. ISO8601 format
-z, --timezone string get logs in a specific timezone
-e, --entity string get logs for a specific entity: cluster, node, module (default "cluster")
-f, --from string get logs from a specific date. ISO8601 format
-h, --help help for logs
-i, --instance string search for logs in a specific instance. (Example: loki1, loki2, ...)
-l, --lines string get logs for a specific lines in dump mode (default "25")
-m, --mode string get logs in a specific mode: tail or dump (default "tail")
-n, --name string get logs for a specific entity name. used in node or module
-r, --regexp treat the search string as a regular expression instead of a substring
-s, --search string get logs for a specific search string
-z, --timezone string get logs in a specific timezone
-t, --to string get logs to a specific date. ISO8601 format
```
- `version`: prints the command version
Expand Down
42 changes: 17 additions & 25 deletions core/api-server/api-server-logs.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,10 +25,7 @@ package main
import (
"github.com/NethServer/ns8-core/core/api-server/models"
"github.com/NethServer/ns8-core/core/api-server/socket"
"github.com/NethServer/ns8-core/core/api-server/utils"
"github.com/pkg/errors"

"encoding/json"
"fmt"
"os"
"sync"
Expand All @@ -48,6 +45,7 @@ var (
searchFlag = ""
timezone = ""
instance = ""
regexpFlag = false
)

var RootCmd = &cobra.Command{
Expand Down Expand Up @@ -86,6 +84,7 @@ func Execute() {
LogsCmd.Flags().StringVarP(&searchFlag, "search", "s", "", "get logs for a specific search string")
LogsCmd.Flags().StringVarP(&timezone, "timezone", "z", "", "get logs in a specific timezone")
LogsCmd.Flags().StringVarP(&instance, "instance", "i", "", "search for logs in a specific instance. (Example: loki1, loki2, ...)")
LogsCmd.Flags().BoolVarP(&regexpFlag, "regexp", "r", false, "treat the search string as a regular expression instead of a substring")

// check errors on cmd execution
if err := RootCmd.Execute(); err != nil {
Expand All @@ -99,29 +98,22 @@ func Logs() {
var wg sync.WaitGroup
wg.Add(1)

// define payload
payload := `
{
"action": "logs-start",
"payload": {
"id": "` + uuid.New().String() + `",
"mode": "` + modeFlag + `",
"lines": "` + linesFlag + `",
"filter": "` + searchFlag + `",
"from": "` + fromFlag + `",
"to": "` + toFlag + `",
"entity": "` + entityFlag + `",
"entity_name": "` + entityNameFlag + `",
"timezone": "` + timezone + `",
"instance": "` + instance + `"
}
}
`

// init command to execute
var action models.SocketAction
if errAction := json.Unmarshal([]byte(payload), &action); errAction != nil {
utils.LogError(errors.Wrap(errAction, "[LOG-CLI] error in Action json unmarshal"))
action := models.SocketAction{
Action: "logs-start",
Payload: models.LogsStartAction{
Id: uuid.New().String(),
Mode: modeFlag,
Lines: linesFlag,
Filter: searchFlag,
From: fromFlag,
To: toFlag,
Entity: entityFlag,
EntityName: entityNameFlag,
TimeZone: timezone,
Instance: instance,
Regexp: regexpFlag,
},
}

// execute command
Expand Down
1 change: 1 addition & 0 deletions core/api-server/models/socket.go
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ type LogsStartAction struct {
EntityName string `json:"entity_name" structs:"entity_name"`
TimeZone string `json:"timezone" structs:"timezone"`
Instance string `json:"instance" structs:"instance"`
Regexp bool `json:"regexp" structs:"regexp"`
}

type LogsStopAction struct {
Expand Down
106 changes: 80 additions & 26 deletions core/api-server/socket/action.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ package socket

import (
"bufio"
"bytes"
"encoding/json"
"fmt"
"os"
Expand Down Expand Up @@ -100,9 +101,6 @@ func Action(socketAction models.SocketAction, s *melody.Session, wg *sync.WaitGr

// filter logs params
var mode = ""
var filter = ""
var streamSelector = ""
var logqlPipeline = ""
var from = ""
var to = ""
var timezone = "UTC"
Expand Down Expand Up @@ -143,29 +141,8 @@ func Action(socketAction models.SocketAction, s *melody.Session, wg *sync.WaitGr
}
args = append(args, mode)

// check filter
if len(logsAction.Filter) > 0 {
filter = ` |= "` + strings.ReplaceAll(logsAction.Filter, `"`, `\"`) + `"`
} else {
filter = ``
}

// switch entity
switch logsAction.Entity {
default:
streamSelector = `{node_id=~".+"}`

case "node":
streamSelector = `{node_id="` + logsAction.EntityName + `"}`

case "module":
streamSelector = `{module_id="` + logsAction.EntityName + `"}`
}

logqlPipeline = ` | json syslog_id="SYSLOG_IDENTIFIER", message="MESSAGE" | line_format "[{{.node_id}}:{{.module_id}}:{{.syslog_id}}] {{.message}}"`

// Compose and append the query strings to logcli arguments
args = append(args, streamSelector+logqlPipeline+filter)
args = append(args, buildLogqlQuery(logsAction))

// define command
cmd := exec.Command("/usr/local/bin/logcli", args...)
Expand All @@ -181,9 +158,21 @@ func Action(socketAction models.SocketAction, s *melody.Session, wg *sync.WaitGr
go func() {
pid := ""

// whatever ends the stream - an error, a clean logcli exit,
// logs-stop - the frontend must leave the follow state
if s != nil {
defer func() {
writeSocketResponse(s, "logs-stop", gin.H{"id": logsAction.Id, "pid": pid, "message": "logs follow stopped"})
}()
}

var stderrBuf bytes.Buffer
cmd.Stderr = &stderrBuf

// create a pipe for the output of the script
stdout, errStdOut := cmd.StdoutPipe()
if errStdOut != nil {
writeLogsError(s, logsAction.Id, errStdOut.Error())
return
}

Expand All @@ -203,6 +192,7 @@ func Action(socketAction models.SocketAction, s *melody.Session, wg *sync.WaitGr
// start command
err = cmd.Start()
if err != nil {
writeLogsError(s, logsAction.Id, err.Error())
return
}

Expand All @@ -224,6 +214,18 @@ func Action(socketAction models.SocketAction, s *melody.Session, wg *sync.WaitGr
// use Wait to avoid defunct process when killed
err = cmd.Wait()
if err != nil {
// logs-stop kills the process: that exit is expected
if exitErr, isExitErr := err.(*exec.ExitError); isExitErr {
if status, isStatus := exitErr.Sys().(syscall.WaitStatus); isStatus && status.Signal() == syscall.SIGTERM {
return
Comment thread
stephdl marked this conversation as resolved.
}
}

message := strings.TrimSpace(stderrBuf.String())
if message == "" {
message = err.Error()
}
writeLogsError(s, logsAction.Id, message)
return
}
}()
Expand All @@ -233,7 +235,12 @@ func Action(socketAction models.SocketAction, s *melody.Session, wg *sync.WaitGr
go func() {
out, err := cmd.Output()
if err != nil {
utils.LogError(errors.Wrap(err, "[SOCKET] error executing Cmd for dump"))
message := err.Error()
if exitErr, isExitErr := err.(*exec.ExitError); isExitErr && len(exitErr.Stderr) > 0 {
message = strings.TrimSpace(string(exitErr.Stderr))
}
writeLogsError(s, logsAction.Id, message)
return
}

// reverse logs orders
Expand Down Expand Up @@ -291,6 +298,53 @@ func Action(socketAction models.SocketAction, s *melody.Session, wg *sync.WaitGr
}
}

// A failed query is log text like any other: logcli and Loki word it, and the
// frontend prints it in the output area without a case of its own.
func writeLogsError(s *melody.Session, id string, message string) {
if s == nil {
// nothing waits for the WaitGroup of the CLI: the exit ends the process
fmt.Fprintln(os.Stderr, message)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The message prints twice. It was already printed by line 301.

Suggested change
fmt.Fprintln(os.Stderr, message)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in 1890449. I had first moved the print into a shared helper, which kept the duplicate: utils.LogError already writes to stderr. Each path now prints once, the server fault through utils.LogError and the user error through its own fmt.Fprintln.

os.Exit(1)
}

writeSocketResponse(s, "logs-start", gin.H{"id": id, "pid": "", "message": message})
}

// LogQL strings use Go escape rules: a raw backslash must be doubled.
func escapeLogqlString(s string) string {
s = strings.ReplaceAll(s, `\`, `\\`)
return strings.ReplaceAll(s, `"`, `\"`)
}

func buildLogqlQuery(logsAction models.LogsStartAction) string {
var streamSelector string

switch logsAction.Entity {
default:
streamSelector = `{node_id=~".+"}`

case "node":
streamSelector = `{node_id="` + escapeLogqlString(logsAction.EntityName) + `"}`

case "module":
streamSelector = `{module_id="` + escapeLogqlString(logsAction.EntityName) + `"}`
}

logqlPipeline := ` | json syslog_id="SYSLOG_IDENTIFIER", message="MESSAGE" | line_format "[{{.node_id}}:{{.module_id}}:{{.syslog_id}}] {{.message}}"`

filter := ""
if len(logsAction.Filter) > 0 {
operator := `|=`
if logsAction.Regexp {
// a bad pattern is rejected by Loki, which compiles it anyway
operator = `|~`
}
filter = ` ` + operator + ` "` + escapeLogqlString(logsAction.Filter) + `"`
}

return streamSelector + logqlPipeline + filter
}

func reverse(ss []string) []string {
last := len(ss) - 1
for i := 0; i < len(ss)/2; i++ {
Expand Down
3 changes: 3 additions & 0 deletions core/api-server/socket/socket.go
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,9 @@ func Instance() *melody.Melody {
muClock = new(utils.MuClock)
muClock.Sync()
socketConnection = melody.New()
// melody defaults to 512 bytes and closes the connection past it: a
// logs-start payload is 280 bytes with an empty search query
socketConnection.Config.MaxMessageSize = 65536
socketConnection.HandleDisconnect(onDisconnect)
socketConnection.HandleMessage(onMessage)
socketConnection.HandlePong(onPong)
Expand Down
13 changes: 11 additions & 2 deletions core/ui/public/i18n/en/translation.json
Original file line number Diff line number Diff line change
Expand Up @@ -1178,15 +1178,24 @@
"title": "System logs",
"start_date": "Start date",
"end_date": "End date",
"start_time_label": "Start time (24-hour)",
"end_time_label": "End time (24-hour)",
"start_time_label": "Start time",
"end_time_label": "End time",
"time_format_helper": "24-hour",
"start": "Start",
"end": "End",
"context": "Context",
"context_cluster": "Cluster",
"context_node": "Node",
"context_module": "Application",
"search_query": "Search query",
"regexp": "Regular expression",
"regexp_helper": "RE2 syntax is accepted. Hint: '(?i)' ignores case, '|' matches any of the words",
"substring_helper": "Exact and case-sensitive substring match",
"websocket_disconnected": "The connection to the server was lost while the query was running. Run the search again.",
"showing_lines": "Showing {n} lines from {from} to {to}",
"showing_lines_of": "Showing {n} of {max} lines from {from} to {to}",
"showing_lines_no_interval": "Showing {n} lines",
"showing_lines_of_no_interval": "Showing {n} of {max} lines",
"max_lines": "Max lines",
"wrap_text": "Wrap text",
"search": "Search",
Expand Down
3 changes: 2 additions & 1 deletion core/ui/src/components/system-logs/LogOutput.vue
Original file line number Diff line number Diff line change
Expand Up @@ -178,9 +178,10 @@ export default {
background-image: none;
}

// show scrollbar
// show scrollbar, and grow so it lands on the edge, not on the longest line
.system-logs .logs-output.bx--snippet--multi .bx--snippet-container {
overflow-y: auto !important;
flex: 1 1 auto;
}

.logs-output {
Expand Down
Loading
Loading