Skip to content

Add regexp mode to system logs search - #1272

Merged
stephdl merged 35 commits into
mainfrom
sdl-8032
Aug 28, 2026
Merged

Add regexp mode to system logs search#1272
stephdl merged 35 commits into
mainfrom
sdl-8032

Conversation

@stephdl

@stephdl stephdl commented Aug 7, 2026

Copy link
Copy Markdown
Contributor

Adds a "Regular expression" toggle to the System Logs search bar. When enabled, the filter is applied with the LogQL |~ operator instead of the substring |=.

Backslashes are now escaped along with quotes for both operators, so a filter carrying either no longer produces an unparsable query and a misleading "No log found".

Invalid patterns are rejected client-side, and again server-side with Go regexp (RE2, the engine Loki runs). logcli failures now reach the UI as an error notification: until now a broken query was indistinguishable from an empty result.

Also from the Figma mockup:

  • a feedback line showing the number of retrieved lines and the interval they cover
  • collapse-filters and layout controls moved out of the kebab menu into the page toolbar
  • filter field widths aligned

Refs NethServer/dev#8032
Design: NethServer/dev#7974

Testing

  • toggle off, filter redis: results unchanged
  • toggle on, filter redis|traefik and ^2026-05
  • toggle on, filter [unclosed: error notification, no empty state
  • toggle off, filter containing a backslash: results instead of a silent empty
  • each of the above in both Dump and Follow mode
  • entity selection and date-range filtering still narrow the results
  • CLI: api-server-logs logs -s 'err(or)?' -r -m dump -l 50

Add `instance` and `regexp` input fields and the `error` output field
for the logs query endpoint.
Add a regexp toggle for log search with dedicated placeholder and
helper text, display backend query errors in an inline notification,
and show a results summary line with line count and time interval.
Rework the start/end date-time layout into a responsive flex group
and remove the now-unused collapse/expand filters toggle.

@DavidePrincipi DavidePrincipi left a comment

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 backend part seems ok, I'm proposing just a few little tweaks.

Comment thread core/api-server/README.md
if s != nil {
writeSocketResponse(s, "logs-start", gin.H{"id": id, "pid": "", "message": "", "error": message})
} else {
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.

Comment thread core/api-server/socket/action.go Outdated
args = append(args, streamSelector+logqlPipeline+filter)
query, errQuery := buildLogqlQuery(logsAction)
if errQuery != nil {
writeLogsError(s, logsAction.Id, errQuery.Error(), wg)

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.

Please do not log the error here. An invalid user regexp is a typo, not a server fault; it now lands in the api-server stderr/journal.

Suggested change
writeLogsError(s, logsAction.Id, errQuery.Error(), wg)

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 4b839d7. The invalid regexp goes through writeLogsQueryError, which does not log: it only sends the message back with an error_code the frontend turns into a translated wording. Checked on a test node, journalctl -u api-server stays empty after a failed pattern.

Comment thread core/api-server/socket/action.go
class="mg-bottom-lg"
v-model="internalTimezone"
>
<cv-select-option disabled selected hidden>{{

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Timezone field should not be displayed in follow mode

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 2d77e1f, the field and its entry in the collapsed filter summary are both hidden while following.

Digging further, hiding it was not enough: the timezone applies to follow output too (logcli -z UTC prints 07:23:02Z, -z Europe/Rome prints 10:01:01+02:00 on the same lines), so a UTC picked for an earlier dump search kept driving live timestamps with no visible control. Follow mode now always uses the browser timezone, 975657f. Verified: dump in UTC shows 2026-08-26T07:11:48Z, switching to follow shows 2026-08-26T10:05:36+02:00.

: $t("system_logs.expand_filters")
}}
</cv-link>
<NsInlineNotification

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Let's move this error notification below the Search button

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 7e2af90, the notification now renders after the toolbar row. It is also hidden when the search returned no lines, where it read as a failure report on an empty result.

this.isFollowing = false;
this.pid = "";
this.noLogsFound = false;
this.logsError = message;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

The regex error is not internationalized 🤔

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. The error banner is translated (title and sentence), and the engine detail is appended as it comes: "The search pattern is not valid RE2 syntax: error parsing regexp: missing closing ): (". That tail is produced by Go's regexp package and names the offending token, so it stays in English rather than being dropped.

Comment thread core/ui/public/i18n/en/translation.json Outdated
"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",

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Suggested change
"regexp_helper": "RE2 syntax is accepted. Hint: (?i) ignores case, | matches any of the words",
"regexp_helper": "RE2 syntax is accepted. Hint: '(?i)' ignores case, '|' matches any of the words",

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.

Applied. The string then had to change once more: regexp mode no longer highlights matches, so it now reads "RE2 syntax is accepted, matches are not highlighted. Hint: '(?i)' ignores case, '|' matches any of the words". Tell me if you prefer that second sentence elsewhere.

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.

back to your proposal

@andre8244

Copy link
Copy Markdown
Contributor

It looks like some patterns (e.g. (), (.)...) overload the browser memory and freeze the web page

… thread

The search pattern comes from the user and runs through the JavaScript engine,
which backtracks. Loki validates with Go RE2, which simulates the automaton
instead and therefore accepts patterns no backtracking engine can run: deciding
whether a line matches is linear, but finding where the match sits inside it is
2^n. Measured on a real log line, 5.5 ms at 18 characters and 340 ms at 24, a
factor of 1.98 per added character, on lines averaging 245. The regex API offers
no timeout and no interruption point, so a match on the main thread freezes the
tab beyond recovery.

Running it here lets the caller enforce a deadline by terminating the worker,
which is the only reliable way to stop a regex already backtracking.

The matching logic is the one that previously lived inline in LogHighlightMark,
moved unchanged: the zero-length-match guard, the adjacent-match merge, the
MAX_PARTS ceiling, and matching each line segment separately so a match cannot
span the timestamp/tag boundary. Offsets are returned rather than substrings, to
keep the reply from carrying a second copy of the log text.

Built from a Blob because the toolchain is webpack 4, which has no support for
`new Worker(new URL(...))`, and worker-loader is not a dependency.
Splitting a line used to mean executing the user pattern here, once per line
segment, during render. That is what froze the tab on a catastrophic pattern,
and it also looped forever on a zero-length match. The pattern now runs in a
worker under a deadline, so this component only slices the text at the offsets
it receives, and renders nothing highlighted while they are missing or when the
pattern turned out to be too expensive.

Verified identical to the previous splitter across 7600 line/pattern comparisons
on real log lines, covering "()", "(.)", "\w*", "\b" and substring searches.

Level colorization stays here, computed from four fixed internal patterns with
no user input, so it carries no risk and its behaviour is unchanged.
Every line was joined into a single string and handed to vue-text-highlight
along with the user pattern. Its indicesOf() has no zero-length-match guard, so
"()", ".*" or "\w*" never advanced lastIndex and looped forever filling an
array, hanging the tab; its mergeRange() also keeps contiguous ranges separate,
so "(.)" produced one chunk per character over the whole output. Render one
LogHighlightMark per line instead, which is what that component was written for.

Wrapping each line in a <mark> also applies the UA rule that sets the mark text
color, so lines with no level class came out black on the black log background:
reset the color as well, and let the level rules that follow override it.

The removed LOG_LEVEL_QUERIES and PROCESS_TAG_QUERY only told the library where
to cut. Colorization comes from LOG_LEVEL_CLASSIFIERS and PROCESS_TAG_PATTERN in
LogHighlightMark, and was verified unchanged: same tally over 500 real lines.

Own the worker here, since outputLines and highlight already arrive as props.
outputLines is trimmed from the front in follow mode, so index bookkeeping across
updates would silently misalign: recompute the whole buffer, debounced, which
costs 1-30 ms for every realistic pattern. Each request carries an id so a reply
for a superseded search is dropped instead of applied to different lines. Past a
400 ms deadline the worker is terminated and the lines stay on screen unmarked
behind an inline notification, since they are still the ones the backend matched.
Never fall back to matching on the main thread, which is the freeze.
Worded around what actually happened: locating the matches inside each line is
what is too slow, not the search, so the unmarked output is not read as "the
pattern matches nothing".
Loki runs RE2, so the pattern is validated server-side and the failure
came back as a raw Go message. Tag it with a code so the UI can word it
in the user language, and keep the engine detail as the only English
part left.
…e up

In follow mode the highlight is recomputed on every batch of new lines, so
a pattern past the deadline started a fresh doomed worker every 120 ms and
kept a core busy. Remember the pattern that gave up until it changes or a
new search clears the buffer.
A substring is escaped before it is compiled, so matching it is linear and
can never be the expensive case the notice describes: seeing it there only
suggests the search is broken. Two changes make the notice regexp-only.

Past the mark budget the worker now stops marking instead of dropping every
mark, so a common letter searched over a full buffer keeps its highlighting
on the lines already processed. The budget goes down to 20000 marks: 50000
cost 3.8s of rendering, 20000 cost 1.3s.

The deadline is left as the only way to lose the highlighting entirely, and
it belongs to a backtracking pattern, which only regexp mode can produce.
Loki matches with RE2 and the browser with its own engine, which knows a
different dialect: "(?P<lvl>error|warn)" and "err(?i)OR" are valid queries
the browser rejects. The fallback treated the pattern as literal text, so
the search worked and nothing was ever highlighted, with no explanation.

Report it instead, with its own wording: the lines are the ones that
matched, only the marking is missing.
A search matching every other character turns each line into dozens of
fragments, and the template compiler was spending ten times longer diffing
them than the browser spends parsing the equivalent markup: 2000 lines and
50000 marks cost 3.8s, against 0.35s for the same DOM built directly.

Two costs, both paid per fragment. The line is now assembled as one escaped
string rendered through v-html, so Vue sees one node per line instead of
fifty. And the offsets coming back from the worker are frozen, which stops
Vue from installing a reactive accessor on each of the hundreds of thousands
of numbers they contain -- on its own that was three quarters of the time.

The same search now costs 1.0s and highlights all 2000 lines instead of the
first 895, so the mark budget goes back up to 100000.
The viewer scrolls to the bottom in both modes, so spending the budget on
the oldest lines left the visible ones plain: the highlighting looked
broken while it was in fact complete on lines nobody was looking at.
melody defaults to a 512 byte read limit and closes the connection past it.
A logs-start payload is 280 bytes with an empty query, so a search of about
240 characters was enough to drop the socket: measured, 507 bytes go through
and 522 kill it. Regexp queries reach that length easily.

The frontend was then waiting for a reply that could never come, spinner
running until the page was reloaded, so it now ends the pending search when
the connection drops.
Cut the restatements and keep one line per non-obvious constraint: the RE2
dialect gap, the backtracking deadline, the v-html escaping, the Vue 2
reactivity walk, the melody message limit.
Loki matches with RE2 and the browser with its own engine, and making the
second one locate the matches of a pattern written for the first cost more
than the feature is worth: a pattern that is linear in RE2 can be
exponential in JavaScript, which needed a worker and a deadline to stay
recoverable; the two dialects disagree without erroring, so "\Q...\E" or
"\p{L}+" returned the right lines and marked nothing; and syntax RE2
accepts but JavaScript rejects needed a notice of its own.

Regexp mode now marks nothing, and the substring highlighter goes back to
what it was before this branch. No regex is built from user input in the
browser any more, so the worker, its deadline, the mark budget, the
abandoned-pattern cache and both notices have nothing left to protect.
utils.LogError already writes the message to stderr, so the CLI path
printed it a second time.
@stephdl

stephdl commented Aug 26, 2026

Copy link
Copy Markdown
Contributor Author

The freeze is gone, and not by containing it: regexp mode no longer highlights matches at all, so no pattern written by the user is ever compiled by the browser engine. Substring mode keeps the highlighter it had before this branch, unchanged.

Measured on a test node, 500 lines, worst main-thread stall per search:

pattern marks stall
(), (.), .*, \b, ^, $ 0 <= 24 ms
(.*)*x, (a+)+$, ((ab)*)*c 0 <= 29 ms
(?P<lvl>...), \Qsystemd\E, [[:alpha:]]+ 0 <= 32 ms
substring e, 2000 lines 16106 681 ms

The helper text under the field says matches are not highlighted when the toggle is on.

The timezone field is hidden while following, but the value was still sent:
a UTC picked for an earlier dump search kept driving the timestamps of live
lines, with no visible control to change it back.

@andre8244 andre8244 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

UI part LGTM

The error and error_code attributes of logs-start need a matching case
on every consumer, and let the frontend pick a translated wording from
a backend constant the API document does not mention. A query fails the
way logcli and Loki word it, and that wording is log output: sending it
as message prints it in the output area, with no attribute, no second
error writer and no notification above the logs.

Loki compiles the regexp of a |~ filter itself, so the query builder no
longer duplicates that check to raise an invalid_regexp code, and the
tail goroutine sends logs-stop whenever it returns, which is how the
frontend already leaves the follow state.

Assisted-by: Claude Code:claude-opus-5

@DavidePrincipi DavidePrincipi left a comment

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 last commit removes the inline notification that displays backend error details. Since this part required a change of the websocket protocol, I preferred to make a step back and report backend errors in the text area itself.

I know it is less effective for UX, but the changes introduced by the feature (which should be rarely used) should be kept small.

I'm requesting Ande's review again.

In follow mode the command stderr was buffered and reported only after
the process ended, so a message about a query that keeps running was
never shown, and a clean exit dropped it altogether. Both streams now
share one pipe: what logcli says about a failure reaches the frontend
as log text, in order with the lines around it.

Reading that pipe to EOF before Wait also closes a race, as Wait used
to close the pipe while the scanner was still reading from it.

Assisted-by: Claude Code:claude-opus-5

@DavidePrincipi DavidePrincipi left a comment

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.

Filed upstream bug grafana/loki#24249 about tail mode hangs in some cases.
NS8 bug NethServer/dev#8140 for core 3.22+

@stephdl
stephdl merged commit aadb8b1 into main Aug 28, 2026
2 checks passed
@stephdl
stephdl deleted the sdl-8032 branch August 28, 2026 15:21
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants