updated dash
This commit is contained in:
Binary file not shown.
+142
-31
@@ -23,6 +23,89 @@ func NewCLIClient(cscliPath string) *CLIClient {
|
|||||||
return &CLIClient{cscliPath: cscliPath}
|
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
|
// Bouncers
|
||||||
// -----------------------------------------------------------------------
|
// -----------------------------------------------------------------------
|
||||||
@@ -187,18 +270,19 @@ func (c *CLIClient) HubUpdate(ctx context.Context) error {
|
|||||||
// Metrics
|
// Metrics
|
||||||
// -----------------------------------------------------------------------
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
// GetMetrics runs cscli metrics and returns parsed sections.
|
// GetMetrics runs cscli metrics and returns parsed sections plus raw output.
|
||||||
func (c *CLIClient) GetMetrics(ctx context.Context) ([]MetricsSection, error) {
|
func (c *CLIClient) GetMetrics(ctx context.Context) ([]MetricsSection, string, error) {
|
||||||
// cscli metrics does not support JSON output reliably — parse table output
|
// cscli metrics does not support JSON output reliably — parse table output
|
||||||
ctx, cancel := context.WithTimeout(ctx, 30*time.Second)
|
ctx, cancel := context.WithTimeout(ctx, 30*time.Second)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
out, err := c.run(ctx, "metrics")
|
out, err := c.run(ctx, "metrics")
|
||||||
if err != nil {
|
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.
|
// GetVersion returns cscli version information.
|
||||||
@@ -249,6 +333,7 @@ var allowedSubcommands = map[string]bool{
|
|||||||
"hub": true,
|
"hub": true,
|
||||||
"metrics": true,
|
"metrics": true,
|
||||||
"version": true,
|
"version": true,
|
||||||
|
"decisions": true,
|
||||||
}
|
}
|
||||||
|
|
||||||
// allowedActions for each subcommand.
|
// allowedActions for each subcommand.
|
||||||
@@ -336,8 +421,32 @@ func (c *CLIClient) hubAction(ctx context.Context, kind, action, name string) er
|
|||||||
return err
|
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`.
|
// 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 {
|
func parseMetricsOutput(output string) []MetricsSection {
|
||||||
var sections []MetricsSection
|
var sections []MetricsSection
|
||||||
var current *MetricsSection
|
var current *MetricsSection
|
||||||
@@ -346,44 +455,42 @@ func parseMetricsOutput(output string) []MetricsSection {
|
|||||||
scanner := bufio.NewScanner(strings.NewReader(output))
|
scanner := bufio.NewScanner(strings.NewReader(output))
|
||||||
for scanner.Scan() {
|
for scanner.Scan() {
|
||||||
line := scanner.Text()
|
line := scanner.Text()
|
||||||
|
trimmed := strings.TrimSpace(line)
|
||||||
|
|
||||||
// Section title lines — plain text before the table
|
// Skip blank lines and log-prefix lines
|
||||||
if !strings.Contains(line, "│") && !strings.Contains(line, "─") &&
|
if trimmed == "" || strings.HasPrefix(trimmed, "time=") || strings.HasPrefix(trimmed, "level=") {
|
||||||
strings.TrimSpace(line) != "" && !strings.HasPrefix(strings.TrimSpace(line), "time=") {
|
|
||||||
if current != nil && len(current.Rows) > 0 {
|
|
||||||
sections = append(sections, *current)
|
|
||||||
}
|
|
||||||
current = &MetricsSection{Title: strings.TrimSpace(line)}
|
|
||||||
headerFound = false
|
|
||||||
continue
|
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 {
|
if current == nil {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
// Header row (contains │ and no numbers)
|
|
||||||
if strings.Contains(line, "│") && !headerFound {
|
|
||||||
parts := splitTableRow(line)
|
parts := splitTableRow(line)
|
||||||
if len(parts) > 0 {
|
if len(parts) == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !headerFound {
|
||||||
current.Headers = parts
|
current.Headers = parts
|
||||||
headerFound = true
|
headerFound = true
|
||||||
}
|
} else {
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
// Separator lines
|
|
||||||
if strings.Contains(line, "─") {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
// Data rows
|
|
||||||
if strings.Contains(line, "│") && headerFound {
|
|
||||||
parts := splitTableRow(line)
|
|
||||||
if len(parts) > 0 {
|
|
||||||
current.Rows = append(current.Rows, parts)
|
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 {
|
if current != nil && len(current.Rows) > 0 {
|
||||||
@@ -394,7 +501,11 @@ func parseMetricsOutput(output string) []MetricsSection {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func splitTableRow(line string) []string {
|
func splitTableRow(line string) []string {
|
||||||
parts := strings.Split(line, "│")
|
sep := "│"
|
||||||
|
if !strings.ContainsRune(line, '│') {
|
||||||
|
sep = "|"
|
||||||
|
}
|
||||||
|
parts := strings.Split(line, sep)
|
||||||
var cells []string
|
var cells []string
|
||||||
for _, p := range parts {
|
for _, p := range parts {
|
||||||
trimmed := strings.TrimSpace(p)
|
trimmed := strings.TrimSpace(p)
|
||||||
|
|||||||
+23
-10
@@ -113,7 +113,9 @@ type DecisionFilter struct {
|
|||||||
// ListDecisions returns decisions matching the filter.
|
// ListDecisions returns decisions matching the filter.
|
||||||
func (c *LAPIClient) ListDecisions(ctx context.Context, f DecisionFilter) ([]Decision, error) {
|
func (c *LAPIClient) ListDecisions(ctx context.Context, f DecisionFilter) ([]Decision, error) {
|
||||||
q := url.Values{}
|
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 {
|
if f.Offset > 0 {
|
||||||
q.Set("offset", strconv.Itoa(f.Offset))
|
q.Set("offset", strconv.Itoa(f.Offset))
|
||||||
}
|
}
|
||||||
@@ -210,11 +212,15 @@ type AlertFilter struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ListAlerts returns alerts matching the filter.
|
// 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) {
|
func (c *LAPIClient) ListAlerts(ctx context.Context, f AlertFilter) ([]Alert, error) {
|
||||||
q := url.Values{}
|
q := url.Values{}
|
||||||
q.Set("limit", strconv.Itoa(max(f.Limit, 100)))
|
fetchLimit := f.Limit
|
||||||
if f.Offset > 0 {
|
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 != "" {
|
if f.Scenario != "" {
|
||||||
q.Set("scenario", 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 {
|
if alerts == nil {
|
||||||
alerts = []Alert{}
|
alerts = []Alert{}
|
||||||
}
|
}
|
||||||
|
if f.Offset > 0 {
|
||||||
|
if f.Offset >= len(alerts) {
|
||||||
|
return []Alert{}, nil
|
||||||
|
}
|
||||||
|
alerts = alerts[f.Offset:]
|
||||||
|
}
|
||||||
return alerts, nil
|
return alerts, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -279,6 +291,14 @@ func isUnauthorized(err error) bool {
|
|||||||
return false
|
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 {
|
func (c *LAPIClient) doJSONOnce(ctx context.Context, method, path string, reqBody, respBody any) error {
|
||||||
var bodyReader io.Reader
|
var bodyReader io.Reader
|
||||||
if reqBody != nil {
|
if reqBody != nil {
|
||||||
@@ -328,10 +348,3 @@ func (c *LAPIClient) setAuth(r *http.Request) {
|
|||||||
r.Header.Set("Authorization", "Bearer "+token)
|
r.Header.Set("Authorization", "Bearer "+token)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func max(a, b int) int {
|
|
||||||
if a > b {
|
|
||||||
return a
|
|
||||||
}
|
|
||||||
return b
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ type DecisionInput struct {
|
|||||||
|
|
||||||
type AlertSource struct {
|
type AlertSource struct {
|
||||||
AsName string `json:"as_name,omitempty"`
|
AsName string `json:"as_name,omitempty"`
|
||||||
AsNumber int `json:"as_number,omitempty"`
|
AsNumber string `json:"as_number,omitempty"`
|
||||||
CN string `json:"cn,omitempty"`
|
CN string `json:"cn,omitempty"`
|
||||||
IP string `json:"ip,omitempty"`
|
IP string `json:"ip,omitempty"`
|
||||||
Latitude float64 `json:"latitude,omitempty"`
|
Latitude float64 `json:"latitude,omitempty"`
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package handlers
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -20,20 +21,32 @@ func NewAlertsHandler(deps Deps) *AlertsHandler {
|
|||||||
return &AlertsHandler{deps: deps}
|
return &AlertsHandler{deps: deps}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const alertsPerPage = 50
|
||||||
|
|
||||||
// AlertsData is passed to the alerts template.
|
// AlertsData is passed to the alerts template.
|
||||||
type AlertsData struct {
|
type AlertsData struct {
|
||||||
PageData
|
PageData
|
||||||
Alerts []crowdsec.Alert
|
Alerts []crowdsec.Alert
|
||||||
Filter crowdsec.AlertFilter
|
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) {
|
func (h *AlertsHandler) List(w http.ResponseWriter, r *http.Request) {
|
||||||
ctx, cancel := context.WithTimeout(r.Context(), 15*time.Second)
|
ctx, cancel := context.WithTimeout(r.Context(), 15*time.Second)
|
||||||
defer cancel()
|
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{
|
filter := crowdsec.AlertFilter{
|
||||||
Limit: 200,
|
Limit: alertsPerPage + 1,
|
||||||
|
Offset: (page - 1) * alertsPerPage,
|
||||||
Scenario: r.URL.Query().Get("scenario"),
|
Scenario: r.URL.Query().Get("scenario"),
|
||||||
IP: r.URL.Query().Get("ip"),
|
IP: r.URL.Query().Get("ip"),
|
||||||
Since: r.URL.Query().Get("since"),
|
Since: r.URL.Query().Get("since"),
|
||||||
@@ -41,10 +54,22 @@ func (h *AlertsHandler) List(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
alerts, err := h.deps.LAPI.ListAlerts(ctx, filter)
|
alerts, err := h.deps.LAPI.ListAlerts(ctx, filter)
|
||||||
if err != nil {
|
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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
hasNext := len(alerts) > alertsPerPage
|
||||||
|
if hasNext {
|
||||||
|
alerts = alerts[:alertsPerPage]
|
||||||
|
}
|
||||||
|
|
||||||
pd := NewPageData(r, "Alerts", h.deps.CLIAvailable, h.deps.PollInterval)
|
pd := NewPageData(r, "Alerts", h.deps.CLIAvailable, h.deps.PollInterval)
|
||||||
if f := readFlash(r); f.Message != "" {
|
if f := readFlash(r); f.Message != "" {
|
||||||
pd.Flash = f
|
pd.Flash = f
|
||||||
@@ -54,6 +79,9 @@ func (h *AlertsHandler) List(w http.ResponseWriter, r *http.Request) {
|
|||||||
PageData: pd,
|
PageData: pd,
|
||||||
Alerts: alerts,
|
Alerts: alerts,
|
||||||
Filter: filter,
|
Filter: filter,
|
||||||
|
Page: page,
|
||||||
|
HasNext: hasNext,
|
||||||
|
ShowUpdates: showUpdates,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"crowdsec-dashy/internal/crowdsec"
|
"crowdsec-dashy/internal/crowdsec"
|
||||||
@@ -20,7 +21,8 @@ func NewAPIHandler(deps Deps) *APIHandler {
|
|||||||
|
|
||||||
type statsResponse struct {
|
type statsResponse struct {
|
||||||
Decisions int `json:"decisions"`
|
Decisions int `json:"decisions"`
|
||||||
Alerts int `json:"alerts"`
|
Alerts24h int `json:"alerts_24h"`
|
||||||
|
Alerts7d int `json:"alerts_7d"`
|
||||||
Bouncers int `json:"bouncers"`
|
Bouncers int `json:"bouncers"`
|
||||||
Machines int `json:"machines"`
|
Machines int `json:"machines"`
|
||||||
Healthy bool `json:"healthy"`
|
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)
|
ctx, cancel := context.WithTimeout(r.Context(), 15*time.Second)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
decisions, _ := h.deps.LAPI.ListDecisions(ctx, crowdsec.DecisionFilter{Limit: 500})
|
// Fetch 24h and 7d alert counts in parallel to minimize latency.
|
||||||
alerts, _ := h.deps.LAPI.ListAlerts(ctx, crowdsec.AlertFilter{Limit: 500})
|
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{
|
resp := statsResponse{
|
||||||
Decisions: len(decisions),
|
Alerts24h: len(alerts24h),
|
||||||
Alerts: len(alerts),
|
Alerts7d: len(alerts7d),
|
||||||
Healthy: h.deps.LAPI.IsHealthy(ctx),
|
Healthy: h.deps.LAPI.IsHealthy(ctx),
|
||||||
}
|
}
|
||||||
|
|
||||||
if h.deps.CLIAvailable {
|
if h.deps.CLIAvailable {
|
||||||
|
decisions, _ := h.deps.CLI.ListDecisions(ctx, crowdsec.DecisionFilter{Limit: 500})
|
||||||
bouncers, _ := h.deps.CLI.ListBouncers(ctx)
|
bouncers, _ := h.deps.CLI.ListBouncers(ctx)
|
||||||
machines, _ := h.deps.CLI.ListMachines(ctx)
|
machines, _ := h.deps.CLI.ListMachines(ctx)
|
||||||
|
resp.Decisions = len(decisions)
|
||||||
resp.Bouncers = len(bouncers)
|
resp.Bouncers = len(bouncers)
|
||||||
resp.Machines = len(machines)
|
resp.Machines = len(machines)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,7 +29,10 @@ func (h *DashboardHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
|||||||
ctx, cancel := context.WithTimeout(r.Context(), 15*time.Second)
|
ctx, cancel := context.WithTimeout(r.Context(), 15*time.Second)
|
||||||
defer cancel()
|
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})
|
alerts, _ := h.deps.LAPI.ListAlerts(ctx, crowdsec.AlertFilter{Limit: 10})
|
||||||
|
|
||||||
h.deps.Renderer.Render(w, "dashboard", DashboardData{
|
h.deps.Renderer.Render(w, "dashboard", DashboardData{
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package handlers
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"log"
|
||||||
"net"
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
"regexp"
|
"regexp"
|
||||||
@@ -45,6 +46,13 @@ func (h *DecisionsHandler) List(w http.ResponseWriter, r *http.Request) {
|
|||||||
page = 1
|
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{
|
filter := crowdsec.DecisionFilter{
|
||||||
Limit: decisionsPerPage + 1,
|
Limit: decisionsPerPage + 1,
|
||||||
Offset: (page - 1) * decisionsPerPage,
|
Offset: (page - 1) * decisionsPerPage,
|
||||||
@@ -54,9 +62,12 @@ func (h *DecisionsHandler) List(w http.ResponseWriter, r *http.Request) {
|
|||||||
Origin: r.URL.Query().Get("origin"),
|
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 {
|
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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -112,9 +123,8 @@ func (h *DecisionsHandler) Add(w http.ResponseWriter, r *http.Request) {
|
|||||||
ctx, cancel := context.WithTimeout(r.Context(), 15*time.Second)
|
ctx, cancel := context.WithTimeout(r.Context(), 15*time.Second)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
if err := h.deps.LAPI.AddDecision(ctx, crowdsec.DecisionInput{
|
if err := h.deps.CLI.AddDecision(ctx, crowdsec.DecisionInput{
|
||||||
Duration: duration,
|
Duration: duration,
|
||||||
Origin: "cscli",
|
|
||||||
Scenario: scenario,
|
Scenario: scenario,
|
||||||
Scope: scope,
|
Scope: scope,
|
||||||
Type: decType,
|
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))
|
errs = append(errs, fmt.Sprintf("invalid id: %q", s))
|
||||||
continue
|
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))
|
errs = append(errs, fmt.Sprintf("id %d: %v", id, err))
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,21 +22,23 @@ func NewMetricsHandler(deps Deps) *MetricsHandler {
|
|||||||
type MetricsData struct {
|
type MetricsData struct {
|
||||||
PageData
|
PageData
|
||||||
Sections []crowdsec.MetricsSection
|
Sections []crowdsec.MetricsSection
|
||||||
|
RawOutput string
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *MetricsHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
func (h *MetricsHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||||
pd := NewPageData(r, "Metrics", h.deps.CLIAvailable, h.deps.PollInterval)
|
pd := NewPageData(r, "Metrics", h.deps.CLIAvailable, h.deps.PollInterval)
|
||||||
|
|
||||||
var sections []crowdsec.MetricsSection
|
var sections []crowdsec.MetricsSection
|
||||||
|
var raw string
|
||||||
if h.deps.CLIAvailable {
|
if h.deps.CLIAvailable {
|
||||||
ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second)
|
ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
var err error
|
var err error
|
||||||
sections, err = h.deps.CLI.GetMetrics(ctx)
|
sections, raw, err = h.deps.CLI.GetMetrics(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
pd.Flash = FlashMessage{Type: "error", Message: fmt.Sprintf("cscli error: %v", err)}
|
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})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import (
|
|||||||
"io/fs"
|
"io/fs"
|
||||||
"log"
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"net/url"
|
||||||
"path"
|
"path"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
@@ -112,6 +113,28 @@ func buildFuncMap() template.FuncMap {
|
|||||||
return template.FuncMap{
|
return template.FuncMap{
|
||||||
"inc": func(i int) int { return i + 1 },
|
"inc": func(i int) int { return i + 1 },
|
||||||
"dec": 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.
|
// dict builds a map for passing multiple values to a sub-template.
|
||||||
// Usage: {{template "foo" dict "Key1" val1 "Key2" val2}}
|
// Usage: {{template "foo" dict "Key1" val1 "Key2" val2}}
|
||||||
"dict": func(pairs ...any) (map[string]any, error) {
|
"dict": func(pairs ...any) (map[string]any, error) {
|
||||||
|
|||||||
@@ -5,7 +5,8 @@
|
|||||||
var cliAvailable = !!window._cliAvailable;
|
var cliAvailable = !!window._cliAvailable;
|
||||||
|
|
||||||
var elDecisions = document.getElementById('stat-decisions');
|
var elDecisions = document.getElementById('stat-decisions');
|
||||||
var elAlerts = document.getElementById('stat-alerts');
|
var elAlerts24h = document.getElementById('stat-alerts-24h');
|
||||||
|
var elAlerts7d = document.getElementById('stat-alerts-7d');
|
||||||
var elBouncers = document.getElementById('stat-bouncers');
|
var elBouncers = document.getElementById('stat-bouncers');
|
||||||
var elMachines = document.getElementById('stat-machines');
|
var elMachines = document.getElementById('stat-machines');
|
||||||
|
|
||||||
@@ -32,7 +33,8 @@
|
|||||||
})
|
})
|
||||||
.then(function (d) {
|
.then(function (d) {
|
||||||
animateTo(elDecisions, d.decisions);
|
animateTo(elDecisions, d.decisions);
|
||||||
animateTo(elAlerts, d.alerts);
|
animateTo(elAlerts24h, d.alerts_24h);
|
||||||
|
animateTo(elAlerts7d, d.alerts_7d);
|
||||||
if (cliAvailable) {
|
if (cliAvailable) {
|
||||||
animateTo(elBouncers, d.bouncers);
|
animateTo(elBouncers, d.bouncers);
|
||||||
animateTo(elMachines, d.machines);
|
animateTo(elMachines, d.machines);
|
||||||
|
|||||||
@@ -9,7 +9,7 @@
|
|||||||
|
|
||||||
<div class="panel" style="margin-bottom:16px">
|
<div class="panel" style="margin-bottom:16px">
|
||||||
<div class="panel-body" style="padding:12px 18px">
|
<div class="panel-body" style="padding:12px 18px">
|
||||||
<form method="GET" action="/alerts" class="filter-bar">
|
<form method="GET" action="/alerts" class="filter-bar" id="filter-form">
|
||||||
<input class="filter-input" type="text" name="scenario" placeholder="Scenario filter..." value="{{.Filter.Scenario}}">
|
<input class="filter-input" type="text" name="scenario" placeholder="Scenario filter..." value="{{.Filter.Scenario}}">
|
||||||
<input class="filter-input" type="text" name="ip" placeholder="Source IP..." value="{{.Filter.IP}}">
|
<input class="filter-input" type="text" name="ip" placeholder="Source IP..." value="{{.Filter.IP}}">
|
||||||
<select class="filter-select" name="since">
|
<select class="filter-select" name="since">
|
||||||
@@ -18,8 +18,9 @@
|
|||||||
<option value="24h" {{if eq .Filter.Since "24h"}}selected{{end}}>Last 24h</option>
|
<option value="24h" {{if eq .Filter.Since "24h"}}selected{{end}}>Last 24h</option>
|
||||||
<option value="7d" {{if eq .Filter.Since "7d"}}selected{{end}}>Last 7d</option>
|
<option value="7d" {{if eq .Filter.Since "7d"}}selected{{end}}>Last 7d</option>
|
||||||
</select>
|
</select>
|
||||||
|
{{if .ShowUpdates}}<input type="hidden" name="show_updates" value="1">{{end}}
|
||||||
<button type="submit" class="btn-secondary">Filter</button>
|
<button type="submit" class="btn-secondary">Filter</button>
|
||||||
{{if or .Filter.Scenario .Filter.IP .Filter.Since}}<a href="/alerts" class="btn-ghost">Clear</a>{{end}}
|
{{if or .Filter.Scenario .Filter.IP .Filter.Since}}<a href="/alerts{{if .ShowUpdates}}?show_updates=1{{end}}" class="btn-ghost">Clear</a>{{end}}
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -28,8 +29,9 @@
|
|||||||
<form method="POST" action="/alerts/delete" id="bulk-form">
|
<form method="POST" action="/alerts/delete" id="bulk-form">
|
||||||
<input type="hidden" name="_csrf" value="{{.CSRFToken}}">
|
<input type="hidden" name="_csrf" value="{{.CSRFToken}}">
|
||||||
<div class="panel-header">
|
<div class="panel-header">
|
||||||
<span class="panel-title">Alerts ({{len .Alerts}} shown)</span>
|
<span class="panel-title">Alerts <span id="visible-count"></span></span>
|
||||||
<div style="display:flex;gap:8px;align-items:center">
|
<div style="display:flex;gap:8px;align-items:center">
|
||||||
|
<button type="button" class="btn-ghost-sm" id="toggle-updates" onclick="toggleUpdates()">{{if .ShowUpdates}}Hide updates{{else}}Show updates{{end}}</button>
|
||||||
<button type="button" class="btn-ghost-sm" onclick="toggleAll()">Select all</button>
|
<button type="button" class="btn-ghost-sm" onclick="toggleAll()">Select all</button>
|
||||||
<button type="submit" class="btn-danger-sm" onclick="return confirmBulkDelete()">Delete selected</button>
|
<button type="submit" class="btn-danger-sm" onclick="return confirmBulkDelete()">Delete selected</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -51,12 +53,12 @@
|
|||||||
<th>Action</th>
|
<th>Action</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody id="alerts-tbody">
|
||||||
{{range .Alerts}}
|
{{range .Alerts}}
|
||||||
<tr>
|
<tr data-scenario="{{.Scenario}}">
|
||||||
<td><input type="checkbox" name="id" value="{{.ID}}" class="row-chk"></td>
|
<td><input type="checkbox" name="id" value="{{.ID}}" class="row-chk"></td>
|
||||||
<td style="font-family:'JetBrains Mono',monospace;font-size:12px">{{.ID}}</td>
|
<td style="font-family:'JetBrains Mono',monospace;font-size:12px">{{.ID}}</td>
|
||||||
<td style="font-size:12px" title="{{.Scenario}}">{{truncate .Scenario 36}}</td>
|
<td style="font-size:12px" title="{{.Scenario}}">{{truncate .Scenario 40}}</td>
|
||||||
<td style="font-family:'JetBrains Mono',monospace;font-size:12px">{{.Source.Value}}</td>
|
<td style="font-family:'JetBrains Mono',monospace;font-size:12px">{{.Source.Value}}</td>
|
||||||
<td style="font-size:12px;color:var(--muted)">{{.Source.CN}}</td>
|
<td style="font-size:12px;color:var(--muted)">{{.Source.CN}}</td>
|
||||||
<td style="font-family:'JetBrains Mono',monospace">{{.EventsCount}}</td>
|
<td style="font-family:'JetBrains Mono',monospace">{{.EventsCount}}</td>
|
||||||
@@ -74,6 +76,19 @@
|
|||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div style="display:flex;align-items:center;justify-content:space-between;padding:12px 18px;border-top:1px solid var(--border)">
|
||||||
|
<div style="font-size:12px;color:var(--muted)">Page {{.Page}} · {{len .Alerts}} per page</div>
|
||||||
|
<div style="display:flex;gap:8px">
|
||||||
|
{{if gt .Page 1}}
|
||||||
|
<a href="{{alertsPageURL .Filter .Page false .ShowUpdates}}" class="btn-ghost-sm">Previous</a>
|
||||||
|
{{end}}
|
||||||
|
{{if .HasNext}}
|
||||||
|
<a href="{{alertsPageURL .Filter .Page true .ShowUpdates}}" class="btn-ghost-sm">Next</a>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{{else}}
|
{{else}}
|
||||||
<div class="empty-state">
|
<div class="empty-state">
|
||||||
<div class="empty-text">No alerts found</div>
|
<div class="empty-text">No alerts found</div>
|
||||||
@@ -87,6 +102,44 @@
|
|||||||
|
|
||||||
{{define "scripts"}}
|
{{define "scripts"}}
|
||||||
<script>
|
<script>
|
||||||
|
var showUpdates = {{if .ShowUpdates}}true{{else}}false{{end}};
|
||||||
|
|
||||||
|
function applyFilter() {
|
||||||
|
var rows = document.querySelectorAll('#alerts-tbody tr');
|
||||||
|
var visible = 0;
|
||||||
|
rows.forEach(function(row) {
|
||||||
|
var scenario = row.getAttribute('data-scenario') || '';
|
||||||
|
var isUpdate = scenario.startsWith('update :') || scenario.startsWith('update:');
|
||||||
|
if (!showUpdates && isUpdate) {
|
||||||
|
row.style.display = 'none';
|
||||||
|
} else {
|
||||||
|
row.style.display = '';
|
||||||
|
visible++;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
var el = document.getElementById('visible-count');
|
||||||
|
if (el) el.textContent = '(' + visible + ' shown)';
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleUpdates() {
|
||||||
|
showUpdates = !showUpdates;
|
||||||
|
var btn = document.getElementById('toggle-updates');
|
||||||
|
if (btn) btn.textContent = showUpdates ? 'Hide updates' : 'Show updates';
|
||||||
|
applyFilter();
|
||||||
|
// persist across pages: update filter form hidden input
|
||||||
|
var form = document.getElementById('filter-form');
|
||||||
|
var existing = form.querySelector('input[name="show_updates"]');
|
||||||
|
if (showUpdates) {
|
||||||
|
if (!existing) {
|
||||||
|
var inp = document.createElement('input');
|
||||||
|
inp.type = 'hidden'; inp.name = 'show_updates'; inp.value = '1';
|
||||||
|
form.appendChild(inp);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (existing) existing.remove();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function selectAll(cb) {
|
function selectAll(cb) {
|
||||||
document.querySelectorAll('.row-chk').forEach(function(c) { c.checked = cb.checked; });
|
document.querySelectorAll('.row-chk').forEach(function(c) { c.checked = cb.checked; });
|
||||||
}
|
}
|
||||||
@@ -100,5 +153,7 @@ function confirmBulkDelete() {
|
|||||||
if (n === 0) { alert('Select at least one alert.'); return false; }
|
if (n === 0) { alert('Select at least one alert.'); return false; }
|
||||||
return confirm('Delete ' + n + ' alert(s)?');
|
return confirm('Delete ' + n + ' alert(s)?');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
applyFilter();
|
||||||
</script>
|
</script>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|||||||
@@ -2,16 +2,21 @@
|
|||||||
{{define "content"}}
|
{{define "content"}}
|
||||||
<div style="max-width:1400px">
|
<div style="max-width:1400px">
|
||||||
|
|
||||||
<div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(220px,1fr));gap:16px;margin-bottom:24px">
|
<div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(200px,1fr));gap:16px;margin-bottom:24px">
|
||||||
<div class="stat-card stat-card--threat">
|
<div class="stat-card stat-card--threat">
|
||||||
<div class="stat-label">Active Bans</div>
|
<div class="stat-label">Active Bans</div>
|
||||||
<div class="stat-value" id="stat-decisions">—</div>
|
<div class="stat-value" id="stat-decisions">—</div>
|
||||||
<div class="stat-sub">decisions in effect</div>
|
<div class="stat-sub">decisions in effect</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="stat-card stat-card--warn">
|
<div class="stat-card stat-card--warn">
|
||||||
<div class="stat-label">Recent Alerts</div>
|
<div class="stat-label">Alerts (24h)</div>
|
||||||
<div class="stat-value" id="stat-alerts">—</div>
|
<div class="stat-value" id="stat-alerts-24h">—</div>
|
||||||
<div class="stat-sub">up to 500 counted</div>
|
<div class="stat-sub">last 24 hours</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card stat-card--warn">
|
||||||
|
<div class="stat-label">Alerts (7d)</div>
|
||||||
|
<div class="stat-value" id="stat-alerts-7d">—</div>
|
||||||
|
<div class="stat-sub">last 7 days</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="stat-card stat-card--accent">
|
<div class="stat-card stat-card--accent">
|
||||||
<div class="stat-label">Bouncers</div>
|
<div class="stat-label">Bouncers</div>
|
||||||
@@ -100,9 +105,9 @@
|
|||||||
{{end}}
|
{{end}}
|
||||||
|
|
||||||
{{define "scripts"}}
|
{{define "scripts"}}
|
||||||
<script src="/static/js/dashboard.js"></script>
|
|
||||||
<script>
|
<script>
|
||||||
window._pollInterval = {{.PollInterval}};
|
window._pollInterval = {{.PollInterval}};
|
||||||
window._cliAvailable = {{.CLIAvailable}};
|
window._cliAvailable = {{.CLIAvailable}};
|
||||||
</script>
|
</script>
|
||||||
|
<script src="/static/js/dashboard.js"></script>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|||||||
@@ -52,11 +52,20 @@
|
|||||||
</div>
|
</div>
|
||||||
{{end}}
|
{{end}}
|
||||||
{{else if .CLIAvailable}}
|
{{else if .CLIAvailable}}
|
||||||
|
{{if .RawOutput}}
|
||||||
|
<div class="panel" style="margin-bottom:16px">
|
||||||
|
<div class="panel-header"><span class="panel-title">Raw cscli metrics output</span></div>
|
||||||
|
<div class="panel-body" style="padding:12px 18px">
|
||||||
|
<pre style="font-family:'JetBrains Mono',monospace;font-size:11px;color:var(--muted);white-space:pre-wrap;word-break:break-all;max-height:600px;overflow-y:auto">{{.RawOutput}}</pre>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{{else}}
|
||||||
<div class="empty-state" style="padding:48px">
|
<div class="empty-state" style="padding:48px">
|
||||||
<div class="empty-text">No metrics available</div>
|
<div class="empty-text">No metrics available</div>
|
||||||
<div class="empty-sub">CrowdSec may not have processed any data yet</div>
|
<div class="empty-sub">CrowdSec may not have processed any data yet</div>
|
||||||
</div>
|
</div>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
{{end}}
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|||||||
Reference in New Issue
Block a user