diff --git a/changelog.md b/changelog.md
index 218d6b1..949c266 100644
--- a/changelog.md
+++ b/changelog.md
@@ -1,15 +1,20 @@
## Version 0.12.0
- New Dashboard
- New metrics gathering system
+ - New alerts system
+ - New notification center
+ - New events manager
- Integrated a new docker-less mode of functioning for networking
- Added Button to force reset HTTPS cert in settings
- New color slider with reset buttons
- - Fixed blinking modals issues
+ - Added a notification when updating a container
- Added lazyloading to URL and Servapp pages images
- Added a dangerous IP detector that stops sending HTTP response to IPs that are abusing various shields features
- Added a button in the servapp page to easily download the docker backup
- Redirect static folder to host if possible
- New Homescreen look
+ - Added option to disable routes without deleting them
+ - Fixed blinking modals issues
- Improve display or icons [fixes #121]
- Refactored Mongo connection code [fixes #111]
- Forward simultaneously TCP and UDP [fixes #122]
diff --git a/client/src/api/metrics.demo.jsx b/client/src/api/metrics.demo.jsx
index 10a9c0f..71d28bb 100644
--- a/client/src/api/metrics.demo.jsx
+++ b/client/src/api/metrics.demo.jsx
@@ -6,6 +6,20 @@ function get() {
});
}
+function reset() {
+ return new Promise((resolve, reject) => {
+ resolve()
+ });
+}
+
+// function list() {
+// return new Promise((resolve, reject) => {
+// resolve()
+// });
+// }
+
export {
get,
+ reset,
+ // list,
};
\ No newline at end of file
diff --git a/client/src/api/metrics.jsx b/client/src/api/metrics.jsx
index c16ed66..5822023 100644
--- a/client/src/api/metrics.jsx
+++ b/client/src/api/metrics.jsx
@@ -18,7 +18,17 @@ function reset() {
}))
}
+function list() {
+ return wrap(fetch('/cosmos/api/list-metrics', {
+ method: 'GET',
+ headers: {
+ 'Content-Type': 'application/json'
+ },
+ }))
+}
+
export {
get,
reset,
+ list,
};
\ No newline at end of file
diff --git a/client/src/api/users.jsx b/client/src/api/users.jsx
index 46a2998..323ef22 100644
--- a/client/src/api/users.jsx
+++ b/client/src/api/users.jsx
@@ -110,6 +110,24 @@ function resetPassword(values) {
}))
}
+function getNotifs() {
+ return wrap(fetch('/cosmos/api/notifications', {
+ method: 'GET',
+ headers: {
+ 'Content-Type': 'application/json'
+ },
+ }))
+}
+
+function readNotifs(notifs) {
+ return wrap(fetch('/cosmos/api/notifications/read?ids=' + notifs.join(','), {
+ method: 'GET',
+ headers: {
+ 'Content-Type': 'application/json'
+ },
+ }))
+}
+
export {
list,
create,
@@ -122,4 +140,6 @@ export {
check2FA,
reset2FA,
resetPassword,
+ getNotifs,
+ readNotifs,
};
\ No newline at end of file
diff --git a/client/src/assets/images/wallpaper2.jpg b/client/src/assets/images/wallpaper2.jpg
index 149e23e..93b77f3 100644
Binary files a/client/src/assets/images/wallpaper2.jpg and b/client/src/assets/images/wallpaper2.jpg differ
diff --git a/client/src/components/delete.jsx b/client/src/components/delete.jsx
index ed3601b..362182c 100644
--- a/client/src/components/delete.jsx
+++ b/client/src/components/delete.jsx
@@ -3,11 +3,13 @@ import { Card, Chip, Stack, Tooltip } from "@mui/material";
import { useState } from "react";
import { useTheme } from '@mui/material/styles';
-export const DeleteButton = ({onDelete}) => {
+export const DeleteButton = ({onDelete, disabled}) => {
const [confirmDelete, setConfirmDelete] = useState(false);
return (<>
- {!confirmDelete && (} onClick={() => setConfirmDelete(true)}/>)}
- {confirmDelete && (} color="error" onClick={(event) => onDelete(event)}/>)}
+ {!confirmDelete && (}
+ onClick={() => !disabled && setConfirmDelete(true)}/>)}
+ {confirmDelete && (} color="error"
+ onClick={(event) => !disabled && onDelete(event)}/>)}
>);
}
\ No newline at end of file
diff --git a/client/src/components/tabbedView/tabbedView.jsx b/client/src/components/tabbedView/tabbedView.jsx
index 8aee043..3070c55 100644
--- a/client/src/components/tabbedView/tabbedView.jsx
+++ b/client/src/components/tabbedView/tabbedView.jsx
@@ -37,7 +37,7 @@ const a11yProps = (index) => {
};
};
-const PrettyTabbedView = ({ tabs, isLoading, currentTab, setCurrentTab, fullwidth }) => {
+const PrettyTabbedView = ({ tabs, isLoading, currentTab, setCurrentTab }) => {
const [value, setValue] = useState(0);
const isMobile = useMediaQuery((theme) => theme.breakpoints.down('md'));
@@ -55,8 +55,8 @@ const PrettyTabbedView = ({ tabs, isLoading, currentTab, setCurrentTab, fullwidt
};
return (
-
- {(isMobile && !currentTab) ? (
+
+ {(isMobile) ? (
diff --git a/src/CRON.go b/src/CRON.go
index c0ce84d..a817175 100644
--- a/src/CRON.go
+++ b/src/CRON.go
@@ -122,6 +122,7 @@ func CRON() {
s.Every(1).Day().At("00:00").Do(checkVersion)
s.Every(1).Day().At("01:00").Do(checkCerts)
s.Every(6).Hours().Do(checkUpdatesAvailable)
+ s.Every(1).Hours().Do(utils.CleanBannedIPs)
s.Start()
}()
}
\ No newline at end of file
diff --git a/src/docker/docker.go b/src/docker/docker.go
index 50a363e..04a3cbe 100644
--- a/src/docker/docker.go
+++ b/src/docker/docker.go
@@ -537,10 +537,18 @@ func CheckUpdatesAvailable() map[string]bool {
}
if needsUpdate && HasAutoUpdateOn(fullContainer) {
- utils.Log("Downlaoded new update for " + container.Image + " ready to install")
+ utils.WriteNotification(utils.Notification{
+ Recipient: "admin",
+ Title: "Container Update",
+ Message: "Container " + container.Names[0][1:] + " updated to the latest version!",
+ Level: "info",
+ Link: "/cosmos-ui/servapps/containers/" + container.Names[0][1:],
+ })
+
+ utils.Log("Downloaded new update for " + container.Image + " ready to install")
_, err := RecreateContainer(container.Names[0], fullContainer)
if err != nil {
- utils.Error("CheckUpdatesAvailable - Failed to update - ", err)
+ utils.MajorError("Container failed to update", err)
} else {
result[container.Names[0]] = false
}
@@ -675,8 +683,8 @@ type ContainerStats struct {
}
func Stats(container types.Container) (ContainerStats, error) {
- utils.Debug("StatsAll - Getting stats for " + container.Names[0])
- utils.Debug("Time: " + time.Now().String())
+ // utils.Debug("StatsAll - Getting stats for " + container.Names[0])
+ // utils.Debug("Time: " + time.Now().String())
statsBody, err := DockerClient.ContainerStats(DockerContext, container.ID, false)
if err != nil {
@@ -698,7 +706,7 @@ func Stats(container types.Container) (ContainerStats, error) {
perCore := len(stats.CPUStats.CPUUsage.PercpuUsage)
if perCore == 0 {
- utils.Warn("StatsAll - Docker CPU PercpuUsage is 0")
+ utils.Debug("StatsAll - Docker CPU PercpuUsage is 0")
perCore = 1
}
@@ -715,9 +723,9 @@ func Stats(container types.Container) (ContainerStats, error) {
if systemDelta > 0 && cpuDelta > 0 {
cpuUsage = (cpuDelta / systemDelta) * float64(perCore) * 100
- utils.Debug("StatsAll - CPU CPUUsage " + strconv.FormatFloat(cpuUsage, 'f', 6, 64))
+ // utils.Debug("StatsAll - CPU CPUUsage " + strconv.FormatFloat(cpuUsage, 'f', 6, 64))
} else {
- utils.Error("StatsAll - Error calculating CPU usage for " + container.Names[0], nil)
+ utils.Debug("StatsAll - Error calculating CPU usage for " + container.Names[0])
}
// memUsage := float64(stats.MemoryStats.Usage) / float64(stats.MemoryStats.Limit) * 100
@@ -780,4 +788,12 @@ func Stats(container types.Container) (ContainerStats, error) {
wg.Wait() // Wait for all goroutines to finish.
return containerStatsList, nil
+ }
+
+ func StopContainer(containerName string) {
+ err := DockerClient.ContainerStop(DockerContext, containerName, container.StopOptions{})
+ if err != nil {
+ utils.Error("StopContainer", err)
+ return
+ }
}
\ No newline at end of file
diff --git a/src/httpServer.go b/src/httpServer.go
index 4ef24e5..6675fa6 100644
--- a/src/httpServer.go
+++ b/src/httpServer.go
@@ -154,11 +154,11 @@ func SecureAPI(userRouter *mux.Router, public bool) {
userRouter.Use(proxy.SmartShieldMiddleware(
"__COSMOS",
utils.ProxyRouteConfig{
- Name: "_Cosmos",
+ Name: "Cosmos-Internal",
SmartShield: utils.SmartShieldPolicy{
Enabled: true,
PolicyStrictness: 1,
- PerUserRequestLimit: 5000,
+ PerUserRequestLimit: 6000,
},
},
))
@@ -350,6 +350,10 @@ func InitServer() *mux.Router {
srapi.HandleFunc("/api/metrics", metrics.API_GetMetrics)
srapi.HandleFunc("/api/reset-metrics", metrics.API_ResetMetrics)
+ srapi.HandleFunc("/api/list-metrics", metrics.ListMetrics)
+
+ srapi.HandleFunc("/api/notifications/read", utils.MarkAsRead)
+ srapi.HandleFunc("/api/notifications", utils.NotifGet)
if(!config.HTTPConfig.AcceptAllInsecureHostname) {
srapi.Use(utils.EnsureHostname)
diff --git a/src/index.go b/src/index.go
index 5561b7e..7cd65b4 100644
--- a/src/index.go
+++ b/src/index.go
@@ -23,6 +23,8 @@ func main() {
LoadConfig()
+ utils.InitDBBuffers()
+
go CRON()
docker.ExportDocker()
diff --git a/src/metrics/aggl.go b/src/metrics/aggl.go
index 3a19ae7..fc9d5b5 100644
--- a/src/metrics/aggl.go
+++ b/src/metrics/aggl.go
@@ -33,6 +33,7 @@ type DataDefDB struct {
AggloType string
Scale int
Unit string
+ Object string
}
func AggloMetrics(metricsList []string) []DataDefDB {
@@ -61,7 +62,7 @@ func AggloMetrics(metricsList []string) []DataDefDB {
for _, metric := range metricsList {
if strings.Contains(metric, "*") {
// Convert wildcard to regex. Replace * with .*
- regexPattern := "^" + strings.ReplaceAll(metric, "*", ".*")
+ regexPattern := "^" + strings.ReplaceAll(metric, "*", ".*?")
regexPatterns = append(regexPatterns, bson.M{"Key": bson.M{"$regex": regexPattern}})
} else {
// If there's no wildcard, match the metric directly
@@ -90,6 +91,9 @@ func AggloMetrics(metricsList []string) []DataDefDB {
hourlyPoolTo := ModuloTime(time.Now().Add(1 * time.Hour), time.Hour)
dailyPool := ModuloTime(time.Now(), 24 * time.Hour)
dailyPoolTo := ModuloTime(time.Now().Add(24 * time.Hour), 24 * time.Hour)
+
+ previousHourlyPool := ModuloTime(time.Now().Add(-1 * time.Hour), time.Hour)
+ previousDailyPool := ModuloTime(time.Now().Add(-24 * time.Hour), 24 * time.Hour)
for metInd, metric := range metrics {
values := metric.Values
@@ -109,6 +113,15 @@ func AggloMetrics(metricsList []string) []DataDefDB {
AggloTo: hourlyPoolTo,
AggloExpire: hourlyPoolTo.Add(48 * time.Hour),
}
+
+ // check alerts on previous pool
+ if agMet, ok := metric.ValuesAggl["hour_" + previousHourlyPool.UTC().Format("2006-01-02 15:04:05")]; ok {
+ CheckAlerts(metric.Key, "hourly", utils.AlertMetricTrack{
+ Key: metric.Key,
+ Object: metric.Object,
+ Max: metric.Max,
+ }, agMet.Value)
+ }
}
// if daily pool does not exist, create it
@@ -121,6 +134,15 @@ func AggloMetrics(metricsList []string) []DataDefDB {
AggloTo: dailyPoolTo,
AggloExpire: dailyPoolTo.Add(30 * 24 * time.Hour),
}
+
+ // check alerts on previous pool
+ if agMet, ok := metric.ValuesAggl["day_" + previousDailyPool.UTC().Format("2006-01-02 15:04:05")]; ok {
+ CheckAlerts(metric.Key, "daily", utils.AlertMetricTrack{
+ Key: metric.Key,
+ Object: metric.Object,
+ Max: metric.Max,
+ }, agMet.Value)
+ }
}
for valInd, value := range values {
diff --git a/src/metrics/alerts.go b/src/metrics/alerts.go
new file mode 100644
index 0000000..40d75ae
--- /dev/null
+++ b/src/metrics/alerts.go
@@ -0,0 +1,175 @@
+package metrics
+
+import (
+ "strings"
+ "regexp"
+ "fmt"
+ "time"
+
+ "github.com/azukaar/cosmos-server/src/utils"
+ "github.com/azukaar/cosmos-server/src/docker"
+)
+
+func CheckAlerts(TrackingMetric string, Period string, metric utils.AlertMetricTrack, Value int) {
+ config := utils.GetMainConfig()
+ ActiveAlerts := config.MonitoringAlerts
+
+ alerts := []utils.Alert{}
+ ok := false
+
+ // if tracking metric contains a wildcard
+ if strings.Contains(TrackingMetric, "*") {
+ regexPattern := "^" + strings.ReplaceAll(TrackingMetric, "*", ".*?")
+ regex, _ := regexp.Compile(regexPattern)
+
+ // Iterate over the map to find a match
+ for _, val := range ActiveAlerts {
+ if regex.MatchString(val.TrackingMetric) && val.Period == Period {
+ alerts = append(alerts, val)
+ ok = true
+ }
+ }
+ } else {
+ for _, val := range ActiveAlerts {
+ if val.TrackingMetric == TrackingMetric && val.Period == Period {
+ alerts = append(alerts, val)
+ ok = true
+ break
+ }
+ }
+ }
+
+ if !ok {
+ return
+ }
+
+ for _, alert := range alerts {
+ if !alert.Enabled {
+ continue
+ }
+
+ if alert.Throttled && alert.LastTriggered.Add(time.Hour * 24).After(time.Now()) {
+ continue
+ }
+
+ ValueToTest := Value
+
+ if alert.Condition.Percent {
+ ValueToTest = int(float64(Value) / float64(metric.Max) * 100)
+
+ utils.Debug(fmt.Sprintf("Alert %s: %d / %d = %d%%", alert.Name, Value, metric.Max, ValueToTest))
+ }
+
+ // Check if the condition is met
+ if alert.Condition.Operator == "gt" {
+ if ValueToTest > alert.Condition.Value {
+ ExecuteAllActions(alert, alert.Actions, metric)
+ }
+ } else if alert.Condition.Operator == "lt" {
+ if ValueToTest < alert.Condition.Value {
+ ExecuteAllActions(alert, alert.Actions, metric)
+ }
+ } else if alert.Condition.Operator == "eq" {
+ if ValueToTest == alert.Condition.Value {
+ ExecuteAllActions(alert, alert.Actions, metric)
+ }
+ }
+ }
+}
+
+func ExecuteAllActions(alert utils.Alert, actions []utils.AlertAction, metric utils.AlertMetricTrack) {
+ utils.Debug("Alert triggered: " + alert.Name)
+ for _, action := range actions {
+ ExecuteAction(alert, action, metric)
+ }
+
+ // set LastTriggered to now
+ alert.LastTriggered = time.Now()
+
+ // update alert in config
+ config := utils.GetMainConfig()
+ for i, val := range config.MonitoringAlerts {
+ if val.Name == alert.Name {
+ config.MonitoringAlerts[i] = alert
+ break
+ }
+ }
+
+ utils.SetBaseMainConfig(config)
+}
+
+func ExecuteAction(alert utils.Alert, action utils.AlertAction, metric utils.AlertMetricTrack) {
+ utils.Log("Executing action " + action.Type + " on " + metric.Key + " " + metric.Object )
+
+ if action.Type == "email" {
+ utils.Debug("Sending email to " + action.Target)
+
+ if utils.GetMainConfig().EmailConfig.Enabled {
+ users := utils.ListAllUsers("admin")
+ for _, user := range users {
+ if user.Email != "" {
+ utils.SendEmail([]string{user.Email}, "Alert Triggered: " + alert.Name,
+ fmt.Sprintf(`Alert Triggered [%s]
+You are recevining this email because you are admin on a Cosmos
+server where an Alert has been subscribed to.
+You can manage your subscriptions in the Monitoring tab.
+Alert triggered on %s. Please refer to the Monitoring tab for
+more information.
`, alert.Severity, metric.Key))
+ }
+ }
+ } else {
+ utils.Warn("Alert triggered but Email is not enabled")
+ }
+
+ } else if action.Type == "webhook" {
+ utils.Debug("Calling webhook " + action.Target)
+
+ } else if action.Type == "stop" {
+ utils.Debug("Stopping application")
+
+ parts := strings.Split(metric.Object, "@")
+
+ if len(parts) > 1 {
+ object := parts[0]
+ objectName := strings.Join(parts[1:], "@")
+
+ if object == "container" {
+ docker.StopContainer(objectName)
+ } else if object == "route" {
+ config := utils.ReadConfigFromFile()
+
+ objectIndex := -1
+ for i, route := range config.HTTPConfig.ProxyConfig.Routes {
+ if route.Name == objectName {
+ objectIndex = i
+ break
+ }
+ }
+
+ if objectIndex != -1 {
+ config.HTTPConfig.ProxyConfig.Routes[objectIndex].Disabled = true
+
+ utils.SetBaseMainConfig(config)
+ } else {
+ utils.Warn("No route found, for " + objectName)
+ }
+
+ utils.RestartHTTPServer()
+ }
+ } else {
+ utils.Warn("No object found, for " + metric.Object)
+ }
+
+ } else if action.Type == "notification" {
+ utils.WriteNotification(utils.Notification{
+ Recipient: "admin",
+ Title: "Alert triggered",
+ Message: "The alert \"" + alert.Name + "\" was triggered.",
+ Level: alert.Severity,
+ Link: "/cosmos-ui/monitoring",
+ })
+
+ } else if action.Type == "script" {
+ utils.Debug("Executing script")
+ }
+}
\ No newline at end of file
diff --git a/src/metrics/middleware.go b/src/metrics/http.go
similarity index 60%
rename from src/metrics/middleware.go
rename to src/metrics/http.go
index dc8f07d..2889f73 100644
--- a/src/metrics/middleware.go
+++ b/src/metrics/http.go
@@ -2,6 +2,11 @@ package metrics
import (
"time"
+ "net/http"
+ "encoding/json"
+
+ "go.mongodb.org/mongo-driver/mongo/options"
+ "go.mongodb.org/mongo-driver/bson"
"github.com/azukaar/cosmos-server/src/utils"
)
@@ -25,6 +30,7 @@ func PushRequestMetrics(route utils.ProxyRouteConfig, statusCode int, TimeStarte
Label: "Request Errors " + route.Name,
AggloType: "sum",
SetOperation: "sum",
+ Object: "route@" + route.Name,
})
} else {
PushSetMetric("proxy.all.success", 1, DataDef{
@@ -40,6 +46,7 @@ func PushRequestMetrics(route utils.ProxyRouteConfig, statusCode int, TimeStarte
Label: "Request Success " + route.Name,
AggloType: "sum",
SetOperation: "sum",
+ Object: "route@" + route.Name,
})
}
@@ -59,6 +66,7 @@ func PushRequestMetrics(route utils.ProxyRouteConfig, statusCode int, TimeStarte
AggloType: "sum",
SetOperation: "sum",
Unit: "ms",
+ Object: "route@" + route.Name,
})
PushSetMetric("proxy.all.bytes", int(size), DataDef{
@@ -77,6 +85,7 @@ func PushRequestMetrics(route utils.ProxyRouteConfig, statusCode int, TimeStarte
AggloType: "sum",
SetOperation: "sum",
Unit: "B",
+ Object: "route@" + route.Name,
})
}
@@ -108,4 +117,58 @@ func PushShieldMetrics(reason string) {
AggloType: "sum",
SetOperation: "sum",
})
+}
+
+type MetricList struct {
+ Key string
+ Label string
+}
+
+func ListMetrics(w http.ResponseWriter, req *http.Request) {
+ if utils.AdminOnly(w, req) != nil {
+ return
+ }
+
+ if(req.Method == "GET") {
+ c, errCo := utils.GetCollection(utils.GetRootAppId(), "metrics")
+ if errCo != nil {
+ utils.Error("Database Connect", errCo)
+ utils.HTTPError(w, "Database", http.StatusInternalServerError, "DB001")
+ return
+ }
+
+ metrics := []MetricList{}
+
+ cursor, err := c.Find(nil, map[string]interface{}{}, options.Find().SetProjection(bson.M{"Key": 1, "Label":1, "_id": 0}))
+
+ if err != nil {
+ utils.Error("metrics: Error while getting metrics", err)
+ utils.HTTPError(w, "metrics Get Error", http.StatusInternalServerError, "UD001")
+ return
+ }
+
+ defer cursor.Close(nil)
+
+ if err = cursor.All(nil, &metrics); err != nil {
+ utils.Error("metrics: Error while decoding metrics", err)
+ utils.HTTPError(w, "metrics decode Error", http.StatusInternalServerError, "UD002")
+ return
+ }
+
+ // Extract the names into a string slice
+ metricNames := map[string]string{}
+
+ for _, metric := range metrics {
+ metricNames[metric.Key] = metric.Label
+ }
+
+ json.NewEncoder(w).Encode(map[string]interface{}{
+ "status": "OK",
+ "data": metricNames,
+ })
+ } else {
+ utils.Error("metrics: Method not allowed" + req.Method, nil)
+ utils.HTTPError(w, "Method not allowed", http.StatusMethodNotAllowed, "HTTP001")
+ return
+ }
}
\ No newline at end of file
diff --git a/src/metrics/index.go b/src/metrics/index.go
index ac75406..0d6c5b2 100644
--- a/src/metrics/index.go
+++ b/src/metrics/index.go
@@ -19,6 +19,7 @@ type DataDef struct {
Scale int
Unit string
Decumulate bool
+ Object string
}
type DataPush struct {
@@ -33,6 +34,7 @@ type DataPush struct {
Scale int
Unit string
Decumulate bool
+ Object string
}
var dataBuffer = map[string]DataPush{}
@@ -109,6 +111,7 @@ func SaveMetrics() {
"AggloType": dp.AggloType,
"Scale": scale,
"Unit": dp.Unit,
+ "Object": dp.Object,
},
}
@@ -181,9 +184,16 @@ func PushSetMetric(key string, value int, def DataDef) {
AggloType: def.AggloType,
Scale: def.Scale,
Unit: def.Unit,
+ Object: def.Object,
}
}
+ CheckAlerts(key, "latest", utils.AlertMetricTrack{
+ Key: key,
+ Object: def.Object,
+ Max: def.Max,
+ }, value)
+
lastInserted[key] = originalValue
}()
}
diff --git a/src/metrics/system.go b/src/metrics/system.go
index 89d9090..74bcd9e 100644
--- a/src/metrics/system.go
+++ b/src/metrics/system.go
@@ -160,6 +160,7 @@ func GetSystemMetrics() {
Period: time.Second * 120,
Label: "Disk " + part.Mountpoint,
Unit: "B",
+ Object: "disk@" + part.Mountpoint,
})
}
}
@@ -208,13 +209,15 @@ func GetSystemMetrics() {
AggloType: "avg",
Scale: 100,
Unit: "%",
+ Object: "container@" + ds.Name,
})
PushSetMetric("system.docker.ram." + ds.Name, int(ds.MemUsage), DataDef{
- Max: 0,
+ Max: memInfo.Total,
Period: time.Second * 30,
Label: "Docker RAM " + ds.Name,
AggloType: "avg",
Unit: "B",
+ Object: "container@" + ds.Name,
})
PushSetMetric("system.docker.netRx." + ds.Name, int(ds.NetworkRx), DataDef{
Max: 0,
@@ -224,6 +227,7 @@ func GetSystemMetrics() {
AggloType: "sum",
Decumulate: true,
Unit: "B",
+ Object: "container@" + ds.Name,
})
PushSetMetric("system.docker.netTx." + ds.Name, int(ds.NetworkTx), DataDef{
Max: 0,
@@ -233,6 +237,7 @@ func GetSystemMetrics() {
AggloType: "sum",
Decumulate: true,
Unit: "B",
+ Object: "container@" + ds.Name,
})
}
}
\ No newline at end of file
diff --git a/src/proxy/buildFromConfig.go b/src/proxy/buildFromConfig.go
index 91223da..57556bd 100644
--- a/src/proxy/buildFromConfig.go
+++ b/src/proxy/buildFromConfig.go
@@ -15,7 +15,9 @@ func BuildFromConfig(router *mux.Router, config utils.ProxyConfig) *mux.Router {
for i := len(config.Routes)-1; i >= 0; i-- {
routeConfig := config.Routes[i]
- RouterGen(routeConfig, router, RouteTo(routeConfig))
+ if !routeConfig.Disabled {
+ RouterGen(routeConfig, router, RouteTo(routeConfig))
+ }
}
return router
diff --git a/src/utils/db.go b/src/utils/db.go
index aed94b9..0eae100 100644
--- a/src/utils/db.go
+++ b/src/utils/db.go
@@ -4,6 +4,9 @@ import (
"context"
"os"
"errors"
+ "sync"
+ "time"
+
"go.mongodb.org/mongo-driver/mongo"
"go.mongodb.org/mongo-driver/mongo/options"
"go.mongodb.org/mongo-driver/mongo/readpref"
@@ -79,4 +82,124 @@ func GetCollection(applicationId string, collection string) (*mongo.Collection,
// func query(q string) (*sql.Rows, error) {
// return db.Query(q)
-// }
\ No newline at end of file
+// }
+
+var (
+ bufferLock sync.Mutex
+ writeBuffer = make(map[string][]map[string]interface{})
+ bufferTicker = time.NewTicker(1 * time.Minute)
+ bufferCapacity = 100
+)
+
+func InitDBBuffers() {
+ go func() {
+ for {
+ select {
+ case <-bufferTicker.C:
+ flushAllBuffers()
+ }
+ }
+ }()
+}
+
+func flushBuffer(collectionName string) {
+ bufferLock.Lock()
+ objects, exists := writeBuffer[collectionName]
+ if exists && len(objects) > 0 {
+ collection, errG := GetCollection(GetRootAppId(), collectionName)
+ if errG != nil {
+ Error("BulkDBWritter: Error getting collection", errG)
+ }
+
+ if err := WriteToDatabase(collection, objects); err != nil {
+ Error("BulkDBWritter: Error writing to database", err)
+ }
+ writeBuffer[collectionName] = make([]map[string]interface{}, 0)
+ }
+ bufferLock.Unlock()
+}
+
+func flushAllBuffers() {
+ bufferLock.Lock()
+ for collectionName, objects := range writeBuffer {
+ if len(objects) > 0 {
+ collection, errG := GetCollection(GetRootAppId(), collectionName)
+ if errG != nil {
+ Error("BulkDBWritter: Error getting collection", errG)
+ }
+
+ if err := WriteToDatabase(collection, objects); err != nil {
+ Error("BulkDBWritter: Error writing to database: ", err)
+ }
+ writeBuffer[collectionName] = make([]map[string]interface{}, 0)
+ }
+ }
+ bufferLock.Unlock()
+}
+
+func BufferedDBWrite(collectionName string, object map[string]interface{}) {
+ bufferLock.Lock()
+ writeBuffer[collectionName] = append(writeBuffer[collectionName], object)
+ if len(writeBuffer[collectionName]) >= bufferCapacity {
+ flushBuffer(collectionName)
+ }
+ bufferLock.Unlock()
+}
+
+func WriteToDatabase(collection *mongo.Collection, objects []map[string]interface{}) error {
+ if len(objects) == 0 {
+ return nil // Nothing to write
+ }
+
+ // Convert to a slice of interface{} for insertion
+ interfaceSlice := make([]interface{}, len(objects))
+ for i, v := range objects {
+ interfaceSlice[i] = v
+ }
+
+ _, err := collection.InsertMany(context.Background(), interfaceSlice)
+ if err != nil {
+ return err
+ }
+
+ return nil
+}
+
+func ListAllUsers(role string) []User {
+ // list all users
+ c, errCo := GetCollection(GetRootAppId(), "users")
+ if errCo != nil {
+ Error("Database Connect", errCo)
+ return []User{}
+ }
+
+ users := []User{}
+
+ condition := map[string]interface{}{}
+
+ if role == "admin" {
+ condition = map[string]interface{}{
+ "Role": 2,
+ }
+ } else if role == "user" {
+ condition = map[string]interface{}{
+ "Role": 1,
+ }
+ }
+
+ cursor, err := c.Find(nil, condition)
+
+ if err != nil {
+ Error("Database: Error while getting users", err)
+ return []User{}
+ }
+
+ defer cursor.Close(nil)
+
+ if err = cursor.All(nil, &users); err != nil {
+ Error("Database: Error while decoding users", err)
+ return []User{}
+ }
+
+ return users
+}
\ No newline at end of file
diff --git a/src/utils/log.go b/src/utils/log.go
index ec2726f..7e6ed8f 100644
--- a/src/utils/log.go
+++ b/src/utils/log.go
@@ -47,6 +47,24 @@ func Error(message string, err error) {
}
}
+func MajorError(message string, err error) {
+ ll := LoggingLevelLabels[GetMainConfig().LoggingLevel]
+ errStr := ""
+ if err != nil {
+ errStr = err.Error()
+ }
+ if ll <= ERROR {
+ log.Println(Red + "[ERROR] " + message + " : " + errStr + Reset)
+ }
+
+ WriteNotification(Notification{
+ Recipient: "admin",
+ Title: "Server Error",
+ Message: message + " : " + errStr,
+ Level: "error",
+ })
+}
+
func Fatal(message string, err error) {
ll := LoggingLevelLabels[GetMainConfig().LoggingLevel]
errStr := ""
diff --git a/src/utils/middleware.go b/src/utils/middleware.go
index 836d0c3..3f5b3f3 100644
--- a/src/utils/middleware.go
+++ b/src/utils/middleware.go
@@ -62,9 +62,11 @@ func BlockBannedIPs(next http.Handler) http.Handler {
nbAbuse := getIPAbuseCounter(ip)
- // Debug("IP " + ip + " has " + fmt.Sprintf("%d", nbAbuse) + " abuse(s)")
+ if nbAbuse > 275 {
+ Warn("IP " + ip + " has " + fmt.Sprintf("%d", nbAbuse) + " abuse(s) and will soon be banned.")
+ }
- if nbAbuse > 1000 {
+ if nbAbuse > 300 {
if hj, ok := w.(http.Hijacker); ok {
conn, _, err := hj.Hijack()
if err == nil {
@@ -78,6 +80,13 @@ func BlockBannedIPs(next http.Handler) http.Handler {
})
}
+func CleanBannedIPs() {
+ BannedIPs.Range(func(key, value interface{}) bool {
+ BannedIPs.Delete(key)
+ return true
+ })
+}
+
func MiddlewareTimeout(timeout time.Duration) func(next http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
fn := func(w http.ResponseWriter, r *http.Request) {
diff --git a/src/utils/notifications.go b/src/utils/notifications.go
new file mode 100644
index 0000000..a8469f1
--- /dev/null
+++ b/src/utils/notifications.go
@@ -0,0 +1,201 @@
+package utils
+
+import (
+ "net/http"
+ "encoding/json"
+ "time"
+ "fmt"
+ "strings"
+
+ "go.mongodb.org/mongo-driver/mongo/options"
+ "go.mongodb.org/mongo-driver/bson/primitive"
+ "go.mongodb.org/mongo-driver/bson"
+)
+
+type NotificationActions struct {
+ Text string
+ Link string
+}
+
+type Notification struct {
+ ID primitive.ObjectID `bson:"_id,omitempty"`
+ Title string
+ Message string
+ Icon string
+ Link string
+ Date time.Time
+ Level string
+ Read bool
+ Recipient string
+ Actions []NotificationActions
+}
+
+func NotifGet(w http.ResponseWriter, req *http.Request) {
+ _from := req.URL.Query().Get("from")
+ from, _ := primitive.ObjectIDFromHex(_from)
+
+ if LoggedInOnly(w, req) != nil {
+ return
+ }
+
+ nickname := req.Header.Get("x-cosmos-user")
+
+ if(req.Method == "GET") {
+ c, errCo := GetCollection(GetRootAppId(), "notifications")
+ if errCo != nil {
+ Error("Database Connect", errCo)
+ HTTPError(w, "Database", http.StatusInternalServerError, "DB001")
+ return
+ }
+
+ Debug("Notifications: Get notif for " + nickname)
+
+ notifications := []Notification{}
+
+ reqdb := map[string]interface{}{
+ "Recipient": nickname,
+ }
+
+ if from != primitive.NilObjectID {
+ reqdb = map[string]interface{}{
+ // nickname or role
+ "Recipient": nickname,
+ // get notif before from
+ "_id": map[string]interface{}{
+ "$lt": from,
+ },
+ }
+ }
+
+ limit := int64(20)
+
+ cursor, err := c.Find(nil, reqdb, &options.FindOptions{
+ Sort: map[string]interface{}{
+ "Date": -1,
+ },
+ Limit: &limit,
+ })
+
+ if err != nil {
+ Error("Notifications: Error while getting notifications", err)
+ HTTPError(w, "notifications Get Error", http.StatusInternalServerError, "UD001")
+ return
+ }
+
+ defer cursor.Close(nil)
+
+ if err = cursor.All(nil, ¬ifications); err != nil {
+ Error("Notifications: Error while decoding notifications", err)
+ HTTPError(w, "notifications Get Error", http.StatusInternalServerError, "UD002")
+ return
+ }
+
+ json.NewEncoder(w).Encode(map[string]interface{}{
+ "status": "OK",
+ "data": notifications,
+ })
+ } else {
+ Error("Notifications: Method not allowed" + req.Method, nil)
+ HTTPError(w, "Method not allowed", http.StatusMethodNotAllowed, "HTTP001")
+ return
+ }
+}
+
+func MarkAsRead(w http.ResponseWriter, req *http.Request) {
+ if(req.Method == "GET") {
+ if LoggedInOnly(w, req) != nil {
+ return
+ }
+
+ notificationIDs := []primitive.ObjectID{}
+ nickname := req.Header.Get("x-cosmos-user")
+
+ notificationIDsRawRunes := req.URL.Query().Get("ids")
+
+ notificationIDsRaw := strings.Split(notificationIDsRawRunes, ",")
+
+ Debug(fmt.Sprintf("Marking %v notifications as read",notificationIDsRaw))
+
+ for _, notificationIDRaw := range notificationIDsRaw {
+ notificationID, err := primitive.ObjectIDFromHex(notificationIDRaw)
+
+ if err != nil {
+ HTTPError(w, "Invalid notification ID " + notificationIDRaw, http.StatusBadRequest, "InvalidID")
+ return
+ }
+
+ notificationIDs = append(notificationIDs, notificationID)
+ }
+
+
+ c, errCo := GetCollection(GetRootAppId(), "notifications")
+ if errCo != nil {
+ Error("Database Connect", errCo)
+ HTTPError(w, "Database connection error", http.StatusInternalServerError, "DB001")
+ return
+ }
+
+ filter := bson.M{"_id": bson.M{"$in": notificationIDs}, "Recipient": nickname}
+ update := bson.M{"$set": bson.M{"Read": true}}
+ result, err := c.UpdateMany(nil, filter, update)
+ if err != nil {
+ Error("Notifications: Error while marking notification as read", err)
+ HTTPError(w, "Error updating notification", http.StatusInternalServerError, "UpdateError")
+ return
+ }
+
+ if result.MatchedCount == 0 {
+ HTTPError(w, "No matching notification found", http.StatusNotFound, "NotFound")
+ return
+ }
+
+ json.NewEncoder(w).Encode(map[string]interface{}{
+ "status": "OK",
+ "message": "Notification marked as read",
+ })
+ } else {
+ Error("Notifications: Method not allowed" + req.Method, nil)
+ HTTPError(w, "Method not allowed", http.StatusMethodNotAllowed, "HTTP001")
+ return
+ }
+}
+
+
+func WriteNotification(notification Notification) {
+ notification.Date = time.Now()
+
+ notification.Read = false
+
+ if notification.Recipient == "all" || notification.Recipient == "admin" || notification.Recipient == "user" {
+ // list all users
+ users := ListAllUsers(notification.Recipient)
+
+ Debug("Notifications: Sending notification to " + string(len(users)) + " users")
+
+ for _, user := range users {
+ BufferedDBWrite("notifications", map[string]interface{}{
+ "Title": notification.Title,
+ "Message": notification.Message,
+ "Icon": notification.Icon,
+ "Link": notification.Link,
+ "Date": notification.Date,
+ "Level": notification.Level,
+ "Read": notification.Read,
+ "Recipient": user.Nickname,
+ "Actions": notification.Actions,
+ })
+ }
+ } else {
+ BufferedDBWrite("notifications", map[string]interface{}{
+ "Title": notification.Title,
+ "Message": notification.Message,
+ "Icon": notification.Icon,
+ "Link": notification.Link,
+ "Date": notification.Date,
+ "Level": notification.Level,
+ "Read": notification.Read,
+ "Recipient": notification.Recipient,
+ "Actions": notification.Actions,
+ })
+ }
+}
\ No newline at end of file
diff --git a/src/utils/types.go b/src/utils/types.go
index 9930af2..cd586c9 100644
--- a/src/utils/types.go
+++ b/src/utils/types.go
@@ -92,6 +92,7 @@ type Config struct {
ThemeConfig ThemeConfig
ConstellationConfig ConstellationConfig
MonitoringDisabled bool
+ MonitoringAlerts map[string]Alert
}
type HomepageConfig struct {
@@ -160,6 +161,7 @@ type AddionalFiltersConfig struct {
}
type ProxyRouteConfig struct {
+ Disabled bool
Name string `validate:"required"`
Description string
UseHost bool
@@ -323,3 +325,32 @@ type Device struct {
PrivateKey string `json:"privateKey",omitempty`
IP string `json:"ip",validate:"required,ipv4"`
}
+
+type Alert struct {
+ Name string
+ Enabled bool
+ Period string
+ TrackingMetric string
+ Condition AlertCondition
+ Actions []AlertAction
+ LastTriggered time.Time
+ Throttled bool
+ Severity string
+}
+
+type AlertCondition struct {
+ Operator string
+ Value int
+ Percent bool
+}
+
+type AlertAction struct {
+ Type string
+ Target string
+}
+
+type AlertMetricTrack struct {
+ Key string
+ Object string
+ Max uint64
+}
\ No newline at end of file