diff --git a/crowdsec-dashy b/crowdsec-dashy index 09252ac..7e12264 100755 Binary files a/crowdsec-dashy and b/crowdsec-dashy differ diff --git a/internal/crowdsec/cli.go b/internal/crowdsec/cli.go index 1eef79c..ace4444 100644 --- a/internal/crowdsec/cli.go +++ b/internal/crowdsec/cli.go @@ -23,6 +23,89 @@ func NewCLIClient(cscliPath string) *CLIClient { return &CLIClient{cscliPath: cscliPath} } +// ----------------------------------------------------------------------- +// Decisions +// ----------------------------------------------------------------------- + +// ListDecisions returns decisions via cscli, applying filter options. +// cscli does not support offset, so pagination is handled in Go by fetching +// enough rows and slicing. Maximum fetch = page * limit + 1. +func (c *CLIClient) ListDecisions(ctx context.Context, f DecisionFilter) ([]Decision, error) { + fetchLimit := f.Limit + if f.Offset > 0 { + fetchLimit = f.Offset + f.Limit + } + + args := []string{"decisions", "list", "-o", "json"} + if fetchLimit > 0 { + args = append(args, "--limit", fmt.Sprintf("%d", fetchLimit)) + } + if f.Type != "" && safeArg.MatchString(f.Type) { + args = append(args, "--type", f.Type) + } + if f.Scope != "" && safeArg.MatchString(f.Scope) { + args = append(args, "--scope", f.Scope) + } + if f.Value != "" && safeArg.MatchString(f.Value) { + args = append(args, "--value", f.Value) + } + if f.Origin != "" && safeArg.MatchString(f.Origin) { + args = append(args, "--origin", f.Origin) + } + + out, err := c.run(ctx, args...) + if err != nil { + return nil, err + } + + // cscli returns null when no decisions exist + if len(out) == 0 || string(out) == "null\n" || string(out) == "null" { + return []Decision{}, nil + } + + var decisions []Decision + if err := json.Unmarshal(out, &decisions); err != nil { + return nil, fmt.Errorf("parse decisions: %w\noutput: %s", err, string(out)) + } + + // apply Go-side offset slice + if f.Offset > 0 { + if f.Offset >= len(decisions) { + return []Decision{}, nil + } + decisions = decisions[f.Offset:] + } + + return decisions, nil +} + +// AddDecision adds a manual decision via cscli. +func (c *CLIClient) AddDecision(ctx context.Context, d DecisionInput) error { + args := []string{"decisions", "add", "--duration", d.Duration, "--type", d.Type} + + switch d.Scope { + case "Ip": + args = append(args, "--ip", d.Value) + case "Range": + args = append(args, "--range", d.Value) + default: + args = append(args, "--scope", d.Scope, "--value", d.Value) + } + + if d.Scenario != "" && safeArg.MatchString(d.Scenario) { + args = append(args, "--reason", d.Scenario) + } + + _, err := c.run(ctx, args...) + return err +} + +// DeleteDecision removes a decision by ID via cscli. +func (c *CLIClient) DeleteDecision(ctx context.Context, id int64) error { + _, err := c.run(ctx, "decisions", "delete", "--id", fmt.Sprintf("%d", id)) + return err +} + // ----------------------------------------------------------------------- // Bouncers // ----------------------------------------------------------------------- @@ -187,18 +270,19 @@ func (c *CLIClient) HubUpdate(ctx context.Context) error { // Metrics // ----------------------------------------------------------------------- -// GetMetrics runs cscli metrics and returns parsed sections. -func (c *CLIClient) GetMetrics(ctx context.Context) ([]MetricsSection, error) { +// GetMetrics runs cscli metrics and returns parsed sections plus raw output. +func (c *CLIClient) GetMetrics(ctx context.Context) ([]MetricsSection, string, error) { // cscli metrics does not support JSON output reliably — parse table output ctx, cancel := context.WithTimeout(ctx, 30*time.Second) defer cancel() out, err := c.run(ctx, "metrics") if err != nil { - return nil, err + return nil, "", err } - return parseMetricsOutput(string(out)), nil + raw := string(out) + return parseMetricsOutput(raw), raw, nil } // GetVersion returns cscli version information. @@ -240,15 +324,16 @@ func (c *CLIClient) run(ctx context.Context, args ...string) ([]byte, error) { // allowedSubcommands is the strict allow-list of cscli first arguments. var allowedSubcommands = map[string]bool{ - "bouncers": true, - "machines": true, - "collections": true, - "parsers": true, - "scenarios": true, + "bouncers": true, + "machines": true, + "collections": true, + "parsers": true, + "scenarios": true, "postoverflows": true, - "hub": true, - "metrics": true, - "version": true, + "hub": true, + "metrics": true, + "version": true, + "decisions": true, } // allowedActions for each subcommand. @@ -336,8 +421,32 @@ func (c *CLIClient) hubAction(ctx context.Context, kind, action, name string) er return err } +// colSepInLine reports whether a line contains a column separator (Unicode │ or ASCII |). +func colSepInLine(s string) bool { + return strings.ContainsRune(s, '│') || strings.ContainsRune(s, '|') +} + +// isTableBorder reports whether a line consists entirely of border/line characters +// (handles both Unicode box-drawing and ASCII +/-/| borders). +func isTableBorder(s string) bool { + t := strings.TrimSpace(s) + if len(t) < 3 { + return false + } + for _, r := range t { + switch r { + case '─', '-', '+', '│', '|', '╭', '╮', '╰', '╯', + '├', '┤', '┬', '┴', '┼', ' ': + // border char — ok + default: + return false + } + } + return true +} + // parseMetricsOutput parses the tabular output of `cscli metrics`. -// It looks for lines that look like table headers (─── separators). +// Handles both Unicode box-drawing (╭│─╮) and ASCII (+|-) table borders. func parseMetricsOutput(output string) []MetricsSection { var sections []MetricsSection var current *MetricsSection @@ -346,44 +455,42 @@ func parseMetricsOutput(output string) []MetricsSection { scanner := bufio.NewScanner(strings.NewReader(output)) for scanner.Scan() { line := scanner.Text() + trimmed := strings.TrimSpace(line) - // Section title lines — plain text before the table - if !strings.Contains(line, "│") && !strings.Contains(line, "─") && - strings.TrimSpace(line) != "" && !strings.HasPrefix(strings.TrimSpace(line), "time=") { - if current != nil && len(current.Rows) > 0 { - sections = append(sections, *current) + // Skip blank lines and log-prefix lines + if trimmed == "" || strings.HasPrefix(trimmed, "time=") || strings.HasPrefix(trimmed, "level=") { + continue + } + + // Skip pure border/separator rows + if isTableBorder(line) { + continue + } + + // Lines with a column separator are header or data rows + if colSepInLine(line) { + if current == nil { + continue } - current = &MetricsSection{Title: strings.TrimSpace(line)} - headerFound = false - continue - } - - if current == nil { - continue - } - - // Header row (contains │ and no numbers) - if strings.Contains(line, "│") && !headerFound { parts := splitTableRow(line) - if len(parts) > 0 { + if len(parts) == 0 { + continue + } + if !headerFound { current.Headers = parts headerFound = true - } - continue - } - - // Separator lines - if strings.Contains(line, "─") { - continue - } - - // Data rows - if strings.Contains(line, "│") && headerFound { - parts := splitTableRow(line) - if len(parts) > 0 { + } else { current.Rows = append(current.Rows, parts) } + continue } + + // Plain non-empty text → section title + if current != nil && len(current.Rows) > 0 { + sections = append(sections, *current) + } + current = &MetricsSection{Title: trimmed} + headerFound = false } if current != nil && len(current.Rows) > 0 { @@ -394,7 +501,11 @@ func parseMetricsOutput(output string) []MetricsSection { } func splitTableRow(line string) []string { - parts := strings.Split(line, "│") + sep := "│" + if !strings.ContainsRune(line, '│') { + sep = "|" + } + parts := strings.Split(line, sep) var cells []string for _, p := range parts { trimmed := strings.TrimSpace(p) diff --git a/internal/crowdsec/lapi.go b/internal/crowdsec/lapi.go index 1ce24b2..8db9854 100644 --- a/internal/crowdsec/lapi.go +++ b/internal/crowdsec/lapi.go @@ -113,7 +113,9 @@ type DecisionFilter struct { // ListDecisions returns decisions matching the filter. func (c *LAPIClient) ListDecisions(ctx context.Context, f DecisionFilter) ([]Decision, error) { q := url.Values{} - q.Set("limit", strconv.Itoa(max(f.Limit, 100))) + if f.Limit > 0 { + q.Set("limit", strconv.Itoa(f.Limit)) + } if f.Offset > 0 { q.Set("offset", strconv.Itoa(f.Offset)) } @@ -210,11 +212,15 @@ type AlertFilter struct { } // ListAlerts returns alerts matching the filter. +// LAPI has no offset param — we fetch offset+limit items and slice in Go. func (c *LAPIClient) ListAlerts(ctx context.Context, f AlertFilter) ([]Alert, error) { q := url.Values{} - q.Set("limit", strconv.Itoa(max(f.Limit, 100))) + fetchLimit := f.Limit if f.Offset > 0 { - q.Set("offset", strconv.Itoa(f.Offset)) + fetchLimit = f.Offset + f.Limit + } + if fetchLimit > 0 { + q.Set("limit", strconv.Itoa(fetchLimit)) } if f.Scenario != "" { q.Set("scenario", f.Scenario) @@ -234,6 +240,12 @@ func (c *LAPIClient) ListAlerts(ctx context.Context, f AlertFilter) ([]Alert, er if alerts == nil { alerts = []Alert{} } + if f.Offset > 0 { + if f.Offset >= len(alerts) { + return []Alert{}, nil + } + alerts = alerts[f.Offset:] + } return alerts, nil } @@ -279,6 +291,14 @@ func isUnauthorized(err error) bool { return false } +// IsForbidden reports whether err is a 403 response from the LAPI. +func IsForbidden(err error) bool { + if e, ok := err.(*lapiError); ok { + return e.status == http.StatusForbidden + } + return false +} + func (c *LAPIClient) doJSONOnce(ctx context.Context, method, path string, reqBody, respBody any) error { var bodyReader io.Reader if reqBody != nil { @@ -328,10 +348,3 @@ func (c *LAPIClient) setAuth(r *http.Request) { r.Header.Set("Authorization", "Bearer "+token) } } - -func max(a, b int) int { - if a > b { - return a - } - return b -} diff --git a/internal/crowdsec/types.go b/internal/crowdsec/types.go index c37f190..a400a1b 100644 --- a/internal/crowdsec/types.go +++ b/internal/crowdsec/types.go @@ -39,7 +39,7 @@ type DecisionInput struct { type AlertSource struct { AsName string `json:"as_name,omitempty"` - AsNumber int `json:"as_number,omitempty"` + AsNumber string `json:"as_number,omitempty"` CN string `json:"cn,omitempty"` IP string `json:"ip,omitempty"` Latitude float64 `json:"latitude,omitempty"` diff --git a/internal/handlers/alerts.go b/internal/handlers/alerts.go index 86caf3f..f64b765 100644 --- a/internal/handlers/alerts.go +++ b/internal/handlers/alerts.go @@ -3,6 +3,7 @@ package handlers import ( "context" "fmt" + "log" "net/http" "strconv" "strings" @@ -20,20 +21,32 @@ func NewAlertsHandler(deps Deps) *AlertsHandler { return &AlertsHandler{deps: deps} } +const alertsPerPage = 50 + // AlertsData is passed to the alerts template. type AlertsData struct { PageData - Alerts []crowdsec.Alert - Filter crowdsec.AlertFilter + Alerts []crowdsec.Alert + Filter crowdsec.AlertFilter + Page int + HasNext bool + ShowUpdates bool } -// List renders the alerts list. +// List renders the paginated alerts list. func (h *AlertsHandler) List(w http.ResponseWriter, r *http.Request) { ctx, cancel := context.WithTimeout(r.Context(), 15*time.Second) defer cancel() + page, _ := strconv.Atoi(r.URL.Query().Get("page")) + if page < 1 { + page = 1 + } + showUpdates := r.URL.Query().Get("show_updates") == "1" + filter := crowdsec.AlertFilter{ - Limit: 200, + Limit: alertsPerPage + 1, + Offset: (page - 1) * alertsPerPage, Scenario: r.URL.Query().Get("scenario"), IP: r.URL.Query().Get("ip"), Since: r.URL.Query().Get("since"), @@ -41,19 +54,34 @@ func (h *AlertsHandler) List(w http.ResponseWriter, r *http.Request) { alerts, err := h.deps.LAPI.ListAlerts(ctx, filter) if err != nil { - h.deps.Renderer.RenderError(w, http.StatusBadGateway, "failed to fetch alerts") + log.Printf("alerts: LAPI error: %v", err) + pd := NewPageData(r, "Alerts", h.deps.CLIAvailable, h.deps.PollInterval) + if crowdsec.IsForbidden(err) { + pd = pd.WithFlash("error", "Access denied: re-register machine with admin rights: cscli machines delete crowdsec-dashy && cscli machines add crowdsec-dashy -a") + } else { + pd = pd.WithFlash("error", "Failed to fetch alerts from LAPI.") + } + h.deps.Renderer.Render(w, "alerts", AlertsData{PageData: pd}) return } + hasNext := len(alerts) > alertsPerPage + if hasNext { + alerts = alerts[:alertsPerPage] + } + pd := NewPageData(r, "Alerts", h.deps.CLIAvailable, h.deps.PollInterval) if f := readFlash(r); f.Message != "" { pd.Flash = f } h.deps.Renderer.Render(w, "alerts", AlertsData{ - PageData: pd, - Alerts: alerts, - Filter: filter, + PageData: pd, + Alerts: alerts, + Filter: filter, + Page: page, + HasNext: hasNext, + ShowUpdates: showUpdates, }) } diff --git a/internal/handlers/api.go b/internal/handlers/api.go index d35600e..0845ca2 100644 --- a/internal/handlers/api.go +++ b/internal/handlers/api.go @@ -4,6 +4,7 @@ import ( "context" "encoding/json" "net/http" + "sync" "time" "crowdsec-dashy/internal/crowdsec" @@ -20,7 +21,8 @@ func NewAPIHandler(deps Deps) *APIHandler { type statsResponse struct { Decisions int `json:"decisions"` - Alerts int `json:"alerts"` + Alerts24h int `json:"alerts_24h"` + Alerts7d int `json:"alerts_7d"` Bouncers int `json:"bouncers"` Machines int `json:"machines"` Healthy bool `json:"healthy"` @@ -36,18 +38,31 @@ func (h *APIHandler) Stats(w http.ResponseWriter, r *http.Request) { ctx, cancel := context.WithTimeout(r.Context(), 15*time.Second) defer cancel() - decisions, _ := h.deps.LAPI.ListDecisions(ctx, crowdsec.DecisionFilter{Limit: 500}) - alerts, _ := h.deps.LAPI.ListAlerts(ctx, crowdsec.AlertFilter{Limit: 500}) + // Fetch 24h and 7d alert counts in parallel to minimize latency. + var alerts24h, alerts7d []crowdsec.Alert + var wg sync.WaitGroup + wg.Add(2) + go func() { + defer wg.Done() + alerts24h, _ = h.deps.LAPI.ListAlerts(ctx, crowdsec.AlertFilter{Limit: 5000, Since: "24h"}) + }() + go func() { + defer wg.Done() + alerts7d, _ = h.deps.LAPI.ListAlerts(ctx, crowdsec.AlertFilter{Limit: 5000, Since: "168h"}) + }() + wg.Wait() resp := statsResponse{ - Decisions: len(decisions), - Alerts: len(alerts), + Alerts24h: len(alerts24h), + Alerts7d: len(alerts7d), Healthy: h.deps.LAPI.IsHealthy(ctx), } if h.deps.CLIAvailable { + decisions, _ := h.deps.CLI.ListDecisions(ctx, crowdsec.DecisionFilter{Limit: 500}) bouncers, _ := h.deps.CLI.ListBouncers(ctx) machines, _ := h.deps.CLI.ListMachines(ctx) + resp.Decisions = len(decisions) resp.Bouncers = len(bouncers) resp.Machines = len(machines) } diff --git a/internal/handlers/dashboard.go b/internal/handlers/dashboard.go index 390976d..7ec3979 100644 --- a/internal/handlers/dashboard.go +++ b/internal/handlers/dashboard.go @@ -29,7 +29,10 @@ func (h *DashboardHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { ctx, cancel := context.WithTimeout(r.Context(), 15*time.Second) defer cancel() - decisions, _ := h.deps.LAPI.ListDecisions(ctx, crowdsec.DecisionFilter{Limit: 10}) + var decisions []crowdsec.Decision + if h.deps.CLIAvailable { + decisions, _ = h.deps.CLI.ListDecisions(ctx, crowdsec.DecisionFilter{Limit: 10}) + } alerts, _ := h.deps.LAPI.ListAlerts(ctx, crowdsec.AlertFilter{Limit: 10}) h.deps.Renderer.Render(w, "dashboard", DashboardData{ diff --git a/internal/handlers/decisions.go b/internal/handlers/decisions.go index 0c1e249..da44f82 100644 --- a/internal/handlers/decisions.go +++ b/internal/handlers/decisions.go @@ -3,6 +3,7 @@ package handlers import ( "context" "fmt" + "log" "net" "net/http" "regexp" @@ -45,6 +46,13 @@ func (h *DecisionsHandler) List(w http.ResponseWriter, r *http.Request) { page = 1 } + if !h.deps.CLIAvailable { + pd := NewPageData(r, "Decisions", h.deps.CLIAvailable, h.deps.PollInterval) + pd = pd.WithFlash("error", "cscli is required for decisions management but is not available.") + h.deps.Renderer.Render(w, "decisions", DecisionsData{PageData: pd}) + return + } + filter := crowdsec.DecisionFilter{ Limit: decisionsPerPage + 1, Offset: (page - 1) * decisionsPerPage, @@ -54,9 +62,12 @@ func (h *DecisionsHandler) List(w http.ResponseWriter, r *http.Request) { Origin: r.URL.Query().Get("origin"), } - decisions, err := h.deps.LAPI.ListDecisions(ctx, filter) + decisions, err := h.deps.CLI.ListDecisions(ctx, filter) if err != nil { - h.deps.Renderer.RenderError(w, http.StatusBadGateway, "failed to fetch decisions") + log.Printf("decisions: CLI error: %v", err) + pd := NewPageData(r, "Decisions", h.deps.CLIAvailable, h.deps.PollInterval) + pd = pd.WithFlash("error", "Failed to fetch decisions via cscli.") + h.deps.Renderer.Render(w, "decisions", DecisionsData{PageData: pd}) return } @@ -112,9 +123,8 @@ func (h *DecisionsHandler) Add(w http.ResponseWriter, r *http.Request) { ctx, cancel := context.WithTimeout(r.Context(), 15*time.Second) defer cancel() - if err := h.deps.LAPI.AddDecision(ctx, crowdsec.DecisionInput{ + if err := h.deps.CLI.AddDecision(ctx, crowdsec.DecisionInput{ Duration: duration, - Origin: "cscli", Scenario: scenario, Scope: scope, Type: decType, @@ -157,7 +167,7 @@ func (h *DecisionsHandler) Delete(w http.ResponseWriter, r *http.Request) { errs = append(errs, fmt.Sprintf("invalid id: %q", s)) continue } - if err := h.deps.LAPI.DeleteDecision(ctx, id); err != nil { + if err := h.deps.CLI.DeleteDecision(ctx, id); err != nil { errs = append(errs, fmt.Sprintf("id %d: %v", id, err)) continue } diff --git a/internal/handlers/metrics.go b/internal/handlers/metrics.go index 44cf48f..473c073 100644 --- a/internal/handlers/metrics.go +++ b/internal/handlers/metrics.go @@ -21,22 +21,24 @@ func NewMetricsHandler(deps Deps) *MetricsHandler { // MetricsData is passed to the metrics template. type MetricsData struct { PageData - Sections []crowdsec.MetricsSection + Sections []crowdsec.MetricsSection + RawOutput string } func (h *MetricsHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { pd := NewPageData(r, "Metrics", h.deps.CLIAvailable, h.deps.PollInterval) var sections []crowdsec.MetricsSection + var raw string if h.deps.CLIAvailable { ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second) defer cancel() var err error - sections, err = h.deps.CLI.GetMetrics(ctx) + sections, raw, err = h.deps.CLI.GetMetrics(ctx) if err != nil { pd.Flash = FlashMessage{Type: "error", Message: fmt.Sprintf("cscli error: %v", err)} } } - h.deps.Renderer.Render(w, "metrics", MetricsData{PageData: pd, Sections: sections}) + h.deps.Renderer.Render(w, "metrics", MetricsData{PageData: pd, Sections: sections, RawOutput: raw}) } diff --git a/internal/handlers/renderer.go b/internal/handlers/renderer.go index c13b478..c7eaaaf 100644 --- a/internal/handlers/renderer.go +++ b/internal/handlers/renderer.go @@ -8,6 +8,7 @@ import ( "io/fs" "log" "net/http" + "net/url" "path" "strings" @@ -112,6 +113,28 @@ func buildFuncMap() template.FuncMap { return template.FuncMap{ "inc": func(i int) int { return i + 1 }, "dec": func(i int) int { return i - 1 }, + // alertsPageURL builds the prev/next link for the alerts page. + "alertsPageURL": func(f crowdsec.AlertFilter, page int, next bool, showUpdates bool) string { + p := page - 1 + if next { + p = page + 1 + } + q := url.Values{} + q.Set("page", fmt.Sprintf("%d", p)) + if f.Scenario != "" { + q.Set("scenario", f.Scenario) + } + if f.IP != "" { + q.Set("ip", f.IP) + } + if f.Since != "" { + q.Set("since", f.Since) + } + if showUpdates { + q.Set("show_updates", "1") + } + return "/alerts?" + q.Encode() + }, // dict builds a map for passing multiple values to a sub-template. // Usage: {{template "foo" dict "Key1" val1 "Key2" val2}} "dict": func(pairs ...any) (map[string]any, error) { diff --git a/web/static/js/dashboard.js b/web/static/js/dashboard.js index e28544b..4f16f2c 100644 --- a/web/static/js/dashboard.js +++ b/web/static/js/dashboard.js @@ -4,10 +4,11 @@ var pollInterval = (window._pollInterval || 15) * 1000; var cliAvailable = !!window._cliAvailable; - var elDecisions = document.getElementById('stat-decisions'); - var elAlerts = document.getElementById('stat-alerts'); - var elBouncers = document.getElementById('stat-bouncers'); - var elMachines = document.getElementById('stat-machines'); + var elDecisions = document.getElementById('stat-decisions'); + var elAlerts24h = document.getElementById('stat-alerts-24h'); + var elAlerts7d = document.getElementById('stat-alerts-7d'); + var elBouncers = document.getElementById('stat-bouncers'); + var elMachines = document.getElementById('stat-machines'); function animateTo(el, newVal) { if (!el) return; @@ -32,7 +33,8 @@ }) .then(function (d) { animateTo(elDecisions, d.decisions); - animateTo(elAlerts, d.alerts); + animateTo(elAlerts24h, d.alerts_24h); + animateTo(elAlerts7d, d.alerts_7d); if (cliAvailable) { animateTo(elBouncers, d.bouncers); animateTo(elMachines, d.machines); diff --git a/web/templates/pages/alerts.html b/web/templates/pages/alerts.html index a4a47ec..9f02bbc 100644 --- a/web/templates/pages/alerts.html +++ b/web/templates/pages/alerts.html @@ -9,7 +9,7 @@
-
+ + {{if .ShowUpdates}}{{end}} - {{if or .Filter.Scenario .Filter.IP .Filter.Since}}Clear{{end}} + {{if or .Filter.Scenario .Filter.IP .Filter.Since}}Clear{{end}}
@@ -28,8 +29,9 @@
- Alerts ({{len .Alerts}} shown) + Alerts
+
@@ -51,12 +53,12 @@ Action - + {{range .Alerts}} - + {{.ID}} - {{truncate .Scenario 36}} + {{truncate .Scenario 40}} {{.Source.Value}} {{.Source.CN}} {{.EventsCount}} @@ -74,6 +76,19 @@
+ +
+
Page {{.Page}} · {{len .Alerts}} per page
+
+ {{if gt .Page 1}} + Previous + {{end}} + {{if .HasNext}} + Next + {{end}} +
+
+ {{else}}
No alerts found
@@ -87,6 +102,44 @@ {{define "scripts"}} {{end}} diff --git a/web/templates/pages/dashboard.html b/web/templates/pages/dashboard.html index bdfe84e..9b45942 100644 --- a/web/templates/pages/dashboard.html +++ b/web/templates/pages/dashboard.html @@ -2,16 +2,21 @@ {{define "content"}}
-
+
Active Bans
decisions in effect
-
Recent Alerts
-
-
up to 500 counted
+
Alerts (24h)
+
+
last 24 hours
+
+
+
Alerts (7d)
+
+
last 7 days
Bouncers
@@ -100,9 +105,9 @@ {{end}} {{define "scripts"}} - + {{end}} diff --git a/web/templates/pages/metrics.html b/web/templates/pages/metrics.html index 8f71e1b..a1dc013 100644 --- a/web/templates/pages/metrics.html +++ b/web/templates/pages/metrics.html @@ -52,11 +52,20 @@
{{end}} {{else if .CLIAvailable}} + {{if .RawOutput}} +
+
Raw cscli metrics output
+
+
{{.RawOutput}}
+
+
+ {{else}}
No metrics available
CrowdSec may not have processed any data yet
{{end}} + {{end}}
{{end}}