From cbf668e87cbca9f6d6f9fc3e5969baf4e2a81135 Mon Sep 17 00:00:00 2001 From: ze0s <43699394+zze0s@users.noreply.github.com> Date: Sun, 10 Sep 2023 12:28:10 +0200 Subject: [PATCH] feat(logs): show full log event (#1096) * feat(logs): show all extra fields in UI * feat(logs): init log level as debug then trace --- cmd/autobrr/main.go | 2 +- internal/irc/handler.go | 69 +++--- internal/logger/logger.go | 8 +- internal/logger/sse_writer.go | 394 ++++++++++++++++++++++++++++++++++ internal/logger/ssehook.go | 46 ---- web/src/screens/Logs.tsx | 17 +- 6 files changed, 448 insertions(+), 88 deletions(-) create mode 100644 internal/logger/sse_writer.go delete mode 100644 internal/logger/ssehook.go diff --git a/cmd/autobrr/main.go b/cmd/autobrr/main.go index 9c6c022..2e0a485 100644 --- a/cmd/autobrr/main.go +++ b/cmd/autobrr/main.go @@ -59,7 +59,7 @@ func main() { serverEvents.CreateStreamWithOpts("logs", sse.StreamOpts{MaxEntries: 1000, AutoReplay: true}) // register SSE hook on logger - log.RegisterSSEHook(serverEvents) + log.RegisterSSEWriter(serverEvents) // setup internal eventbus bus := EventBus.New() diff --git a/internal/irc/handler.go b/internal/irc/handler.go index 0ca7d56..075f552 100644 --- a/internal/irc/handler.go +++ b/internal/irc/handler.go @@ -160,7 +160,9 @@ func (h *Handler) Run() error { addr = h.network.BouncerAddr } - subLogger := zstdlog.NewStdLoggerWithLevel(h.log.With().Logger(), zerolog.TraceLevel) + // this used to be TraceLevel but was changed to DebugLevel during connect to see the info without needing to change loglevel + // we change back to TraceLevel in the handleJoined method. + subLogger := zstdlog.NewStdLoggerWithLevel(h.log.With().Logger(), zerolog.DebugLevel) h.client = &ircevent.Connection{ Nick: h.network.Nick, @@ -318,6 +320,7 @@ func (h *Handler) resetChannelHealth() { // Stop the network and quit func (h *Handler) Stop() { h.m.Lock() + h.connectedSince = time.Time{} h.manuallyDisconnected = true if h.client.Connected() { @@ -325,6 +328,8 @@ func (h *Handler) Stop() { } h.m.Unlock() + h.resetChannelHealth() + h.client.Quit() } @@ -351,7 +356,7 @@ func (h *Handler) onConnect(m ircmsg.Message) { if h.haveDisconnected { h.notificationService.Send(domain.NotificationEventIRCReconnected, domain.NotificationPayload{ Subject: "IRC Reconnected", - Message: fmt.Sprintf("Network: %v", h.network.Name), + Message: fmt.Sprintf("Network: %s", h.network.Name), }) // reset haveDisconnected @@ -359,12 +364,13 @@ func (h *Handler) onConnect(m ircmsg.Message) { } h.m.Unlock() - h.log.Debug().Msgf("connected to: %v", h.network.Name) + h.log.Debug().Msgf("connected to: %s", h.network.Name) }() time.Sleep(1 * time.Second) h.authenticate() + } // onDisconnect is the disconnect callback @@ -391,7 +397,7 @@ func (h *Handler) onDisconnect(m ircmsg.Message) { // only send notification if we did not initiate disconnect/restart/stop h.notificationService.Send(domain.NotificationEventIRCDisconnected, domain.NotificationPayload{ Subject: "IRC Disconnected unexpectedly", - Message: fmt.Sprintf("Network: %v", h.network.Name), + Message: fmt.Sprintf("Network: %s", h.network.Name), }) } else { // reset @@ -466,7 +472,7 @@ func (h *Handler) handleNickServ(msg ircmsg.Message) { if contains(msg.Params[1], "invalid parameters", "help identify") { h.log.Debug().Msgf("NOTICE nickserv invalid: %v", msg.Params) - if err := h.client.Send("PRIVMSG", "NickServ", fmt.Sprintf("IDENTIFY %v %v", h.network.Auth.Account, h.network.Auth.Password)); err != nil { + if err := h.client.Send("PRIVMSG", "NickServ", fmt.Sprintf("IDENTIFY %s %s", h.network.Auth.Account, h.network.Auth.Password)); err != nil { return } } @@ -520,7 +526,7 @@ func (h *Handler) inviteCommand() { if h.network.InviteCommand != "" { h.log.Trace().Msg("on connect invite command not empty: send connect commands") if err := h.sendConnectCommands(h.network.InviteCommand); err != nil { - h.log.Error().Stack().Err(err).Msgf("error sending connect command %v", h.network.InviteCommand) + h.log.Error().Stack().Err(err).Msgf("error sending connect command %s", h.network.InviteCommand) return } } @@ -541,7 +547,7 @@ func contains(s string, substr ...string) bool { // onNick handles NICK events func (h *Handler) onNick(msg ircmsg.Message) { - h.log.Trace().Msgf("NICK event: %v params: %v", msg.Nick(), msg.Params) + h.log.Trace().Msgf("NICK event: %s params: %v", msg.Nick(), msg.Params) if len(msg.Params) < 1 { return } @@ -593,7 +599,7 @@ func (h *Handler) onMessage(msg ircmsg.Message) { h.log.Debug().Str("channel", channel).Str("nick", nick).Msg(cleanedMsg) if err := h.sendToAnnounceProcessor(channel, cleanedMsg); err != nil { - h.log.Error().Stack().Err(err).Msgf("could not queue line: %v", cleanedMsg) + h.log.Error().Stack().Err(err).Msgf("could not queue line: %s", cleanedMsg) return } @@ -607,12 +613,12 @@ func (h *Handler) sendToAnnounceProcessor(channel string, msg string) error { // check if queue exists queue, ok := h.announceProcessors[channel] if !ok { - return errors.New("queue '%v' not found", channel) + return errors.New("queue '%s' not found", channel) } // if it exists, add msg if err := queue.AddLineToQueue(channel, msg); err != nil { - h.log.Error().Stack().Err(err).Msgf("could not queue line: %v", msg) + h.log.Error().Stack().Err(err).Msgf("could not queue line: %s", msg) return err } @@ -627,7 +633,7 @@ func (h *Handler) sendToAnnounceProcessor(channel string, msg string) error { func (h *Handler) JoinChannels() { for _, channel := range h.network.Channels { if err := h.JoinChannel(channel.Name, channel.Password); err != nil { - h.log.Error().Stack().Err(err).Msgf("error joining channel %v", channel.Name) + h.log.Error().Stack().Err(err).Msgf("error joining channel %s", channel.Name) } time.Sleep(1 * time.Second) } @@ -645,10 +651,10 @@ func (h *Handler) JoinChannel(channel string, password string) error { m.Params = []string{channel, password} } - h.log.Debug().Msgf("sending JOIN command %v", strings.Join(m.Params, " ")) + h.log.Debug().Msgf("sending JOIN command %s", strings.Join(m.Params, " ")) if err := h.client.SendIRCMessage(m); err != nil { - h.log.Error().Stack().Err(err).Msgf("error handling join: %v", channel) + h.log.Error().Stack().Err(err).Msgf("error handling join: %s", channel) return err } @@ -663,7 +669,7 @@ func (h *Handler) handlePart(msg ircmsg.Message) { } channel := strings.ToLower(msg.Params[0]) - h.log.Debug().Msgf("PART channel %v", channel) + h.log.Debug().Msgf("PART channel %s", channel) // reset monitoring status if v, ok := h.channelHealth[channel]; ok { @@ -672,15 +678,15 @@ func (h *Handler) handlePart(msg ircmsg.Message) { // TODO remove announceProcessor - h.log.Debug().Msgf("Left channel %v", channel) + h.log.Debug().Msgf("Left channel %s", channel) } // PartChannel parts/leaves channel func (h *Handler) PartChannel(channel string) error { - h.log.Debug().Msgf("Leaving channel %v", channel) + h.log.Debug().Msgf("Leaving channel %s", channel) if err := h.client.Part(channel); err != nil { - h.log.Error().Err(err).Msgf("error handling part: %v", channel) + h.log.Error().Err(err).Msgf("error handling part: %s", channel) return err } @@ -699,12 +705,12 @@ func (h *Handler) handleJoined(msg ircmsg.Message) { // get channel channel := strings.ToLower(msg.Params[1]) - h.log.Debug().Msgf("JOINED: %v", channel) + h.log.Debug().Msgf("JOINED: %s", channel) // check if channel is valid and if not lets part if valid := h.isValidHandlerChannel(channel); !valid { if err := h.PartChannel(msg.Params[1]); err != nil { - h.log.Error().Err(err).Msgf("error handling part for unwanted channel: %v", msg.Params[1]) + h.log.Error().Err(err).Msgf("error handling part for unwanted channel: %s", msg.Params[1]) return } return @@ -717,7 +723,7 @@ func (h *Handler) handleJoined(msg ircmsg.Message) { v.monitoring = true v.monitoringSince = time.Now() - h.log.Trace().Msgf("set monitoring: %v", v.name) + h.log.Trace().Msgf("set monitoring: %s", v.name) } } else { @@ -727,17 +733,20 @@ func (h *Handler) handleJoined(msg ircmsg.Message) { monitoringSince: time.Now(), } - h.log.Trace().Msgf("add channel health monitoring: %v", channel) + h.log.Trace().Msgf("add channel health monitoring: %s", channel) } h.m.Unlock() // if not valid it's considered an extra channel if valid := h.isValidChannel(channel); !valid { - h.log.Info().Msgf("Joined extra channel %v", channel) + h.log.Info().Msgf("Joined extra channel %s", channel) return } - h.log.Info().Msgf("Monitoring channel %v", channel) + h.log.Info().Msgf("Monitoring channel %s", channel) + + // reset log level to Trace now that we are monitoring a channel + h.client.Log = zstdlog.NewStdLoggerWithLevel(h.log.With().Logger(), zerolog.TraceLevel) } // sendConnectCommands sends invite commands @@ -758,7 +767,7 @@ func (h *Handler) sendConnectCommands(msg string) error { Params: strings.Split(cmd, " "), } - h.log.Debug().Msgf("sending connect command: %v", cmd) + h.log.Debug().Msgf("sending connect command: %s", cmd) if err := h.client.SendIRCMessage(m); err != nil { h.log.Error().Err(err).Msgf("error handling connect command: %v", m) @@ -780,17 +789,17 @@ func (h *Handler) handleInvite(msg ircmsg.Message) { // get channel channel := strings.ToLower(msg.Params[1]) - h.log.Trace().Msgf("INVITE from %v to join: %v", msg.Nick(), channel) + h.log.Trace().Msgf("INVITE from %s to join: %s", msg.Nick(), channel) if validChannel := h.isValidHandlerChannel(channel); !validChannel { - h.log.Trace().Msgf("invite from %v to join: %v - invalid channel, skip joining", msg.Nick(), channel) + h.log.Trace().Msgf("invite from %s to join: %s - invalid channel, skip joining", msg.Nick(), channel) return } - h.log.Debug().Msgf("INVITE from %v, joining %v", msg.Nick(), channel) + h.log.Debug().Msgf("INVITE from %s, joining %s", msg.Nick(), channel) if err := h.client.Join(msg.Params[1]); err != nil { - h.log.Error().Stack().Err(err).Msgf("error handling join: %v", msg.Params[1]) + h.log.Error().Stack().Err(err).Msgf("error handling join: %s", msg.Params[1]) return } @@ -816,7 +825,7 @@ func (h *Handler) NickServIdentify(password string) error { // NickChange sets a new nick for our user func (h *Handler) NickChange(nick string) error { - h.log.Debug().Msgf("NICK change: %v", nick) + h.log.Debug().Msgf("NICK change: %s", nick) h.client.SetNick(nick) @@ -853,7 +862,7 @@ func (h *Handler) SendMsg(channel, msg string) error { h.log.Debug().Msgf("sending msg command: %s", msg) if err := h.client.Privmsg(channel, msg); err != nil { - h.log.Error().Stack().Err(err).Msgf("error sending msg: %v", msg) + h.log.Error().Stack().Err(err).Msgf("error sending msg: %s", msg) return err } diff --git a/internal/logger/logger.go b/internal/logger/logger.go index 4bf9d1d..fb2a9ef 100644 --- a/internal/logger/logger.go +++ b/internal/logger/logger.go @@ -27,7 +27,7 @@ type Logger interface { Trace() *zerolog.Event Debug() *zerolog.Event With() zerolog.Context - RegisterSSEHook(sse *sse.Server) + RegisterSSEWriter(sse *sse.Server) SetLogLevel(level string) } @@ -78,8 +78,10 @@ func New(cfg *domain.Config) Logger { return l } -func (l *DefaultLogger) RegisterSSEHook(sse *sse.Server) { - l.log = l.log.Hook(&ServerSentEventHook{sse: sse}) +func (l *DefaultLogger) RegisterSSEWriter(sse *sse.Server) { + w := NewSSEWriter(sse) + l.writers = append(l.writers, w) + l.log = zerolog.New(io.MultiWriter(l.writers...)).With().Stack().Logger() } func (l *DefaultLogger) SetLogLevel(level string) { diff --git a/internal/logger/sse_writer.go b/internal/logger/sse_writer.go new file mode 100644 index 0000000..462b19f --- /dev/null +++ b/internal/logger/sse_writer.go @@ -0,0 +1,394 @@ +// Copyright (c) 2021 - 2023, Ludvig Lundgren and the autobrr contributors. +// SPDX-License-Identifier: GPL-2.0-or-later + +package logger + +import ( + "bytes" + "encoding/json" + "fmt" + "os" + "path/filepath" + "sort" + "strconv" + "strings" + "time" + + "github.com/r3labs/sse/v2" + "github.com/rs/zerolog" +) + +type SSEWriter struct { + // SSE + SSE *sse.Server + + // TimeFormat specifies the format for timestamp in output. + TimeFormat string + + // PartsOrder defines the order of parts in output. + PartsOrder []string +} + +func NewSSEWriter(sse *sse.Server, options ...func(w *SSEWriter)) SSEWriter { + w := SSEWriter{ + SSE: sse, + TimeFormat: defaultTimeFormat, + PartsOrder: defaultPartsOrder(), + } + + for _, opt := range options { + opt(&w) + } + + return w +} + +type LogMessage struct { + Time string `json:"time"` + Level string `json:"level"` + Message string `json:"message"` +} + +func (m LogMessage) Bytes() ([]byte, error) { + j, err := json.Marshal(m) + if err != nil { + return nil, err + } + return j, nil +} + +func (w SSEWriter) Write(p []byte) (n int, err error) { + if w.SSE == nil { + return 0, nil + } + + var evt map[string]interface{} + p = decodeIfBinaryToBytes(p) + d := json.NewDecoder(bytes.NewReader(p)) + d.UseNumber() + err = d.Decode(&evt) + if err != nil { + return n, fmt.Errorf("cannot decode event: %s", err) + } + + buf := bytes.NewBuffer(make([]byte, 0, 100)) + defer func() { + buf.Reset() + }() + + for _, p := range w.PartsOrder { + w.writePart(buf, evt, p) + } + + w.writeFields(buf, evt) + + err = buf.WriteByte('\n') + if err != nil { + return n, err + } + + m := LogMessage{ + //Time: w.formatTime(evt), + Time: evt["time"].(string), + Level: w.formatLevel(evt), + Message: buf.String(), + } + + data, err := m.Bytes() + if err != nil { + return n, err + } + + // publish too logs topic + w.SSE.Publish("logs", &sse.Event{ + Data: data, + }) + + return len(p), err +} + +// writeFields appends formatted key-value pairs to buf. +func (w SSEWriter) writeFields(buf *bytes.Buffer, evt map[string]interface{}) { + var fields = make([]string, 0, len(evt)) + for field := range evt { + + switch field { + case zerolog.LevelFieldName, zerolog.TimestampFieldName, zerolog.MessageFieldName, zerolog.CallerFieldName: + continue + } + fields = append(fields, field) + } + sort.Strings(fields) + + // Write space only if something has already been written to the buffer, and if there are fields. + if buf.Len() > 0 && len(fields) > 0 { + buf.WriteByte(' ') + } + + // Move the "error" field to the front + ei := sort.Search(len(fields), func(i int) bool { return fields[i] >= zerolog.ErrorFieldName }) + if ei < len(fields) && fields[ei] == zerolog.ErrorFieldName { + fields[ei] = "" + fields = append([]string{zerolog.ErrorFieldName}, fields...) + var xfields = make([]string, 0, len(fields)) + for _, field := range fields { + if field == "" { // Skip empty fields + continue + } + xfields = append(xfields, field) + } + fields = xfields + } + + for i, field := range fields { + var fn Formatter + var fv Formatter + + if field == zerolog.ErrorFieldName { + fn = defaultFormatErrFieldName() + + fv = defaultFormatErrFieldValue() + } else { + fn = defaultFormatFieldName() + + fv = defaultFormatFieldValue + } + + buf.WriteString(fn(field)) + + switch fValue := evt[field].(type) { + case string: + if needsQuote(fValue) { + buf.WriteString(fv(strconv.Quote(fValue))) + } else { + buf.WriteString(fv(fValue)) + } + case json.Number: + buf.WriteString(fv(fValue)) + default: + b, err := zerolog.InterfaceMarshalFunc(fValue) + if err != nil { + fmt.Fprintf(buf, "[error: %v]", err) + } else { + fmt.Fprint(buf, fv(b)) + } + } + + if i < len(fields)-1 { // Skip space for last field + buf.WriteByte(' ') + } + } +} + +// writePart appends a formatted part to buf. +func (w SSEWriter) writePart(buf *bytes.Buffer, evt map[string]interface{}, p string) { + var f Formatter + + switch p { + case zerolog.LevelFieldName: + f = defaultFormatLevel() + + case zerolog.TimestampFieldName: + f = defaultFormatTimestamp(w.TimeFormat) + + case zerolog.MessageFieldName: + f = defaultFormatMessage + + case zerolog.CallerFieldName: + f = defaultFormatCaller() + + default: + f = defaultFormatFieldValue + } + + var s = f(evt[p]) + + if len(s) > 0 { + if buf.Len() > 0 { + buf.WriteByte(' ') // Write space only if not the first part + } + buf.WriteString(s) + } +} + +// formatLevel format level to string +func (w SSEWriter) formatLevel(evt map[string]interface{}) string { + var f Formatter + + f = defaultFormatLevel() + + var s = f(evt["level"]) + + if len(s) > 0 { + return s + } + + return "" +} + +// formatTime format time to string +func (w SSEWriter) formatTime(evt map[string]interface{}) string { + var f Formatter + + f = defaultFormatTimestamp(w.TimeFormat) + + var s = f(evt["time"]) + + if len(s) > 0 { + return s + } + + return "" +} + +const ( + defaultTimeFormat = time.Kitchen +) + +// Formatter transforms the input into a formatted string. +type Formatter func(interface{}) string + +func decodeIfBinaryToBytes(in []byte) []byte { + return in +} + +// needsQuote returns true when the string s should be quoted in output. +func needsQuote(s string) bool { + for i := range s { + if s[i] < 0x20 || s[i] > 0x7e || s[i] == ' ' || s[i] == '\\' || s[i] == '"' { + return true + } + } + return false +} + +// ----- DEFAULT FORMATTERS --------------------------------------------------- + +func defaultPartsOrder() []string { + return []string{ + //zerolog.TimestampFieldName, + //zerolog.LevelFieldName, + zerolog.CallerFieldName, + zerolog.MessageFieldName, + } +} + +func defaultFormatTimestamp(timeFormat string) Formatter { + if timeFormat == "" { + timeFormat = defaultTimeFormat + } + return func(i interface{}) string { + t := "" + switch tt := i.(type) { + case string: + ts, err := time.ParseInLocation(zerolog.TimeFieldFormat, tt, time.Local) + if err != nil { + t = tt + } else { + t = ts.Local().Format(timeFormat) + } + case json.Number: + i, err := tt.Int64() + if err != nil { + t = tt.String() + } else { + var sec, nsec int64 + + switch zerolog.TimeFieldFormat { + case zerolog.TimeFormatUnixNano: + sec, nsec = 0, i + case zerolog.TimeFormatUnixMicro: + sec, nsec = 0, int64(time.Duration(i)*time.Microsecond) + case zerolog.TimeFormatUnixMs: + sec, nsec = 0, int64(time.Duration(i)*time.Millisecond) + default: + sec, nsec = i, 0 + } + + ts := time.Unix(sec, nsec) + t = ts.Format(timeFormat) + } + } + return t + } +} + +func defaultFormatLevel() Formatter { + return func(i interface{}) string { + var l string + if ll, ok := i.(string); ok { + switch ll { + case zerolog.LevelTraceValue: + l = "TRC" + case zerolog.LevelDebugValue: + l = "DBG" + case zerolog.LevelInfoValue: + l = "INF" + case zerolog.LevelWarnValue: + l = "WRN" + case zerolog.LevelErrorValue: + l = "ERR" + case zerolog.LevelFatalValue: + l = "FTL" + case zerolog.LevelPanicValue: + l = "PNC" + default: + l = ll + } + } else { + if i == nil { + l = "???" + } else { + l = strings.ToUpper(fmt.Sprintf("%s", i))[0:3] + } + } + return l + } +} + +func defaultFormatCaller() Formatter { + return func(i interface{}) string { + var c string + if cc, ok := i.(string); ok { + c = cc + } + if len(c) > 0 { + if cwd, err := os.Getwd(); err == nil { + if rel, err := filepath.Rel(cwd, c); err == nil { + c = rel + } + } + c = c + " >" + } + return c + } +} + +func defaultFormatMessage(i interface{}) string { + if i == nil { + return "" + } + return fmt.Sprintf("%s", i) +} + +func defaultFormatFieldName() Formatter { + return func(i interface{}) string { + return fmt.Sprintf("%s=", i) + } +} + +func defaultFormatFieldValue(i interface{}) string { + return fmt.Sprintf("%s", i) +} + +func defaultFormatErrFieldName() Formatter { + return func(i interface{}) string { + return fmt.Sprintf("%s=", i) + } +} + +func defaultFormatErrFieldValue() Formatter { + return func(i interface{}) string { + return fmt.Sprintf("%s=", i) + } +} diff --git a/internal/logger/ssehook.go b/internal/logger/ssehook.go deleted file mode 100644 index a06e72b..0000000 --- a/internal/logger/ssehook.go +++ /dev/null @@ -1,46 +0,0 @@ -// Copyright (c) 2021 - 2023, Ludvig Lundgren and the autobrr contributors. -// SPDX-License-Identifier: GPL-2.0-or-later - -package logger - -import ( - "encoding/json" - "strings" - "time" - - "github.com/r3labs/sse/v2" - "github.com/rs/zerolog" -) - -type LogMessage struct { - Time string `json:"time"` - Level string `json:"level"` - Message string `json:"message"` -} - -func (m LogMessage) ToJsonString() string { - j, err := json.Marshal(m) - if err != nil { - return "" - } - return string(j) -} - -type ServerSentEventHook struct { - sse *sse.Server -} - -func (h *ServerSentEventHook) Run(e *zerolog.Event, level zerolog.Level, msg string) { - if h.sse != nil { - // publish too logs topic - logMsg := LogMessage{ - Time: time.Now().Format(time.RFC3339), - Level: strings.ToUpper(level.String()), - Message: msg, - } - - h.sse.Publish("logs", &sse.Event{ - Data: []byte(logMsg.ToJsonString()), - }) - } -} diff --git a/web/src/screens/Logs.tsx b/web/src/screens/Logs.tsx index 6aec8b5..4feeb12 100644 --- a/web/src/screens/Logs.tsx +++ b/web/src/screens/Logs.tsx @@ -31,14 +31,16 @@ type LogEvent = { message: string; }; -type LogLevel = "TRACE" | "DEBUG" | "INFO" | "ERROR" | "WARN"; +type LogLevel = "TRC" | "DBG" | "INF" | "ERR" | "WRN" | "FTL" | "PNC"; const LogColors: Record = { - "TRACE": "text-purple-300", - "DEBUG": "text-yellow-500", - "INFO": "text-green-500", - "ERROR": "text-red-500", - "WARN": "text-yellow-500" + "TRC": "text-purple-300", + "DBG": "text-yellow-500", + "INF": "text-green-500", + "ERR": "text-red-500", + "WRN": "text-yellow-500", + "FTL": "text-red-500", + "PNC": "text-red-600", }; export const Logs = () => { @@ -143,7 +145,7 @@ export const Logs = () => { title={entry.time} className="font-mono text-gray-500 dark:text-gray-600 mr-2 h-full" > - {format(new Date(entry.time), "HH:mm:ss.SSS")} + {format(new Date(entry.time), "HH:mm:ss")} {entry.level in LogColors ? ( { )} > {entry.level} - {" "} ) : null}