updated dash

This commit is contained in:
2026-05-17 14:12:06 +00:00
parent 317a7f3f13
commit 72c71bb95d
14 changed files with 369 additions and 93 deletions
+155 -44
View File
@@ -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)
+23 -10
View File
@@ -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
}
+1 -1
View File
@@ -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"`
+36 -8
View File
@@ -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,
})
}
+20 -5
View File
@@ -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)
}
+4 -1
View File
@@ -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{
+15 -5
View File
@@ -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
}
+5 -3
View File
@@ -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})
}
+23
View File
@@ -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) {