Skip to content

Commit e357e44

Browse files
committed
Support manual decisions parsing & fix HubBrowser
Add a reusable parseDecisionNode helper and fallback parsing for manual decisions that may appear on the top-level alert object (not only inside a decisions array), fixing missing entries in Decisions UI and Decisions Analysis. Import jsonparser and set AlertID/CreatedAt appropriately when falling back. Also remove the `?? null` coercion in HubBrowser query to avoid forcing valid responses to null and causing a blank page. Affected files: internal/api/handlers/common.go, internal/api/handlers/dashboard.go, internal/api/handlers/dashboard_analysis.go, web/src/pages/HubBrowser.tsx.
1 parent d44a2cb commit e357e44

5 files changed

Lines changed: 151 additions & 71 deletions

File tree

DEBUG_FINDINGS.md

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
# Debug Findings: Decisions UI & Hub Browser
2+
3+
## Issue 1: Manual Decisions Not Showing in Decisions UI
4+
5+
### Symptoms
6+
- Adding a decision via `cscli decisions add` (or the UI's "Add Decision" form) does not appear in the Decisions page
7+
- The same decision DOES appear in the Alerts page
8+
9+
### Root Cause
10+
11+
**The JSON parser in `GetDecisions` and `GetDecisionsAnalysis` assumes decisions are always nested inside alerts.**
12+
13+
The `cscli decisions list -o json` output wraps decisions inside an alerts array:
14+
```json
15+
[
16+
{
17+
"id": 1,
18+
"created_at": "...",
19+
"decisions": [
20+
{ "id": 1, "type": "ban", "value": "1.2.3.4", ... }
21+
]
22+
}
23+
]
24+
```
25+
26+
The parser uses nested `jsonparser.ArrayEach`:
27+
- Outer loop: iterates over top-level array (alerts)
28+
- Inner loop: iterates over `decisions` field within each alert
29+
30+
When `cscli decisions add` creates a manual decision, the top-level JSON entry may have the decision fields directly on the alert object (or the `decisions` sub-array may be structured differently). The nested parser silently skips entries that don't match the expected structure.
31+
32+
### Files Affected
33+
- `internal/api/handlers/dashboard.go:72-128` - `GetDecisions` parser
34+
- `internal/api/handlers/dashboard_analysis.go:78-134` - `GetDecisionsAnalysis` parser
35+
36+
### Fix
37+
Add a fallback: after the nested parsing, check if each top-level item has decision-like fields (type, value, scope) directly on it. If so, extract it as a decision directly.
38+
39+
---
40+
41+
## Issue 2: Hub Browser Shows Blank Page
42+
43+
### Symptoms
44+
- Hub Browser page loads but displays empty/no items
45+
- No error shown, just blank content
46+
47+
### Root Cause
48+
49+
**The `?? null` coercion in the query function converts valid data to null.**
50+
51+
In `web/src/pages/HubBrowser.tsx:106`:
52+
```typescript
53+
return response.data.data ?? null
54+
```
55+
56+
The `parseHubItems` function (line 56) treats `null` as "no data":
57+
```typescript
58+
if (!data) return { items: EMPTY_HUB_ITEMS, rawParseError: false }
59+
```
60+
61+
The actual issue is likely that `response.data.data` returns `undefined` or the response envelope is structured differently than expected. The `?? null` was added defensively but masks the real problem.
62+
63+
The backend `ListHubItems` (hub.go:234) returns:
64+
```go
65+
c.JSON(http.StatusOK, models.Response{
66+
Success: true,
67+
Data: parsed, // parsed JSON from cscli hub list
68+
})
69+
```
70+
71+
The frontend accesses `response.data.data` where:
72+
- `response.data` = the axios response body (the `models.Response` envelope)
73+
- `response.data.data` = the `Data` field of the envelope
74+
75+
If the `cscli hub list -o json` output structure changed, `parsed` could be a different shape than expected.
76+
77+
### Files Affected
78+
- `web/src/pages/HubBrowser.tsx:106` - query function with `?? null`
79+
- `web/src/pages/HubBrowser.tsx:55-81` - `parseHubItems` parser
80+
81+
### Fix
82+
Remove the `?? null` coercion. The `parseHubItems` function already handles undefined/null/string/object/array cases defensively. The `?? null` just forces the "no data" path unnecessarily.

internal/api/handlers/common.go

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import (
1212
"crowdsec-manager/internal/logger"
1313
"crowdsec-manager/internal/models"
1414

15+
"github.com/buger/jsonparser"
1516
"github.com/gin-gonic/gin"
1617
)
1718

@@ -199,6 +200,41 @@ func errString(err error) string {
199200
return err.Error()
200201
}
201202

203+
// parseDecisionNode extracts a models.Decision from a jsonparser byte slice.
204+
// Used by both GetDecisions and GetDecisionsAnalysis to avoid duplicating
205+
// field-extraction logic.
206+
func parseDecisionNode(data []byte) models.Decision {
207+
var d models.Decision
208+
if id, err := jsonparser.GetInt(data, "id"); err == nil {
209+
d.ID = id
210+
}
211+
if v, err := jsonparser.GetString(data, "origin"); err == nil {
212+
d.Origin = v
213+
}
214+
if v, err := jsonparser.GetString(data, "type"); err == nil {
215+
d.Type = v
216+
}
217+
if v, err := jsonparser.GetString(data, "scope"); err == nil {
218+
d.Scope = v
219+
}
220+
if v, err := jsonparser.GetString(data, "value"); err == nil {
221+
d.Value = v
222+
}
223+
if v, err := jsonparser.GetString(data, "duration"); err == nil {
224+
d.Duration = v
225+
}
226+
if v, err := jsonparser.GetString(data, "scenario"); err == nil {
227+
d.Scenario = v
228+
}
229+
if v, err := jsonparser.GetBoolean(data, "simulated"); err == nil {
230+
d.Simulated = v
231+
}
232+
if v, err := jsonparser.GetString(data, "created_at"); err == nil {
233+
d.CreatedAt = v
234+
}
235+
return d
236+
}
237+
202238
// CLIFlag represents a CLI flag and its value for building cscli commands.
203239
type CLIFlag struct {
204240
Flag string

internal/api/handlers/dashboard.go

Lines changed: 16 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -83,48 +83,29 @@ func GetDecisions(dockerClient *docker.Client, cfg *config.Config, ttlCache ...*
8383
}
8484

8585
// Parse decisions array within this alert
86+
foundNested := false
8687
jsonparser.ArrayEach(alertValue, func(decisionValue []byte, decisionType jsonparser.ValueType, decisionOffset int, decisionErr error) {
87-
var decision models.Decision
88-
89-
// Extract decision fields
90-
if id, err := jsonparser.GetInt(decisionValue, "id"); err == nil {
91-
decision.ID = id
92-
}
93-
if origin, err := jsonparser.GetString(decisionValue, "origin"); err == nil {
94-
decision.Origin = origin
95-
}
96-
if decisionType, err := jsonparser.GetString(decisionValue, "type"); err == nil {
97-
decision.Type = decisionType
98-
}
99-
if scope, err := jsonparser.GetString(decisionValue, "scope"); err == nil {
100-
decision.Scope = scope
101-
}
102-
if value, err := jsonparser.GetString(decisionValue, "value"); err == nil {
103-
decision.Value = value
104-
}
105-
if duration, err := jsonparser.GetString(decisionValue, "duration"); err == nil {
106-
decision.Duration = duration
107-
}
108-
if scenario, err := jsonparser.GetString(decisionValue, "scenario"); err == nil {
109-
decision.Scenario = scenario
110-
}
111-
if simulated, err := jsonparser.GetBoolean(decisionValue, "simulated"); err == nil {
112-
decision.Simulated = simulated
113-
}
114-
if createdAt, err := jsonparser.GetString(decisionValue, "created_at"); err == nil {
115-
decision.CreatedAt = createdAt
116-
}
117-
118-
// Set created_at from alert if not present in decision
88+
foundNested = true
89+
decision := parseDecisionNode(decisionValue)
11990
if decision.CreatedAt == "" {
12091
decision.CreatedAt = alertCreatedAt
12192
}
122-
123-
// Set AlertID
12493
decision.AlertID = alertID
125-
12694
decisions = append(decisions, decision)
12795
}, "decisions")
96+
97+
// Fallback: if no nested decisions found, check if the top-level
98+
// item itself has decision fields (manual decisions via cscli decisions add)
99+
if !foundNested {
100+
if _, _, _, err := jsonparser.Get(alertValue, "type"); err == nil {
101+
decision := parseDecisionNode(alertValue)
102+
if decision.CreatedAt == "" {
103+
decision.CreatedAt = alertCreatedAt
104+
}
105+
decision.AlertID = alertID
106+
decisions = append(decisions, decision)
107+
}
108+
}
128109
})
129110

130111
if err != nil {

internal/api/handlers/dashboard_analysis.go

Lines changed: 16 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -89,48 +89,29 @@ func GetDecisionsAnalysis(dockerClient *docker.Client, cfg *config.Config) gin.H
8989
}
9090

9191
// Parse decisions array within this alert
92+
foundNested := false
9293
jsonparser.ArrayEach(alertValue, func(decisionValue []byte, decisionType jsonparser.ValueType, decisionOffset int, decisionErr error) {
93-
var decision models.Decision
94-
95-
// Extract decision fields
96-
if id, err := jsonparser.GetInt(decisionValue, "id"); err == nil {
97-
decision.ID = id
98-
}
99-
if origin, err := jsonparser.GetString(decisionValue, "origin"); err == nil {
100-
decision.Origin = origin
101-
}
102-
if decisionType, err := jsonparser.GetString(decisionValue, "type"); err == nil {
103-
decision.Type = decisionType
104-
}
105-
if scope, err := jsonparser.GetString(decisionValue, "scope"); err == nil {
106-
decision.Scope = scope
107-
}
108-
if value, err := jsonparser.GetString(decisionValue, "value"); err == nil {
109-
decision.Value = value
110-
}
111-
if duration, err := jsonparser.GetString(decisionValue, "duration"); err == nil {
112-
decision.Duration = duration
113-
}
114-
if scenario, err := jsonparser.GetString(decisionValue, "scenario"); err == nil {
115-
decision.Scenario = scenario
116-
}
117-
if simulated, err := jsonparser.GetBoolean(decisionValue, "simulated"); err == nil {
118-
decision.Simulated = simulated
119-
}
120-
if createdAt, err := jsonparser.GetString(decisionValue, "created_at"); err == nil {
121-
decision.CreatedAt = createdAt
122-
}
123-
124-
// Set created_at from alert if not present in decision
94+
foundNested = true
95+
decision := parseDecisionNode(decisionValue)
12596
if decision.CreatedAt == "" {
12697
decision.CreatedAt = alertCreatedAt
12798
}
128-
129-
// Set AlertID
13099
decision.AlertID = alertID
131-
132100
decisions = append(decisions, decision)
133101
}, "decisions")
102+
103+
// Fallback: if no nested decisions found, check if the top-level
104+
// item itself has decision fields (manual decisions via cscli decisions add)
105+
if !foundNested {
106+
if _, _, _, err := jsonparser.Get(alertValue, "type"); err == nil {
107+
decision := parseDecisionNode(alertValue)
108+
if decision.CreatedAt == "" {
109+
decision.CreatedAt = alertCreatedAt
110+
}
111+
decision.AlertID = alertID
112+
decisions = append(decisions, decision)
113+
}
114+
}
134115
})
135116

136117
if err != nil {

web/src/pages/HubBrowser.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -103,7 +103,7 @@ export default function HubBrowser() {
103103
queryKey: ['hub-items-browser'],
104104
queryFn: async () => {
105105
const response = await hubAPI.list()
106-
return response.data.data ?? null
106+
return response.data.data
107107
},
108108
})
109109

0 commit comments

Comments
 (0)