Skip to content

Commit 330a67e

Browse files
committed
Add TTL cache, stream logs, and refactor handlers
Introduce a simple in-memory TTL cache and integrate it across API handlers to reduce repeated cscli/docker calls and improve performance. Key changes: - Add internal/cache: a concurrency-safe TTLCache with Get/Set. - Instantiate cache in cmd/server and pass into RegisterServicesRoutes; handlers accept optional TTLCache. - Cache decisions (including a lightweight summary), metrics, and alerts analysis with short TTLs. - Add CLIFlag and appendCLIFlags helpers in handlers/common.go and refactor decision add/delete handlers to use them. - Replace file copy helper in ImportDecisions with writing file bytes into the container via WriteFileToContainer. - Add getExternalIP helper to try multiple external IP services; use it in GetPublicIP and WhitelistCurrentIP. - Add FollowContainerLogs to the Docker client and update StreamLogs to follow container logs natively (streaming ReadCloser), simplifying polling logic and reducing duplicate-send logic. - Web UI: add getDecisionsSummary API call and update Dashboard to use the summary endpoint and reduce auto-refresh frequency/labels. These changes aim to reduce load, improve responsiveness, simplify log streaming, and centralize common helpers.
1 parent 22941fb commit 330a67e

14 files changed

Lines changed: 337 additions & 212 deletions

File tree

cmd/server/main.go

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import (
1818
"crowdsec-manager/internal/api/handlers"
1919
"crowdsec-manager/internal/api/middleware"
2020
"crowdsec-manager/internal/backup"
21+
"crowdsec-manager/internal/cache"
2122
"crowdsec-manager/internal/config"
2223
"crowdsec-manager/internal/configvalidator"
2324
"crowdsec-manager/internal/cron"
@@ -132,7 +133,8 @@ func main() {
132133
api.RegisterBackupRoutes(apiGroup, backupManager, dockerClient)
133134
api.RegisterUpdateRoutes(apiGroup, dockerClient, cfg)
134135
api.RegisterCronRoutes(apiGroup, cronScheduler)
135-
api.RegisterServicesRoutes(apiGroup, dockerClient, db, cfg)
136+
ttlCache := cache.New()
137+
api.RegisterServicesRoutes(apiGroup, dockerClient, db, cfg, ttlCache)
136138
api.RegisterNotificationRoutes(apiGroup, dockerClient, db, cfg)
137139
api.RegisterProfileRoutes(apiGroup, db, cfg, dockerClient)
138140
api.RegisterHostRoutes(apiGroup, multiHost)

internal/api/handlers/common.go

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,12 @@ package handlers
22

33
import (
44
"encoding/json"
5+
"fmt"
6+
"io"
57
"strings"
68
"time"
79

10+
"crowdsec-manager/internal/constants"
811
"crowdsec-manager/internal/docker"
912
"crowdsec-manager/internal/logger"
1013
"crowdsec-manager/internal/models"
@@ -195,3 +198,46 @@ func errString(err error) string {
195198
}
196199
return err.Error()
197200
}
201+
202+
// CLIFlag represents a CLI flag and its value for building cscli commands.
203+
type CLIFlag struct {
204+
Flag string
205+
Value string
206+
}
207+
208+
// appendCLIFlags appends non-empty flag/value pairs to a command slice.
209+
// Returns the extended command and the number of flags added.
210+
func appendCLIFlags(cmd []string, flags []CLIFlag) ([]string, int) {
211+
count := 0
212+
for _, f := range flags {
213+
if f.Value != "" {
214+
cmd = append(cmd, f.Flag, f.Value)
215+
count++
216+
}
217+
}
218+
return cmd, count
219+
}
220+
221+
// getExternalIP tries each external IP service in order and returns the first
222+
// successful result. Used by both GetPublicIP and WhitelistCurrentIP.
223+
func getExternalIP() (string, error) {
224+
var lastErr error
225+
for _, service := range constants.ExternalIPServices {
226+
resp, err := constants.ExternalHTTPClient.Get(service)
227+
if err != nil {
228+
lastErr = err
229+
continue
230+
}
231+
body, err := io.ReadAll(resp.Body)
232+
resp.Body.Close()
233+
if err != nil {
234+
lastErr = err
235+
continue
236+
}
237+
ip := strings.TrimSpace(string(body))
238+
if ip != "" {
239+
return ip, nil
240+
}
241+
}
242+
return "", fmt.Errorf("all IP services failed: %w", lastErr)
243+
}

internal/api/handlers/dashboard.go

Lines changed: 75 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,19 @@
11
package handlers
22

33
import (
4-
"crowdsec-manager/internal/config"
5-
"crowdsec-manager/internal/database"
6-
"crowdsec-manager/internal/docker"
7-
"crowdsec-manager/internal/logger"
8-
"crowdsec-manager/internal/models"
94
"encoding/json"
105
"fmt"
116
"net/http"
127
"strings"
138
"time"
149

10+
"crowdsec-manager/internal/cache"
11+
"crowdsec-manager/internal/config"
12+
"crowdsec-manager/internal/database"
13+
"crowdsec-manager/internal/docker"
14+
"crowdsec-manager/internal/logger"
15+
"crowdsec-manager/internal/models"
16+
1517
"github.com/buger/jsonparser"
1618
"github.com/gin-gonic/gin"
1719
)
@@ -21,10 +23,26 @@ import (
2123
// =============================================================================
2224

2325
// GetDecisions retrieves CrowdSec decisions
24-
// GetDecisions retrieves CrowdSec decisions
25-
func GetDecisions(dockerClient *docker.Client, cfg *config.Config) gin.HandlerFunc {
26+
func GetDecisions(dockerClient *docker.Client, cfg *config.Config, ttlCache ...*cache.TTLCache) gin.HandlerFunc {
2627
return func(c *gin.Context) {
2728
dockerClient = resolveDockerClient(c, dockerClient)
29+
30+
// Check cache first
31+
summary := c.Query("summary") == "true"
32+
cacheKey := "decisions"
33+
if summary {
34+
cacheKey = "decisions-summary"
35+
}
36+
if len(ttlCache) > 0 && ttlCache[0] != nil {
37+
if cached, ok := ttlCache[0].Get(cacheKey); ok {
38+
c.JSON(http.StatusOK, models.Response{
39+
Success: true,
40+
Data: cached,
41+
})
42+
return
43+
}
44+
}
45+
2846
logger.Info("Getting CrowdSec decisions via cscli")
2947

3048
output, err := dockerClient.ExecCommand(cfg.CrowdsecContainerName, []string{
@@ -120,18 +138,61 @@ func GetDecisions(dockerClient *docker.Client, cfg *config.Config) gin.HandlerFu
120138

121139
logger.Debug("Decisions retrieved successfully", "count", len(decisions))
122140

141+
// Summary mode: return only count and lightweight aggregations
142+
if summary {
143+
typeDistribution := make(map[string]int)
144+
topScenarios := make(map[string]int)
145+
for _, d := range decisions {
146+
if d.Type != "" {
147+
typeDistribution[d.Type]++
148+
}
149+
if d.Scenario != "" {
150+
topScenarios[d.Scenario]++
151+
}
152+
}
153+
result := gin.H{
154+
"count": len(decisions),
155+
"types": typeDistribution,
156+
"scenarios": topScenarios,
157+
}
158+
if len(ttlCache) > 0 && ttlCache[0] != nil {
159+
ttlCache[0].Set(cacheKey, result, 15*time.Second)
160+
}
161+
c.JSON(http.StatusOK, models.Response{
162+
Success: true,
163+
Data: result,
164+
})
165+
return
166+
}
167+
168+
result := gin.H{"decisions": decisions, "count": len(decisions)}
169+
if len(ttlCache) > 0 && ttlCache[0] != nil {
170+
ttlCache[0].Set(cacheKey, result, 15*time.Second)
171+
}
172+
123173
c.JSON(http.StatusOK, models.Response{
124174
Success: true,
125-
Data: gin.H{"decisions": decisions, "count": len(decisions)},
175+
Data: result,
126176
})
127177
}
128178
}
129179

130180
// GetMetrics retrieves CrowdSec metrics
131-
// GetMetrics retrieves CrowdSec metrics
132-
func GetMetrics(dockerClient *docker.Client, cfg *config.Config) gin.HandlerFunc {
181+
func GetMetrics(dockerClient *docker.Client, cfg *config.Config, ttlCache ...*cache.TTLCache) gin.HandlerFunc {
133182
return func(c *gin.Context) {
134183
dockerClient = resolveDockerClient(c, dockerClient)
184+
185+
cacheKey := "metrics"
186+
if len(ttlCache) > 0 && ttlCache[0] != nil {
187+
if cached, ok := ttlCache[0].Get(cacheKey); ok {
188+
c.JSON(http.StatusOK, models.Response{
189+
Success: true,
190+
Data: cached,
191+
})
192+
return
193+
}
194+
}
195+
135196
logger.Info("Getting CrowdSec metrics")
136197

137198
output, err := dockerClient.ExecCommand(cfg.CrowdsecContainerName, []string{
@@ -145,7 +206,6 @@ func GetMetrics(dockerClient *docker.Client, cfg *config.Config) gin.HandlerFunc
145206
return
146207
}
147208

148-
// Parse as raw JSON
149209
var metrics interface{}
150210
if err := json.Unmarshal([]byte(output), &metrics); err != nil {
151211
logger.Warn("Failed to parse metrics JSON", "error", err)
@@ -156,6 +216,10 @@ func GetMetrics(dockerClient *docker.Client, cfg *config.Config) gin.HandlerFunc
156216
return
157217
}
158218

219+
if len(ttlCache) > 0 && ttlCache[0] != nil {
220+
ttlCache[0].Set(cacheKey, metrics, 30*time.Second)
221+
}
222+
159223
c.JSON(http.StatusOK, models.Response{
160224
Success: true,
161225
Data: metrics,

internal/api/handlers/dashboard_analysis.go

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,9 @@ import (
44
"encoding/json"
55
"fmt"
66
"net/http"
7+
"time"
78

9+
"crowdsec-manager/internal/cache"
810
"crowdsec-manager/internal/config"
911
"crowdsec-manager/internal/docker"
1012
"crowdsec-manager/internal/logger"
@@ -152,9 +154,22 @@ func GetDecisionsAnalysis(dockerClient *docker.Client, cfg *config.Config) gin.H
152154
}
153155

154156
// GetAlertsAnalysis retrieves CrowdSec alerts with advanced filtering
155-
func GetAlertsAnalysis(dockerClient *docker.Client, cfg *config.Config) gin.HandlerFunc {
157+
func GetAlertsAnalysis(dockerClient *docker.Client, cfg *config.Config, ttlCache ...*cache.TTLCache) gin.HandlerFunc {
156158
return func(c *gin.Context) {
157159
dockerClient = resolveDockerClient(c, dockerClient)
160+
161+
// Cache key includes the "since" param to differentiate dashboard vs analysis queries
162+
cacheKey := "alerts-analysis-" + c.Query("since")
163+
if len(ttlCache) > 0 && ttlCache[0] != nil {
164+
if cached, ok := ttlCache[0].Get(cacheKey); ok {
165+
c.JSON(http.StatusOK, models.Response{
166+
Success: true,
167+
Data: cached,
168+
})
169+
return
170+
}
171+
}
172+
158173
logger.Info("Getting CrowdSec alerts analysis via cscli")
159174

160175
var cmd []string
@@ -233,9 +248,14 @@ func GetAlertsAnalysis(dockerClient *docker.Client, cfg *config.Config) gin.Hand
233248

234249
logger.Info("Alerts analysis retrieved successfully", "count", len(alerts))
235250

251+
result := gin.H{"alerts": alerts, "count": len(alerts)}
252+
if len(ttlCache) > 0 && ttlCache[0] != nil {
253+
ttlCache[0].Set(cacheKey, result, 30*time.Second)
254+
}
255+
236256
c.JSON(http.StatusOK, models.Response{
237257
Success: true,
238-
Data: gin.H{"alerts": alerts, "count": len(alerts)},
258+
Data: result,
239259
})
240260
}
241261
}

internal/api/handlers/decisions.go

Lines changed: 21 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -37,21 +37,15 @@ func AddDecision(dockerClient *docker.Client, cfg *config.Config) gin.HandlerFun
3737
}
3838

3939
cmd := []string{"cscli", "decisions", "add"}
40-
41-
// Helper to add flag if value is present
42-
addFlag := func(flag, value string) {
43-
if value != "" {
44-
cmd = append(cmd, flag, value)
45-
}
46-
}
47-
48-
addFlag("--ip", req.IP)
49-
addFlag("--range", req.Range)
50-
addFlag("--duration", req.Duration)
51-
addFlag("--type", req.Type)
52-
addFlag("--scope", req.Scope)
53-
addFlag("--value", req.Value)
54-
addFlag("--reason", req.Reason)
40+
cmd, _ = appendCLIFlags(cmd, []CLIFlag{
41+
{"--ip", req.IP},
42+
{"--range", req.Range},
43+
{"--duration", req.Duration},
44+
{"--type", req.Type},
45+
{"--scope", req.Scope},
46+
{"--value", req.Value},
47+
{"--reason", req.Reason},
48+
})
5549

5650
logger.Info("Adding decision", "command", cmd)
5751

@@ -99,25 +93,19 @@ func DeleteDecision(dockerClient *docker.Client, cfg *config.Config) gin.Handler
9993
}
10094

10195
cmd := []string{"cscli", "decisions", "delete"}
102-
hasFilter := false
103-
104-
addFlag := func(flag, value string) {
105-
if value != "" {
106-
cmd = append(cmd, flag, value)
107-
hasFilter = true
108-
}
109-
}
110-
111-
addFlag("--id", req.ID)
112-
addFlag("--ip", req.IP)
113-
addFlag("--range", req.Range)
114-
addFlag("--type", req.Type)
115-
addFlag("--scope", req.Scope)
116-
addFlag("--value", req.Value)
117-
addFlag("--scenario", req.Scenario)
118-
addFlag("--origin", req.Origin)
96+
var count int
97+
cmd, count = appendCLIFlags(cmd, []CLIFlag{
98+
{"--id", req.ID},
99+
{"--ip", req.IP},
100+
{"--range", req.Range},
101+
{"--type", req.Type},
102+
{"--scope", req.Scope},
103+
{"--value", req.Value},
104+
{"--scenario", req.Scenario},
105+
{"--origin", req.Origin},
106+
})
119107

120-
if !hasFilter {
108+
if count == 0 {
121109
c.JSON(http.StatusBadRequest, models.Response{
122110
Success: false,
123111
Error: "At least one filter (id, ip, range, etc.) must be provided to delete decisions",

internal/api/handlers/decisions_import.go

Lines changed: 10 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -117,7 +117,16 @@ func ImportDecisions(dockerClient *docker.Client, cfg *config.Config) gin.Handle
117117
// Write to container
118118
containerFile := "/tmp/decisions_import.csv"
119119

120-
err = copyToContainer(dockerClient, cfg.CrowdsecContainerName, tempFilePath, containerFile)
120+
content, err := os.ReadFile(tempFilePath)
121+
if err != nil {
122+
c.JSON(http.StatusInternalServerError, models.Response{
123+
Success: false,
124+
Error: "Failed to read temp file: " + err.Error(),
125+
})
126+
return
127+
}
128+
129+
err = dockerClient.WriteFileToContainer(cfg.CrowdsecContainerName, containerFile, content)
121130
if err != nil {
122131
c.JSON(http.StatusInternalServerError, models.Response{
123132
Success: false,
@@ -148,14 +157,3 @@ func ImportDecisions(dockerClient *docker.Client, cfg *config.Config) gin.Handle
148157
})
149158
}
150159
}
151-
152-
// copyToContainer is a helper to copy a file to the container
153-
func copyToContainer(client *docker.Client, containerName, srcPath, dstPath string) error {
154-
f, err := os.Open(srcPath)
155-
if err != nil {
156-
return fmt.Errorf("failed to open source file: %w", err)
157-
}
158-
defer f.Close()
159-
160-
return client.CopyToContainer(containerName, dstPath, f)
161-
}

0 commit comments

Comments
 (0)