]> git.ipfire.org Git - thirdparty/rspamd.git/commitdiff
[Minor] Add colored output and TTY-aware progress to logstats/mapstats
authorVsevolod Stakhov <vsevolod@rspamd.com>
Wed, 11 Feb 2026 09:25:31 +0000 (09:25 +0000)
committerVsevolod Stakhov <vsevolod@rspamd.com>
Wed, 11 Feb 2026 09:25:31 +0000 (09:25 +0000)
Gate spinner and ANSI escape codes behind isatty() so piped output is
clean. Add ansicolors to logstats (Ham/Spam/Junk labels, symbol names,
actions, warnings, summary) and mapstats (map status, match counts,
unmatched warnings).

lualib/lua_log_utils.lua
lualib/rspamadm/logstats.lua
lualib/rspamadm/mapstats.lua

index 71b8265e9a93e90da0d234e0e879affb51264a0a..03dcdeb3a604a0b62fa37ab717def3a3deb0e688 100644 (file)
@@ -19,6 +19,8 @@ local rspamd_util = require "rspamd_util"
 
 local exports = {}
 
+local isatty = rspamd_util.isatty()
+
 local decompressor = {
   bz2 = 'bzip2 -cd',
   gz  = 'gzip -cd',
@@ -35,6 +37,9 @@ local spinner_chars = { '/', '-', '\\', '|' }
 local spinner_update_time = 0
 
 function exports.spinner()
+  if not isatty then
+    return
+  end
   local now = os.time()
   if (now - spinner_update_time) < 1 then
     return
@@ -315,15 +320,22 @@ function exports.process_logs(log_file, start_time, end_time, callback, opts)
         if not h then
           io.stderr:write(string.format("Cannot open %s: %s\n", path, open_err or 'unknown error'))
         else
-          io.stderr:write(string.format("\027[J  Parsing log files: [%d/%d] %s\027[G",
-            idx, #logs, fname))
+          if isatty then
+            io.stderr:write(string.format("\027[J  Parsing log files: [%d/%d] %s\027[G",
+              idx, #logs, fname))
+          else
+            io.stderr:write(string.format("  Parsing log files: [%d/%d] %s\n",
+              idx, #logs, fname))
+          end
           exports.reset_spinner()
           exports.spinner()
           exports.iterate_log(h, start_time, end_time, callback, opts)
           h:close()
         end
       end
-      io.stderr:write("\027[J\027[G")
+      if isatty then
+        io.stderr:write("\027[J\027[G")
+      end
     else
       local h, open_err = exports.open_log_file(log_file)
       if not h then
index b5749dfff3b90568deacd990686bf4356977c524..981e3ad12385f99934510c6e6d6aa34d3e1eaadc 100644 (file)
@@ -18,6 +18,16 @@ local argparse = require "argparse"
 local rspamd_regexp = require "rspamd_regexp"
 local ucl = require "ucl"
 local log_utils = require "lua_log_utils"
+local ansicolors = require "ansicolors"
+
+local action_colors = {
+  reject = ansicolors.red,
+  ['add header'] = ansicolors.yellow,
+  ['rewrite subject'] = ansicolors.yellow,
+  ['soft reject'] = ansicolors.magenta,
+  greylist = ansicolors.cyan,
+  ['no action'] = ansicolors.green,
+}
 
 local parser = argparse()
   :name "rspamadm logstats"
@@ -539,13 +549,17 @@ local function handler(args)
           local jtp = (total_junk ~= 0) and (jh * 100.0 / total_junk) or 0
 
           io.write(string.format(
-            "%s   avg. weight %.3f, hits %d(%.3f%%):\n" ..
-            "  Ham  %7.3f%%, %6d/%-6d (%7.3f%%)\n" ..
-            "  Spam %7.3f%%, %6d/%-6d (%7.3f%%)\n" ..
-            "  Junk %7.3f%%, %6d/%-6d (%7.3f%%)\n",
-            s, r.weight / th, th, (th / total * 100),
+            "%s   avg. weight %.3f, hits %d (%.3f%%):\n" ..
+            "  %s %7.3f%%, %6d/%-6d (%7.3f%%)\n" ..
+            "  %s %7.3f%%, %6d/%-6d (%7.3f%%)\n" ..
+            "  %s %7.3f%%, %6d/%-6d (%7.3f%%)\n",
+            ansicolors.bright .. s .. ansicolors.reset,
+            r.weight / th, th, (th / total * 100),
+            ansicolors.green .. "Ham " .. ansicolors.reset,
             (hh / th * 100), hh, total_ham, htp,
+            ansicolors.red .. "Spam" .. ansicolors.reset,
             (sh / th * 100), sh, total_spam, stp,
+            ansicolors.yellow .. "Junk" .. ansicolors.reset,
             (jh / th * 100), jh, total_junk, jtp))
 
           local schp = (total_spam > 0) and (r.spam_change / total_spam * 100.0) or 0
@@ -617,16 +631,19 @@ local function handler(args)
 
     if next(alpha_filtered) then
       io.write(string.format(
-        "\nWARNING: the following symbols were found but ignored" ..
-        " due to score < alpha_score (%.2f):\n", diff_alpha))
+        "\n%s the following symbols were found but ignored" ..
+        " due to score < alpha_score (%.2f):\n",
+        ansicolors.yellow .. "WARNING:" .. ansicolors.reset, diff_alpha))
       for sym, count in pairs(alpha_filtered) do
-        io.write(string.format("  %s: %d hit(s)\n", sym, count))
+        io.write(string.format("  %s: %d hit(s)\n",
+          ansicolors.bright .. sym .. ansicolors.reset, count))
       end
       io.write("Use --alpha-score 0 to include them.\n")
     end
 
-    io.write(string.format("\n=== Summary %s\nMessages scanned: %d",
-      string.rep('=', 68), total))
+    io.write(string.format("\n%s\nMessages scanned: %d",
+      ansicolors.bright .. "=== Summary " .. string.rep('=', 68) .. ansicolors.reset,
+      total))
     if timeStamp['start'] then
       io.write(string.format(" [ %s / %s ]\n", timeStamp['start'], timeStamp['end']))
     else
@@ -639,7 +656,10 @@ local function handler(args)
     end
     table.sort(sorted_actions)
     for _, a in ipairs(sorted_actions) do
-      io.write(string.format("%11s: %6.2f%%, %d\n", a, 100 * actions[a] / total, actions[a]))
+      local color = action_colors[a] or ansicolors.white
+      io.write(string.format("%s: %6.2f%%, %d\n",
+        color .. string.format("%11s", a) .. ansicolors.reset,
+        100 * actions[a] / total, actions[a]))
     end
     io.write('\n')
     if scanTime['min'] then
index c6f2950b96c3cc65ed2b5360f2a7cce0c161fd46..137f80ca42b658d17266f070741fa87c8293ea01 100644 (file)
@@ -18,6 +18,7 @@ local argparse = require "argparse"
 local rspamd_regexp = require "rspamd_regexp"
 local rspamd_ip = require "rspamd_ip"
 local log_utils = require "lua_log_utils"
+local ansicolors = require "ansicolors"
 
 local parser = argparse()
   :name "rspamadm mapstats"
@@ -253,7 +254,9 @@ local function handler(args)
 
       -- Skip non-file maps
       if re_non_file_url:match(map_source) then
-        io.write(string.format("%s: %s [SKIPPED]\n", symbol, map_source))
+        io.write(string.format("%s: %s %s\n",
+          ansicolors.bright .. symbol .. ansicolors.reset, map_source,
+          ansicolors.yellow .. "[SKIPPED]" .. ansicolors.reset))
         goto continue_map
       end
 
@@ -262,7 +265,9 @@ local function handler(args)
 
       local entries = get_map(cfg, file_path)
       if #entries == 0 then
-        io.write(string.format("%s: %s [FAILED]\n", symbol, map_source))
+        io.write(string.format("%s: %s %s\n",
+          ansicolors.bright .. symbol .. ansicolors.reset, map_source,
+          ansicolors.red .. "[FAILED]" .. ansicolors.reset))
         goto continue_map
       end
 
@@ -272,7 +277,9 @@ local function handler(args)
           entry_count = entry_count + 1
         end
       end
-      io.write(string.format("%s: %s [OK] - %d entries\n", symbol, map_source, entry_count))
+      io.write(string.format("%s: %s %s - %d entries\n",
+        ansicolors.bright .. symbol .. ansicolors.reset, map_source,
+        ansicolors.green .. "[OK]" .. ansicolors.reset, entry_count))
 
       table.insert(map[symbol].maps, {
         source = map_source,
@@ -389,7 +396,7 @@ local function handler(args)
 
   -- Output results
   for _, symbol in ipairs(symbols_search) do
-    io.write(string.format("%s:\n", symbol))
+    io.write(string.format("%s:\n", ansicolors.bright .. symbol .. ansicolors.reset))
     io.write(string.format("    type=%s\n", map[symbol].type))
 
     for _, map_entry in ipairs(map[symbol].maps) do
@@ -408,7 +415,8 @@ local function handler(args)
           end
 
           if entry.count and entry.count > 0 then
-            io.write(string.format("\t%d", entry.count))
+            io.write(string.format("\t%s",
+              ansicolors.green .. tostring(entry.count) .. ansicolors.reset))
           else
             io.write("\t-")
           end
@@ -427,7 +435,8 @@ local function handler(args)
 
   -- Unmatched report
   if next(unmatched) then
-    io.write("\nSymbols with unmatched values:\n")
+    io.write(string.format("\n%s\n",
+      ansicolors.yellow .. "Symbols with unmatched values:" .. ansicolors.reset))
     io.write(string.rep('-', 80) .. '\n')
 
     local grouped = {}
@@ -451,7 +460,9 @@ local function handler(args)
       local entries = grouped[symbol]
       table.sort(entries, function(a, b) return a.count > b.count end)
 
-      io.write(string.format("\n%s: %d unmatched value(s)\n", symbol, #entries))
+      io.write(string.format("\n%s: %s\n",
+        ansicolors.bright .. symbol .. ansicolors.reset,
+        ansicolors.yellow .. string.format("%d unmatched value(s)", #entries) .. ansicolors.reset))
       local limit = math.min(#entries, 5)
       for i = 1, limit do
         io.write(string.format("  %dx: %s\n", entries[i].count, entries[i].full))