This integration enables Coroot to discover RDS and ElastiCache instances and collect their telemetry data. It requires permissions to
describe RDS and ElastiCache instances, read their logs and read Enhanced Monitoring data from CloudWatch.
diff --git a/front/src/views/IntegrationForm.vue b/front/src/views/IntegrationForm.vue
index f9e4f7a64..395bcd6e1 100644
--- a/front/src/views/IntegrationForm.vue
+++ b/front/src/views/IntegrationForm.vue
@@ -19,17 +19,17 @@
-
+
{{ error }}
-
+
{{ message }}
- Delete
+ Delete
- Send test alert
+ Send test alert
Save
diff --git a/front/src/views/Integrations.vue b/front/src/views/Integrations.vue
index 8a11f27a0..096d44f42 100644
--- a/front/src/views/Integrations.vue
+++ b/front/src/views/Integrations.vue
@@ -1,17 +1,20 @@
-
+
{{ error }}
{{ message }}
-
+
+ Notification settings are defined through the config and cannot be modified via the UI.
+
+
Base url
This URL is used for things like creating links in alerts.
- Save
+ Save
@@ -43,8 +46,12 @@
Configure
- mdi-pencil
- mdi-trash-can-outline
+
+ mdi-pencil
+
+
+ mdi-trash-can-outline
+
|
@@ -70,6 +77,7 @@ export default {
loading: false,
error: '',
message: '',
+ readonly: false,
saving: false,
form: {
base_url: '',
@@ -111,6 +119,7 @@ export default {
this.$api.saveIntegrations('', 'save', this.form, () => {});
}
this.integrations = data.integrations;
+ this.readonly = data.readonly;
});
},
save() {
diff --git a/front/src/views/Logs.vue b/front/src/views/Logs.vue
index fbfb8cc57..2b83edde7 100644
--- a/front/src/views/Logs.vue
+++ b/front/src/views/Logs.vue
@@ -1,531 +1,273 @@
-
-
-
-
-
+
+
+ {{ view.message }}
+
-
-
-
-
+
+
+ {{ view.error }}
+
- Filter:
-
-
+ Query:
+
+
-
-
-
-
- pattern: {{ query.hash.substr(0, 7) }}
-
-
-
- Query
-
+
Show logs
-
- View:
-
-
-
- {{ v.icon }}
- {{ v.name }}
-
-
-
- mdi-arrow-up-thickNewest first
- mdi-arrow-down-thickOldest first
-
-
- Limit:
-
-
+
+
+
-
-
-
-
-
-
- {{ loadingError }}
-
-
-
-
-
-
-
-
- Date |
- Message |
-
-
-
-
-
-
- |
- {{ e.multiline ? e.message.substr(0, e.multiline) : e.message }} |
-
-
-
-
No messages found
-
The output is capped at {{ data.limit }} messages.
-
-
-
-
- {{ entry.severity }}
- {{ entry.date }}
-
-
-
mdi-close
-
+
+
- Message
-
- {{ entry.message }}
-
+
+
+ {{ v.icon }}
+ {{ v.title }}
+
+
- Attributes
-
-
-
- {{ k }} |
-
-
- {{ v }}
-
- {{ v }}
- |
-
-
-
-
- Show similar messages
-
-
- Show the trace
-
-
-
-
+
-
-
-
-
{{ p.sample }}
-
-
-
-
{{ p.percent }}
-
-
-
No patterns found
-
-
-
-
-
{{ pattern.severity }}
- {{ pattern.sum }} events
+
+
+
+
+ Date |
+ Application |
+ Message |
+
+
+
+
+
+
-
- mdi-close
-
-
- Sample
-
- {{ pattern.sample }}
-
- Show messages
-
-
-
-
-
-
-
-
-
- Link "{{ $utils.appId(appId).name }}" with a service
-
- mdi-close
-
-
- Choose a corresponding OpenTelemetry service:
-
-
-
- To configure an application to send logs follow the
- documentation.
+ |
+
+
+
+ {{ e.application }}
+
+
+
+
+ mdi-plus
+ add to search
+
+
+ mdi-minus
+ exclude from search
+
+
+
+ mdi-open-in-new
+ go to application
+
+
+
+ |
+ {{ e.multiline ? e.message.substr(0, e.multiline) : e.message }} |
+
+
+
+
No messages found
+
+ The output is capped at
+
+ messages.
-
-
- {{ error }}
-
-
- {{ message }}
-
-
Save
-
-
-
+
+
+
+
diff --git a/front/src/views/Node.vue b/front/src/views/Node.vue
index d304470a1..3f1461df2 100644
--- a/front/src/views/Node.vue
+++ b/front/src/views/Node.vue
@@ -1,13 +1,6 @@
-
-
- {{ error }}
-
-
-
- {{ name }}
-
-
+
+ {{ name }}
@@ -16,10 +9,11 @@
-
+
diff --git a/front/src/views/Project.vue b/front/src/views/Project.vue
index 5ddf017e7..8d0132abe 100644
--- a/front/src/views/Project.vue
+++ b/front/src/views/Project.vue
@@ -1,6 +1,6 @@
-
Configuration
+
Configuration
@@ -50,6 +50,11 @@
+
+ AI-Powered Root Cause Analysis
+
+
+
AWS integration
@@ -75,7 +80,9 @@
You can organize your applications into groups by defining
glob patterns
- in the <namespace>/<application_name> format.
+ in the <namespace>/<application_name> format. For Kubernetes applications, categories can also be defined by
+ annotating Kubernetes objects. Refer the
+ documentation for more details.
@@ -88,7 +95,7 @@
Coroot groups individual containers into applications using the following approach:
-
+
- Kubernetes metadata: Pods are grouped into Deployments, StatefulSets, etc.
-
Non-Kubernetes containers: Containers such as Docker containers or Systemd units are grouped into applications by their
@@ -97,11 +104,13 @@
-
+
This default approach works well in most cases. However, since no one knows your system better than you do, Coroot allows you to
manually adjust application groupings to better fit your specific needs. You can match desired application instances by defining
glob patterns
- for instance_name. Note that this is not applicable to Kubernetes applications.
+ for instance_name. Note that this does not apply to Kubernetes applications, which can be customized by annotating
+ Kubernetes objects. Refer the
+ documentation for more details.
@@ -158,6 +167,7 @@ import CustomApplications from './CustomApplications.vue';
import Users from './Users.vue';
import RBAC from './RBAC.vue';
import SSO from './SSO.vue';
+import IntegrationAI from '@/views/IntegrationAI.vue';
export default {
props: {
@@ -166,6 +176,7 @@ export default {
},
components: {
+ IntegrationAI,
CustomApplications,
IntegrationPrometheus,
IntegrationClickhouse,
@@ -195,6 +206,7 @@ export default {
{ id: undefined, name: 'General' },
{ id: 'prometheus', name: 'Prometheus', disabled },
{ id: 'clickhouse', name: 'Clickhouse', disabled },
+ { id: 'ai', name: 'AI', disabled },
{ id: 'aws', name: 'AWS', disabled },
{ id: 'inspections', name: 'Inspections', disabled },
{ id: 'applications', name: 'Applications', disabled },
diff --git a/front/src/views/ProjectSettings.vue b/front/src/views/ProjectSettings.vue
index 473c9cc18..f50f1886c 100644
--- a/front/src/views/ProjectSettings.vue
+++ b/front/src/views/ProjectSettings.vue
@@ -6,15 +6,17 @@
Project is a separate infrastructure or environment, e.g. production, staging or prod-us-west.
-
+
+
-
- {{ error }}
-
-
- {{ message }}
-
- Save
+
+ {{ error }}
+
+
+ {{ message }}
+
+ Save
+
@@ -65,6 +67,9 @@ export default {
});
},
save() {
+ if (!this.valid) {
+ return;
+ }
this.loading = true;
this.error = '';
this.message = '';
diff --git a/front/src/views/RCA.vue b/front/src/views/RCA.vue
index 9e1da4244..83d2bcc19 100644
--- a/front/src/views/RCA.vue
+++ b/front/src/views/RCA.vue
@@ -1,10 +1,6 @@
-
-
-
-
- {{ error }}
-
+
+ {{ $utils.appId(appId).name }}
AI-powered Root Cause Analysis is available only in Coroot Enterprise (from $1 per CPU core/month).
@@ -49,6 +45,57 @@
+
+ Issue propagation path
+
+
+
+
+
+
Anomaly Summary
+
+
+
+
+
+
+
+
+ mdi-creation
+ Explain with AI
+
+
+
+
+
+
Anomaly Summary
+
+
+
+
+
+
+
+
+ mdi-creation
+ Enable an AI integration
+
+ Dismiss
+
+
+
+
Possible causes
@@ -100,13 +147,16 @@
-
+
-
+
diff --git a/front/src/views/Risks.vue b/front/src/views/Risks.vue
index 1d131ed89..118ae279e 100644
--- a/front/src/views/Risks.vue
+++ b/front/src/views/Risks.vue
@@ -1,12 +1,6 @@
-
-
-
-
- {{ error }}
-
-
-
+
+
@@ -119,6 +113,9 @@
{{ $pluralize('port', item.exposure.ports.length) }} {{ item.exposure.ports.join(', ') }}
+
+ {{ item.availability.description }}
+
Dismissed by {{ item.dismissal.by }} ({{ $format.date(item.dismissal.timestamp * 1000, '{YYYY}-{MM}-{DD} {HH}:{mm}:{ss}') }}) as
@@ -153,10 +150,11 @@
-
+
diff --git a/front/src/views/Search.vue b/front/src/views/Search.vue
index a20fbecef..a84f1686b 100644
--- a/front/src/views/Search.vue
+++ b/front/src/views/Search.vue
@@ -1,89 +1,109 @@
-
-
-
+
+
+
+
Search for apps and nodes
+
+
mdi-close
+
+
+
mdi-magnify
-
-
-
-
-
- {{ error }}
-
-
-
-
-
- no entries found
-
-
-
-
- mdi-apps
-
- Applications
-
-
-
-
-
- {{ a.name }}
- (ns: {{ a.ns }})
-
-
-
-
-
- mdi-server
-
- Nodes
-
-
-
-
-
- {{ n.name }}
-
-
-
-
-
+
+
+
+ {{ error }}
+
+
+ No items found
+
+
+
+
+ mdi-apps
+
+ Applications
+
+
+
+
+
+
+ {{ a.name }}
+ (ns: {{ a.ns }})
+
+
+
+
+
+
+ mdi-server
+
+ Nodes
+
+
+
+
+
+
+ {{ n.name }}
+
+
+
+
+
+
+
+
diff --git a/front/src/views/ServiceMap.vue b/front/src/views/ServiceMap.vue
index 19279e3e9..ad37ccb4d 100644
--- a/front/src/views/ServiceMap.vue
+++ b/front/src/views/ServiceMap.vue
@@ -1,14 +1,8 @@
-
-
+
+
-
- {{ error }}
-
-
-
-
-
+
Too many applications ({{ tooManyApplications }}) to render. Please choose a different category or namespace.
@@ -23,7 +17,7 @@
>
+
+
+
diff --git a/front/src/views/auth/Saml.vue b/front/src/views/auth/Saml.vue
index 810505adf..32d0ceef6 100644
--- a/front/src/views/auth/Saml.vue
+++ b/front/src/views/auth/Saml.vue
@@ -1,12 +1,15 @@
-
+
+
+ mdi-information-outline
+
Your SAML integration appears to be improperly configured.
Coroot expects to receive the following attributes from your Identity Provider: Email, FirstName, and LastName.
- Authentication using SAML integration was unsuccessful. Please try again later.
+ Authentication using SAML integration was unsuccessful.
Refresh
Login as Admin
@@ -24,4 +27,10 @@ export default {
};
-
+
diff --git a/front/src/views/dashboards/Dashboard.vue b/front/src/views/dashboards/Dashboard.vue
new file mode 100644
index 000000000..3aed8e1f3
--- /dev/null
+++ b/front/src/views/dashboards/Dashboard.vue
@@ -0,0 +1,286 @@
+
+
+ {{ dashboard.name }}
+
+
+
+
+ Add panel
+ Save
+ Cancel
+
+
+ mdi-refresh
+ mdi-pencil-outline
+
+
+
+
+
No panels are configured yet.
+
{
+ edit = true;
+ panel = {};
+ }
+ "
+ >
+ mdi-plus
+ Add panel
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/front/src/views/dashboards/Dashboards.vue b/front/src/views/dashboards/Dashboards.vue
new file mode 100644
index 000000000..f85616461
--- /dev/null
+++ b/front/src/views/dashboards/Dashboards.vue
@@ -0,0 +1,155 @@
+
+
+
+
+
+
+
+ {{ name }}
+
+
+
+ mdi-pencil-outline
+
+
+ mdi-trash-can-outline
+
+
+
+
+
+ mdi-plus
+ Add dashboard
+
+
+
+
+
+ Create dashboard
+ Delete dashboard
+ Edit dashboard
+
+ mdi-close
+
+
+ Name
+
+ Description
+
+
+
+ {{ error }}
+
+
+ {{ message }}
+
+
+
+ Delete
+ Save
+ Cancel
+
+
+
+
+
+
+
+
+
diff --git a/front/src/views/dashboards/GroupForm.vue b/front/src/views/dashboards/GroupForm.vue
new file mode 100644
index 000000000..396d3a1e5
--- /dev/null
+++ b/front/src/views/dashboards/GroupForm.vue
@@ -0,0 +1,52 @@
+
+
+
+
+ Delete panel group
+ Edit panel group
+
+ mdi-close
+
+
+ Name
+
+
+
+
+ Delete
+ Apply
+ Cancel
+
+
+
+
+
+
+
diff --git a/front/src/views/dashboards/Panel.vue b/front/src/views/dashboards/Panel.vue
new file mode 100644
index 000000000..9b616405c
--- /dev/null
+++ b/front/src/views/dashboards/Panel.vue
@@ -0,0 +1,92 @@
+
+
+
+
+
+ {{ config.name }}
+
+
+ mdi-information-outline
+
+
+ {{ config.description }}
+
+
+
+
+
+ mdi-pencil-outline
+ mdi-trash-can-outline
+ mdi-drag
+
+
+ {{ error }}
+
+ No data
+
+
+
+
+
+
+
diff --git a/front/src/views/dashboards/PanelForm.vue b/front/src/views/dashboards/PanelForm.vue
new file mode 100644
index 000000000..24b3db83b
--- /dev/null
+++ b/front/src/views/dashboards/PanelForm.vue
@@ -0,0 +1,144 @@
+
+
+
+
+
{{ action }} panel
+
+
mdi-close
+
+
+
+
+ Name
+
+
+
+ Group
+
+
+
+
+
+ Description
+
+
+
+ Type
+
+
+
+
+ Preview
+
+
+
+
Query #{{ i + 1 }}
+
PromQL expression.
+
+
+
Legend
+
+ Text to be displayed in the legend and the tooltip. Use {{ label_name }} to interpolate label values.
+
+
+
+
+ mdi-plus
+ Add query
+
+
+
+
+
Display
+
+ Line
+ Bar
+
+
+
+
+
+ Apply
+ Cancel
+
+
+
+
+
+
+
+
diff --git a/go.mod b/go.mod
index 49f4abfb3..97ef85f9d 100644
--- a/go.mod
+++ b/go.mod
@@ -7,7 +7,7 @@ require (
github.com/ClickHouse/clickhouse-go/v2 v2.8.3
github.com/DataDog/golz4 v1.3.0
github.com/PagerDuty/go-pagerduty v1.6.0
- github.com/atc0005/go-teams-notify/v2 v2.7.0
+ github.com/atc0005/go-teams-notify/v2 v2.13.0
github.com/buger/jsonparser v1.1.1
github.com/coroot/logparser v1.1.4
github.com/dustin/go-humanize v1.0.1
diff --git a/go.sum b/go.sum
index 9589cd0b8..347b4d69a 100644
--- a/go.sum
+++ b/go.sum
@@ -27,8 +27,8 @@ github.com/alecthomas/units v0.0.0-20240626203959-61d1e3462e30/go.mod h1:fvzegU4
github.com/andybalholm/brotli v1.0.5 h1:8uQZIdzKmjc/iuPu7O2ioW48L81FgatrcpfFmiq/cCs=
github.com/andybalholm/brotli v1.0.5/go.mod h1:fO7iG3H7G2nSZ7m0zPUDn85XEX2GTukHGRSepvi9Eig=
github.com/armon/go-radix v0.0.0-20180808171621-7fddfc383310/go.mod h1:ufUuZ+zHj4x4TnLV4JWEpy2hxWSpsRywHrMgIH9cCH8=
-github.com/atc0005/go-teams-notify/v2 v2.7.0 h1:yRKblRTM/v+FnbibPAQiBcgT+aUBn/8zj9E/UxBdIRg=
-github.com/atc0005/go-teams-notify/v2 v2.7.0/go.mod h1:nJeYAr8U1KtT376MUHHiy47nqy/4Mn0UR8veVQxdMcM=
+github.com/atc0005/go-teams-notify/v2 v2.13.0 h1:nbDeHy89NjYlF/PEfLVF6lsserY9O5SnN1iOIw3AxXw=
+github.com/atc0005/go-teams-notify/v2 v2.13.0/go.mod h1:WSv9moolRsBcpZbwEf6gZxj7h0uJlJskJq5zkEWKO8Y=
github.com/aws/aws-sdk-go v1.55.5 h1:KKUZBfBoyqy5d3swXyiC7Q76ic40rYcbqH7qjh59kzU=
github.com/aws/aws-sdk-go v1.55.5/go.mod h1:eRwEWoyTWFMVYVQzKMNHWP5/RV4xIUGMQfXQHfHkpNU=
github.com/bboreham/go-loser v0.0.0-20230920113527-fcc2c21820a3 h1:6df1vn4bBlDDo4tARvBm7l6KA9iVMnE3NWizDeWSrps=
@@ -216,7 +216,6 @@ github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
-github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
diff --git a/main.go b/main.go
index 3409b0335..ef9dc4c2c 100644
--- a/main.go
+++ b/main.go
@@ -28,6 +28,8 @@ import (
"k8s.io/klog"
)
+const Edition = "Community"
+
var version = "unknown"
//go:embed static
@@ -39,11 +41,14 @@ func main() {
cmd := kingpin.Parse()
+ klog.Infof("edition: %s", Edition)
klog.Infof("version: %s", version)
- cfg := config.Load()
+ cfg, err := config.Load()
+ if err != nil {
+ klog.Exitf("failed to load config: %s", err)
+ }
- var err error
if err = utils.CreateDirectoryIfNotExists(cfg.DataDir); err != nil {
klog.Exitln(err)
}
@@ -123,9 +128,10 @@ func main() {
instanceUuid := utils.GetInstanceUuid(cfg.DataDir)
- statsCollector := stats.NewCollector(cfg.DisableUsageStatistics, instanceUuid, version, database, promCache, pricing, globalClickhouse)
+ statsCollector := stats.NewCollector(cfg.DisableUsageStatistics, instanceUuid, version, Edition, database, promCache, pricing, globalClickhouse)
router := mux.NewRouter()
+ router.Use(statsCollector.MiddleWare)
router.PathPrefix("/debug/pprof/").Handler(http.DefaultServeMux)
router.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) {}).Methods(http.MethodGet)
@@ -146,15 +152,20 @@ func main() {
r.HandleFunc("/api/users", a.Auth(a.Users)).Methods(http.MethodGet, http.MethodPost)
r.HandleFunc("/api/roles", a.Auth(a.Roles)).Methods(http.MethodGet, http.MethodPost)
r.HandleFunc("/api/sso", a.Auth(a.SSO)).Methods(http.MethodGet, http.MethodPost)
+ r.HandleFunc("/api/ai", a.Auth(a.AI)).Methods(http.MethodGet, http.MethodPost)
r.HandleFunc("/api/project/", a.Auth(a.Project)).Methods(http.MethodGet, http.MethodPost)
r.HandleFunc("/api/project/{project}", a.Auth(a.Project)).Methods(http.MethodGet, http.MethodPost, http.MethodDelete)
r.HandleFunc("/api/project/{project}/status", a.Auth(a.Status)).Methods(http.MethodGet)
r.HandleFunc("/api/project/{project}/api_keys", a.Auth(a.ApiKeys)).Methods(http.MethodGet, http.MethodPost)
r.HandleFunc("/api/project/{project}/overview/{view}", a.Auth(a.Overview)).Methods(http.MethodGet)
r.HandleFunc("/api/project/{project}/incident/{incident}", a.Auth(a.Incident)).Methods(http.MethodGet)
+ r.HandleFunc("/api/project/{project}/dashboards", a.Auth(a.Dashboards)).Methods(http.MethodGet, http.MethodPost)
+ r.HandleFunc("/api/project/{project}/dashboards/{dashboard}", a.Auth(a.Dashboards)).Methods(http.MethodGet, http.MethodPost)
+ r.HandleFunc("/api/project/{project}/panel/data", a.Auth(a.PanelData)).Methods(http.MethodGet)
r.HandleFunc("/api/project/{project}/inspections", a.Auth(a.Inspections)).Methods(http.MethodGet)
- r.HandleFunc("/api/project/{project}/categories", a.Auth(a.Categories)).Methods(http.MethodGet, http.MethodPost)
+ r.HandleFunc("/api/project/{project}/application_categories", a.Auth(a.ApplicationCategories)).Methods(http.MethodGet, http.MethodPost)
r.HandleFunc("/api/project/{project}/custom_applications", a.Auth(a.CustomApplications)).Methods(http.MethodGet, http.MethodPost)
+ r.HandleFunc("/api/project/{project}/custom_cloud_pricing", a.Auth(a.CustomCloudPricing)).Methods(http.MethodGet, http.MethodPost, http.MethodDelete)
r.HandleFunc("/api/project/{project}/integrations", a.Auth(a.Integrations)).Methods(http.MethodGet, http.MethodPut)
r.HandleFunc("/api/project/{project}/integrations/{type}", a.Auth(a.Integration)).Methods(http.MethodGet, http.MethodPut, http.MethodDelete, http.MethodPost)
r.HandleFunc("/api/project/{project}/app/{app}", a.Auth(a.Application)).Methods(http.MethodGet)
@@ -217,7 +228,7 @@ func readIndexHtml(basePath, version, instanceUuid string, checkForUpdates bool,
Version: version,
Uuid: instanceUuid,
CheckForUpdates: checkForUpdates,
- Edition: "Community",
+ Edition: Edition,
})
if err != nil {
klog.Exitln(err)
diff --git a/model/annotations.go b/model/annotations.go
new file mode 100644
index 000000000..98447f595
--- /dev/null
+++ b/model/annotations.go
@@ -0,0 +1,71 @@
+package model
+
+import (
+ "github.com/coroot/coroot/timeseries"
+ "github.com/coroot/coroot/utils"
+)
+
+type ApplicationAnnotation string
+
+const (
+ ApplicationAnnotationCategory ApplicationAnnotation = "application_category"
+ ApplicationAnnotationCustomName ApplicationAnnotation = "custom_application_name"
+ ApplicationAnnotationSLOAvailabilityObjective ApplicationAnnotation = "slo_availability_objective"
+ ApplicationAnnotationSLOLatencyObjective ApplicationAnnotation = "slo_latency_objective"
+ ApplicationAnnotationSLOLatencyThreshold ApplicationAnnotation = "slo_latency_threshold"
+)
+
+var ApplicationAnnotationsList = []ApplicationAnnotation{
+ ApplicationAnnotationCategory,
+ ApplicationAnnotationCustomName,
+ ApplicationAnnotationSLOAvailabilityObjective,
+ ApplicationAnnotationSLOLatencyObjective,
+ ApplicationAnnotationSLOLatencyThreshold,
+}
+
+var ApplicationAnnotationLabels = map[string]ApplicationAnnotation{}
+
+func init() {
+ for _, aa := range ApplicationAnnotationsList {
+ ApplicationAnnotationLabels["annotation_coroot_com_"+string(aa)] = aa
+ }
+}
+
+type ApplicationAnnotations map[ApplicationAnnotation]*LabelLastValue
+
+func (aas ApplicationAnnotations) UpdateFromLabels(ls Labels, ts *timeseries.TimeSeries) {
+ for n, v := range ls {
+ aa := ApplicationAnnotationLabels[n]
+ if aa == "" || v == "" {
+ continue
+ }
+ lv := aas[aa]
+ if lv == nil {
+ lv = &LabelLastValue{}
+ aas[aa] = lv
+ }
+ lv.Update(ts, v)
+ }
+}
+
+func (app *Application) GetAnnotation(aa ApplicationAnnotation) string {
+ if lv := app.Annotations[aa]; lv != nil {
+ return lv.Value()
+ }
+ var instanceAnnotations *utils.StringSet
+ for _, i := range app.Instances {
+ if i.IsObsolete() {
+ continue
+ }
+ if lv := i.Annotations[aa]; lv != nil {
+ if instanceAnnotations == nil {
+ instanceAnnotations = utils.NewStringSet()
+ }
+ instanceAnnotations.Add(lv.Value())
+ }
+ }
+ if instanceAnnotations == nil {
+ return ""
+ }
+ return instanceAnnotations.GetFirst()
+}
diff --git a/model/application.go b/model/application.go
index a657d0e52..3a7289e9e 100644
--- a/model/application.go
+++ b/model/application.go
@@ -2,10 +2,12 @@ package model
import (
"fmt"
+ "sort"
"strconv"
"github.com/coroot/coroot/timeseries"
"github.com/coroot/coroot/utils"
+ "golang.org/x/exp/maps"
)
type Application struct {
@@ -14,6 +16,8 @@ type Application struct {
Custom bool
Category ApplicationCategory
+ Annotations ApplicationAnnotations
+
Instances []*Instance
instancesByName map[string][]*Instance
@@ -31,7 +35,7 @@ type Application struct {
Deployments []*ApplicationDeployment
Incidents []*ApplicationIncident
- LogMessages map[LogLevel]*LogMessages
+ LogMessages map[Severity]*LogMessages
Status Status
Reports []*AuditReport
@@ -39,15 +43,22 @@ type Application struct {
Settings *ApplicationSettings
KubernetesServices []*Service
+
+ DNSRequests map[DNSRequest]map[string]*timeseries.TimeSeries
+ DNSRequestsHistogram map[float32]*timeseries.TimeSeries
}
func NewApplication(id ApplicationId) *Application {
app := &Application{
Id: id,
+ Annotations: ApplicationAnnotations{},
instancesByName: map[string][]*Instance{},
- LogMessages: map[LogLevel]*LogMessages{},
+ LogMessages: map[Severity]*LogMessages{},
Upstreams: map[ApplicationId]*AppToAppConnection{},
Downstreams: map[ApplicationId]*AppToAppConnection{},
+
+ DNSRequests: map[DNSRequest]map[string]*timeseries.TimeSeries{},
+ DNSRequestsHistogram: map[float32]*timeseries.TimeSeries{},
}
return app
}
@@ -312,6 +323,26 @@ func (app *Application) ApplicationTypes() map[ApplicationType]bool {
return res
}
+func (app *Application) ApplicationType() ApplicationType {
+ types := maps.Keys(app.ApplicationTypes())
+ if len(types) == 0 {
+ return ApplicationTypeUnknown
+ }
+
+ if len(types) == 1 {
+ return types[0]
+ }
+ sort.Slice(types, func(i, j int) bool {
+ ti, tj := types[i], types[j]
+ tiw, tjw := ti.Weight(), tj.Weight()
+ if tiw == tjw {
+ return ti < tj
+ }
+ return tiw < tjw
+ })
+ return types[0]
+}
+
func (app *Application) PeriodicJob() bool {
switch app.Id.Kind {
case ApplicationKindJob, ApplicationKindCronJob:
diff --git a/model/application_category.go b/model/application_category.go
index da0b9f107..583a396ee 100644
--- a/model/application_category.go
+++ b/model/application_category.go
@@ -1,12 +1,5 @@
package model
-import (
- "fmt"
- "sort"
-
- "github.com/coroot/coroot/utils"
-)
-
type ApplicationCategory string
const (
@@ -48,7 +41,7 @@ var BuiltinCategoryPatterns = map[ApplicationCategory][]string{
"*/containerd",
"*/docker*",
"*/*chaos-*",
- "istio-system/*",
+ "istio*/*",
"amazon-cloudwatch/*",
"karpenter/*",
"cert-manager/*",
@@ -59,7 +52,7 @@ var BuiltinCategoryPatterns = map[ApplicationCategory][]string{
"keda/*",
"keycloak/*",
"longhorn-system/*",
- "calico-system/*",
+ "calico*/*",
"_/esm-cache",
"_/*motd*",
"_/*apt*",
@@ -70,6 +63,11 @@ var BuiltinCategoryPatterns = map[ApplicationCategory][]string{
"litmus/*",
"openshift*/*",
"_/crio*",
+ "*/coredns",
+ "chaos-mesh/*",
+ "cilium/*",
+ "external-dns/*",
+ "gpu-operator/*",
},
ApplicationCategoryMonitoring: {
"monitoring/*",
@@ -83,30 +81,8 @@ var BuiltinCategoryPatterns = map[ApplicationCategory][]string{
"metrics-server/*",
"loki/*",
"observability/*",
+ "jaeger/*",
+ "thanos/*",
+ "sentry/*",
},
}
-
-func CalcApplicationCategory(appId ApplicationId, customPatterns map[ApplicationCategory][]string) ApplicationCategory {
- categories := make([]ApplicationCategory, 0, len(BuiltinCategoryPatterns)+len(customPatterns))
- for c := range BuiltinCategoryPatterns {
- categories = append(categories, c)
- }
- for c := range customPatterns {
- if _, ok := BuiltinCategoryPatterns[c]; ok {
- continue
- }
- categories = append(categories, c)
- }
- sort.Slice(categories, func(i, j int) bool {
- return categories[i] < categories[j]
- })
-
- id := fmt.Sprintf("%s/%s", appId.Namespace, appId.Name)
- for _, c := range categories {
- if utils.GlobMatch(id, BuiltinCategoryPatterns[c]...) || utils.GlobMatch(id, customPatterns[c]...) {
- return c
- }
- }
-
- return ApplicationCategoryApplication
-}
diff --git a/model/application_types.go b/model/application_types.go
index 50bf2afd0..ed0a910e1 100644
--- a/model/application_types.go
+++ b/model/application_types.go
@@ -31,6 +31,7 @@ const (
ApplicationTypeJava ApplicationType = "java"
ApplicationTypeNodeJS ApplicationType = "nodejs"
ApplicationTypePython ApplicationType = "python"
+ ApplicationTypeRuby ApplicationType = "ruby"
ApplicationTypeEnvoy ApplicationType = "envoy"
ApplicationTypePrometheus ApplicationType = "prometheus"
ApplicationTypeVictoriaMetrics ApplicationType = "victoria-metrics"
@@ -72,7 +73,13 @@ func (at ApplicationType) IsQueue() bool {
func (at ApplicationType) IsLanguage() bool {
switch at {
- case ApplicationTypeGolang, ApplicationTypeDotNet, ApplicationTypePHP, ApplicationTypeJava, ApplicationTypeNodeJS:
+ case ApplicationTypeGolang,
+ ApplicationTypeDotNet,
+ ApplicationTypePHP,
+ ApplicationTypeJava,
+ ApplicationTypeNodeJS,
+ ApplicationTypePython,
+ ApplicationTypeRuby:
return true
}
return false
@@ -98,7 +105,7 @@ func (at ApplicationType) AuditReport() AuditReportName {
return AuditReportPostgres
case ApplicationTypeMysql:
return AuditReportMysql
- case ApplicationTypeRedis:
+ case ApplicationTypeRedis, ApplicationTypeKeyDB, ApplicationTypeValkey, ApplicationTypeDragonfly:
return AuditReportRedis
case ApplicationTypeMongodb, ApplicationTypeMongos:
return AuditReportMongodb
@@ -136,7 +143,7 @@ func (at ApplicationType) Icon() string {
return "postgres"
case at == ApplicationTypeMongos:
return "mongodb"
- case at == ApplicationTypeValkey || at == ApplicationTypeKeyDB || at == ApplicationTypeDragonfly:
+ case at == ApplicationTypeKeyDB || at == ApplicationTypeDragonfly:
return "redis"
case at == ApplicationTypeVictoriaMetrics || at == ApplicationTypeVictoriaLogs:
return "victoriametrics"
diff --git a/model/audit_report.go b/model/audit_report.go
index b8488adb6..e96066982 100644
--- a/model/audit_report.go
+++ b/model/audit_report.go
@@ -1,6 +1,7 @@
package model
import (
+ "slices"
"strings"
"github.com/coroot/coroot/timeseries"
@@ -13,6 +14,7 @@ const (
AuditReportSLO AuditReportName = "SLO"
AuditReportInstances AuditReportName = "Instances"
AuditReportCPU AuditReportName = "CPU"
+ AuditReportGPU AuditReportName = "GPU"
AuditReportMemory AuditReportName = "Memory"
AuditReportStorage AuditReportName = "Storage"
AuditReportNetwork AuditReportName = "Net"
@@ -134,7 +136,7 @@ func (r *AuditReport) GetOrCreateTable(header ...string) *Table {
return nil
}
for _, w := range r.Widgets {
- if t := w.Table; t != nil {
+ if t := w.Table; t != nil && slices.Equal(t.Header, header) {
return t
}
}
diff --git a/model/chart.go b/model/chart.go
index 7570b9337..72a1b7d74 100644
--- a/model/chart.go
+++ b/model/chart.go
@@ -146,11 +146,21 @@ func (ch *Chart) AddSeries(name string, data SeriesData, color ...string) *Chart
if data.IsEmpty() {
return ch
}
- s := &Series{Name: name, Data: data}
+ var ns *Series
+ for _, s := range ch.Series.series {
+ if s.Name == name {
+ ns = s
+ break
+ }
+ }
+ if ns == nil {
+ ns = &Series{Name: name}
+ ch.Series.series = append(ch.Series.series, ns)
+ }
+ ns.Data = data
if len(color) > 0 {
- s.Color = color[0]
+ ns.Color = color[0]
}
- ch.Series.series = append(ch.Series.series, s)
return ch
}
diff --git a/model/check.go b/model/check.go
index b56778d9f..8c5fdf2b9 100644
--- a/model/check.go
+++ b/model/check.go
@@ -523,6 +523,12 @@ func (ch *Check) Calc() {
ch.SetStatus(WARNING, buf.String())
}
+type CheckConfigSource string
+
+const (
+ CheckConfigSourceKubernetesAnnotations CheckConfigSource = "kubernetes-annotations"
+)
+
type CheckConfigSimple struct {
Threshold float32 `json:"threshold"`
}
@@ -532,6 +538,9 @@ type CheckConfigSLOAvailability struct {
TotalRequestsQuery string `json:"total_requests_query"`
FailedRequestsQuery string `json:"failed_requests_query"`
ObjectivePercentage float32 `json:"objective_percentage"`
+
+ Source CheckConfigSource `json:"source,omitempty"`
+ Error string `json:"error,omitempty"`
}
func (cfg *CheckConfigSLOAvailability) Total() string {
@@ -547,6 +556,9 @@ type CheckConfigSLOLatency struct {
HistogramQuery string `json:"histogram_query"`
ObjectiveBucket float32 `json:"objective_bucket"`
ObjectivePercentage float32 `json:"objective_percentage"`
+
+ Source CheckConfigSource `json:"source,omitempty"`
+ Error string `json:"error,omitempty"`
}
func (cfg *CheckConfigSLOLatency) Histogram() string {
@@ -555,20 +567,20 @@ func (cfg *CheckConfigSLOLatency) Histogram() string {
type CheckConfigs map[ApplicationId]map[CheckId]json.RawMessage
-func (cc CheckConfigs) getRaw(appId ApplicationId, checkId CheckId) json.RawMessage {
+func (cc CheckConfigs) getRaw(appId ApplicationId, checkId CheckId) (json.RawMessage, bool) {
for _, i := range []ApplicationId{appId, {}} {
if appConfigs, ok := cc[i]; ok {
if cfg, ok := appConfigs[checkId]; ok {
- return cfg
+ return cfg, i.IsZero()
}
}
}
- return nil
+ return nil, false
}
func (cc CheckConfigs) GetSimple(checkId CheckId, appId ApplicationId) CheckConfigSimple {
cfg := CheckConfigSimple{Threshold: Checks.index[checkId].DefaultThreshold}
- raw := cc.getRaw(appId, checkId)
+ raw, _ := cc.getRaw(appId, checkId)
if raw == nil {
return cfg
}
@@ -639,12 +651,9 @@ func (cc CheckConfigs) GetAvailability(appId ApplicationId) (CheckConfigSLOAvail
Custom: false,
ObjectivePercentage: Checks.SLOAvailability.DefaultThreshold,
}
- appConfigs := cc[appId]
- if appConfigs == nil {
- return defaultCfg, true
- }
- raw, ok := appConfigs[Checks.SLOAvailability.Id]
- if !ok {
+
+ raw, _ := cc.getRaw(appId, Checks.SLOAvailability.Id)
+ if raw == nil {
return defaultCfg, true
}
res, err := unmarshal[[]CheckConfigSLOAvailability](raw)
@@ -660,20 +669,17 @@ func (cc CheckConfigs) GetAvailability(appId ApplicationId) (CheckConfigSLOAvail
func (cc CheckConfigs) GetLatency(appId ApplicationId, category ApplicationCategory) (CheckConfigSLOLatency, bool) {
objectiveBucket := float32(0.5)
+ auxObjectiveBucket := float32(5)
if category.Auxiliary() {
- objectiveBucket = 5
+ objectiveBucket = auxObjectiveBucket
}
defaultCfg := CheckConfigSLOLatency{
Custom: false,
ObjectivePercentage: Checks.SLOLatency.DefaultThreshold,
ObjectiveBucket: objectiveBucket,
}
- appConfigs := cc[appId]
- if appConfigs == nil {
- return defaultCfg, true
- }
- raw, ok := appConfigs[Checks.SLOLatency.Id]
- if !ok {
+ raw, projectDefault := cc.getRaw(appId, Checks.SLOLatency.Id)
+ if raw == nil {
return defaultCfg, true
}
res, err := unmarshal[[]CheckConfigSLOLatency](raw)
@@ -684,6 +690,11 @@ func (cc CheckConfigs) GetLatency(appId ApplicationId, category ApplicationCateg
if len(res) == 0 {
return defaultCfg, true
}
+ if projectDefault && category.Auxiliary() {
+ v := res[0]
+ v.ObjectiveBucket = auxObjectiveBucket
+ return v, false
+ }
return res[0], false
}
diff --git a/model/connection.go b/model/connection.go
index 6f5b426ad..5537e5f91 100644
--- a/model/connection.go
+++ b/model/connection.go
@@ -69,8 +69,6 @@ type Connection struct {
Instance *Instance
RemoteInstance *Instance
- Container string
-
Rtt *timeseries.TimeSeries
SuccessfulConnections *timeseries.TimeSeries
@@ -104,17 +102,6 @@ func (c *AppToAppConnection) IsActual() bool {
return (c.SuccessfulConnections.Last() > 0) || (c.Active.Last() > 0) || c.FailedConnections.Last() > 0
}
-func (c *AppToAppConnection) IsEmpty() bool {
- switch {
- case c.Active.Reduce(timeseries.NanSum) > 0:
- case c.SuccessfulConnections.Reduce(timeseries.NanSum) > 0:
- case c.FailedConnections.Reduce(timeseries.NanSum) > 0:
- default:
- return true
- }
- return false
-}
-
func (c *AppToAppConnection) HasConnectivityIssues() bool {
if !c.IsActual() {
return false
@@ -187,7 +174,7 @@ func (c *AppToAppConnection) GetConnectionsRequestsLatency(protocolFilter func(p
requests.Add(ts)
}
req := requests.Get()
- time.Add(timeseries.Mul(latency, req))
+ time.Add(latency)
count.Add(req)
}
return timeseries.Div(time.Get(), count.Get())
diff --git a/model/container.go b/model/container.go
index fca423660..e453e9e3c 100644
--- a/model/container.go
+++ b/model/container.go
@@ -52,18 +52,13 @@ type Container struct {
MemoryRequest *timeseries.TimeSeries
OOMKills *timeseries.TimeSeries
-
- DNSRequests map[DNSRequest]map[string]*timeseries.TimeSeries
- DNSRequestsHistogram map[float32]*timeseries.TimeSeries
}
func NewContainer(id, name string) *Container {
return &Container{
- Id: id,
- Name: name,
- ApplicationTypes: map[ApplicationType]bool{},
- DNSRequests: map[DNSRequest]map[string]*timeseries.TimeSeries{},
- DNSRequestsHistogram: map[float32]*timeseries.TimeSeries{},
+ Id: id,
+ Name: name,
+ ApplicationTypes: map[ApplicationType]bool{},
}
}
diff --git a/model/custom_application.go b/model/custom_application.go
index e9ebd932c..a822c9429 100644
--- a/model/custom_application.go
+++ b/model/custom_application.go
@@ -1,5 +1,5 @@
package model
type CustomApplication struct {
- InstancePattens []string `json:"instance_pattens"`
+ InstancePatterns []string `json:"instance_patterns" yaml:"instancePatterns"`
}
diff --git a/model/instance.go b/model/instance.go
index 1c655f6eb..ceff4eba5 100644
--- a/model/instance.go
+++ b/model/instance.go
@@ -42,6 +42,8 @@ type Instance struct {
Volumes []*Volume
+ GPUUsage map[string]*InstanceGPUUsage
+
Upstreams map[ConnectionKey]*Connection
Requests Requests
@@ -50,6 +52,8 @@ type Instance struct {
Containers map[string]*Container
+ Annotations ApplicationAnnotations
+
ClusterName LabelLastValue
clusterRole *timeseries.TimeSeries
ClusterComponent *Application
@@ -63,11 +67,13 @@ type Instance struct {
func NewInstance(name string, owner *Application) *Instance {
return &Instance{
- Name: name,
- Owner: owner,
- Containers: map[string]*Container{},
- Upstreams: map[ConnectionKey]*Connection{},
- TcpListens: map[Listen]bool{},
+ Name: name,
+ Owner: owner,
+ Annotations: ApplicationAnnotations{},
+ Containers: map[string]*Container{},
+ Upstreams: map[ConnectionKey]*Connection{},
+ TcpListens: map[Listen]bool{},
+ GPUUsage: map[string]*InstanceGPUUsage{},
}
}
diff --git a/model/kubernetes.go b/model/kubernetes.go
index ea8b6f4d7..d1e0d856f 100644
--- a/model/kubernetes.go
+++ b/model/kubernetes.go
@@ -25,6 +25,7 @@ const (
ApplicationKindNomadJobGroup ApplicationKind = "NomadJobGroup"
ApplicationKindArgoWorkflow ApplicationKind = "Workflow"
ApplicationKindSparkApplication ApplicationKind = "SparkApplication"
+ ApplicationKindCustomApplication ApplicationKind = "CustomApplication"
)
type Job struct{}
diff --git a/model/log.go b/model/log.go
index f04bb4ede..872517eb1 100644
--- a/model/log.go
+++ b/model/log.go
@@ -9,18 +9,6 @@ import (
"github.com/coroot/logparser"
)
-type LogLevel string
-
-const (
- LogLevelWarning LogLevel = "warning"
- LogLevelError LogLevel = "error"
- LogLevelCritical LogLevel = "critical"
-)
-
-func (s LogLevel) IsError() bool {
- return s == LogLevelError || s == LogLevelCritical
-}
-
type LogSource string
const (
@@ -42,10 +30,16 @@ type LogPattern struct {
}
type LogEntry struct {
+ ServiceName string
Timestamp time.Time
- Severity string
+ Severity Severity
Body string
TraceId string
LogAttributes map[string]string
ResourceAttributes map[string]string
}
+
+type LogHistogramBucket struct {
+ Severity Severity
+ Timeseries *timeseries.TimeSeries
+}
diff --git a/model/node.go b/model/node.go
index 6275df9e9..193a5c8e0 100644
--- a/model/node.go
+++ b/model/node.go
@@ -74,6 +74,7 @@ type Node struct {
Disks map[string]*DiskStats
NetInterfaces []*InterfaceStats
+ GPUs map[string]*GPU
Instances []*Instance `json:"-"`
@@ -92,6 +93,7 @@ type NodePrice struct {
Total float32
PerCPUCore float32
PerMemoryByte float32
+ Custom bool
}
type InternetStartUsageAmountGB int64
@@ -121,6 +123,7 @@ func NewNode(id NodeId) *Node {
Id: id,
Disks: map[string]*DiskStats{},
CpuUsageByMode: map[string]*timeseries.TimeSeries{},
+ GPUs: map[string]*GPU{},
}
}
@@ -162,3 +165,27 @@ func (n *Node) Status() Status {
}
return OK
}
+
+type GPU struct {
+ UUID string
+ Name LabelLastValue
+
+ TotalMemory *timeseries.TimeSeries
+ UsedMemory *timeseries.TimeSeries
+
+ MemoryUsageAverage *timeseries.TimeSeries
+ MemoryUsagePeak *timeseries.TimeSeries
+
+ UsageAverage *timeseries.TimeSeries
+ UsagePeak *timeseries.TimeSeries
+
+ Temperature *timeseries.TimeSeries
+ PowerWatts *timeseries.TimeSeries
+
+ Instances map[string]*Instance
+}
+
+type InstanceGPUUsage struct {
+ MemoryUsageAverage *timeseries.TimeSeries
+ UsageAverage *timeseries.TimeSeries
+}
diff --git a/model/profile.go b/model/profile.go
index 0056fcbe6..497432622 100644
--- a/model/profile.go
+++ b/model/profile.go
@@ -1,6 +1,8 @@
package model
import (
+ "path"
+ "regexp"
"sort"
"strings"
)
@@ -126,12 +128,8 @@ func (n *FlameGraphNode) InsertStack(stack []string, value int64, comp *int64) {
if comp != nil {
node.Comp += *comp
}
- name := stack[l-i]
- s := strings.IndexByte(name, ' ')
- if s > 0 {
- name = name[:s]
- }
- node = node.Insert(name)
+ s := stack[l-i]
+ node = node.Insert(s)
}
node.Total += value
node.Self += value
@@ -140,12 +138,13 @@ func (n *FlameGraphNode) InsertStack(stack []string, value int64, comp *int64) {
}
}
-func (n *FlameGraphNode) Insert(name string) *FlameGraphNode {
+func (n *FlameGraphNode) Insert(s string) *FlameGraphNode {
+ name, colorBy := parseStackFunction(s)
i := sort.Search(len(n.Children), func(i int) bool {
return strings.Compare(n.Children[i].Name, name) >= 0
})
if i > len(n.Children)-1 || n.Children[i].Name != name {
- child := &FlameGraphNode{Name: name}
+ child := &FlameGraphNode{Name: name, ColorBy: colorBy}
n.Children = append(n.Children, child)
copy(n.Children[i+1:], n.Children[i:])
n.Children[i] = child
@@ -158,6 +157,7 @@ func (n *FlameGraphNode) Diff(comparison *FlameGraphNode) {
n.Comp = comparison.Total
n.Total += n.Comp
}
+
func (n *FlameGraphNode) diff(comparison *FlameGraphNode) {
byName := map[string]*FlameGraphNode{}
if comparison != nil {
@@ -190,3 +190,47 @@ func (n *FlameGraphNode) diff(comparison *FlameGraphNode) {
}
}
}
+
+var (
+ javaFuncRe = regexp.MustCompile(`^(([0-9a-z.]+)\.[^.]+\.[^(]+)\(`)
+)
+
+func parseStackFunction(sf string) (string, string) {
+ parts := strings.Split(sf, " ")
+ var colorBy string
+
+ if len(parts) == 2 {
+ if i := strings.LastIndexByte(parts[0], '/'); i > 0 {
+ colorBy = parts[0][:i]
+ }
+ return parts[0], colorBy
+ }
+
+ // python
+ if len(parts) == 3 {
+ if strings.HasSuffix(parts[0], ".py") {
+ file := parts[0]
+ if i := strings.Index(file, "site-packages/"); i != -1 {
+ file = file[i+len("site-packages/"):]
+ colorBy = path.Dir(file)
+ } else {
+ colorBy = file
+ }
+ return parts[1] + " @" + file, colorBy
+ }
+ }
+
+ // java
+ if len(parts) >= 3 {
+ matches := javaFuncRe.FindStringSubmatch(parts[1])
+ if len(matches) == 3 {
+ return matches[1], matches[2]
+ }
+ }
+
+ if i := strings.LastIndexByte(sf, ' '); i > 0 {
+ return sf[:i], ""
+ }
+
+ return sf, ""
+}
diff --git a/model/profile_test.go b/model/profile_test.go
new file mode 100644
index 000000000..171efea5e
--- /dev/null
+++ b/model/profile_test.go
@@ -0,0 +1,31 @@
+package model
+
+import (
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+)
+
+func TestParseStackFunction(t *testing.T) {
+ var function, colorBy string
+
+ function, colorBy = parseStackFunction("/usr/local/lib/python3.12/threading.py Thread._bootstrap :0")
+ assert.Equal(t, function, "Thread._bootstrap @/usr/local/lib/python3.12/threading.py")
+ assert.Equal(t, "/usr/local/lib/python3.12/threading.py", colorBy)
+
+ function, colorBy = parseStackFunction("/usr/local/lib/python3.12/site-packages/grpc/_channel.py _MultiThreadedRendezvous.__next__ :0")
+ assert.Equal(t, function, "_MultiThreadedRendezvous.__next__ @grpc/_channel.py")
+ assert.Equal(t, "grpc", colorBy)
+
+ function, colorBy = parseStackFunction("boolean io.netty.channel.epoll.EpollEventLoop.processReady(io.netty.channel.epoll.EpollEventArray, int) :0")
+ assert.Equal(t, "io.netty.channel.epoll.EpollEventLoop.processReady", function)
+ assert.Equal(t, "io.netty.channel.epoll", colorBy)
+
+ function, colorBy = parseStackFunction("void okhttp3.internal.connection.RealCall$AsyncCall.run() :0")
+ assert.Equal(t, "okhttp3.internal.connection.RealCall$AsyncCall.run", function)
+ assert.Equal(t, "okhttp3.internal.connection", colorBy)
+
+ function, colorBy = parseStackFunction("github.com/prometheus/prometheus/scrape.(*scrapePool).sync.gowrap2 :0")
+ assert.Equal(t, "github.com/prometheus/prometheus/scrape.(*scrapePool).sync.gowrap2", function)
+ assert.Equal(t, "github.com/prometheus/prometheus", colorBy)
+}
diff --git a/model/risk.go b/model/risk.go
index 607103c4a..3fab12843 100644
--- a/model/risk.go
+++ b/model/risk.go
@@ -3,13 +3,19 @@ package model
type RiskCategory string
const (
- RiskCategorySecurity = "Security"
+ RiskCategorySecurity = "Security"
+ RiskCategoryAvailability = "Availability"
)
type RiskType string
const (
- RiskTypeDbInternetExposure RiskType = "db-internet-exposure"
+ RiskTypeDbInternetExposure RiskType = "db-internet-exposure"
+ RiskTypeSingleInstanceApp RiskType = "single-instance-app"
+ RiskTypeSingleNodeApp RiskType = "single-node-app"
+ RiskTypeSingleAzApp RiskType = "single-az-app"
+ RiskTypeSpotOnlyApp RiskType = "spot-only-app"
+ RiskTypeUnreplicatedDatabase RiskType = "unreplicated-database"
)
type RiskKey struct {
diff --git a/model/severity.go b/model/severity.go
new file mode 100644
index 000000000..8ae02a0b5
--- /dev/null
+++ b/model/severity.go
@@ -0,0 +1,100 @@
+package model
+
+import (
+ "fmt"
+ "strconv"
+ "strings"
+)
+
+type Severity int
+
+const (
+ SeverityUnknown Severity = iota
+ SeverityTrace
+ SeverityDebug
+ SeverityInfo
+ SeverityWarning
+ SeverityError
+ SeverityFatal
+)
+
+func (s Severity) String() string {
+ switch s {
+ case SeverityUnknown:
+ return "unknown"
+ case SeverityTrace:
+ return "trace"
+ case SeverityDebug:
+ return "debug"
+ case SeverityInfo:
+ return "info"
+ case SeverityWarning:
+ return "warning"
+ case SeverityError:
+ return "error"
+ case SeverityFatal:
+ return "fatal"
+ }
+ return fmt.Sprintf("severity-%d", s)
+}
+
+func (s Severity) Color() string {
+ switch s {
+ case SeverityUnknown:
+ return "grey-lighten1"
+ case SeverityTrace:
+ return "green-lighten4"
+ case SeverityDebug:
+ return "green-lighten2"
+ case SeverityInfo:
+ return "blue-lighten2"
+ case SeverityWarning:
+ return "orange-lighten1"
+ case SeverityError:
+ return "red-darken1"
+ case SeverityFatal:
+ return "black"
+ }
+ return ""
+}
+
+func (s Severity) Range() (int, int) {
+ switch s {
+ case SeverityUnknown:
+ return 0, 0
+ case SeverityTrace:
+ return 1, 4
+ case SeverityDebug:
+ return 5, 8
+ case SeverityInfo:
+ return 9, 12
+ case SeverityWarning:
+ return 13, 16
+ case SeverityError:
+ return 17, 20
+ case SeverityFatal:
+ return 21, 24
+ }
+ return int(s), int(s)
+}
+
+func SeverityFromString(s string) Severity {
+ switch s {
+ case "unknown":
+ return SeverityUnknown
+ case "trace":
+ return SeverityTrace
+ case "debug":
+ return SeverityDebug
+ case "info":
+ return SeverityInfo
+ case "warn", "warning":
+ return SeverityWarning
+ case "error":
+ return SeverityError
+ case "fatal", "critical":
+ return SeverityFatal
+ }
+ i, _ := strconv.Atoi(strings.TrimPrefix(s, "severity-"))
+ return Severity(i)
+}
diff --git a/model/sli.go b/model/sli.go
index 559693b6e..98cd099db 100644
--- a/model/sli.go
+++ b/model/sli.go
@@ -8,6 +8,17 @@ import (
"github.com/dustin/go-humanize"
)
+var DefaultHistogramBuckets = []float32{.005, .01, .025, .05, .1, .25, .5, 1, 2.5, 5, 10}
+
+func RoundUpToDefaultBucket(threshold float32) float32 {
+ for _, b := range DefaultHistogramBuckets {
+ if b >= threshold {
+ return b
+ }
+ }
+ return threshold
+}
+
type AvailabilitySLI struct {
Config CheckConfigSLOAvailability
diff --git a/model/widget.go b/model/widget.go
index 9d4b3d6e9..4459a3985 100644
--- a/model/widget.go
+++ b/model/widget.go
@@ -11,6 +11,8 @@ type Widget struct {
Profiling *Profiling `json:"profiling,omitempty"`
Tracing *Tracing `json:"tracing,omitempty"`
+ GroupHeader string `json:"group_header,omitempty"`
+
Width string `json:"width,omitempty"`
DocLink *DocLink `json:"doc_link,omitempty"`
}
diff --git a/notifications/incidents.go b/notifications/incidents.go
index d59309d89..329e017f1 100644
--- a/notifications/incidents.go
+++ b/notifications/incidents.go
@@ -26,20 +26,32 @@ func NewIncidentNotifier(db *db.DB) *IncidentNotifier {
}
func (n *IncidentNotifier) Enqueue(project *db.Project, app *model.Application, incident *model.ApplicationIncident, now timeseries.Time) {
- integrations := project.Settings.Integrations
- for _, i := range integrations.GetInfo() {
- if i.Configured && i.Incidents {
- n.enqueue(project, app, incident, i.Type, now)
- }
+ categorySettings := project.GetApplicationCategories()[app.Category]
+ if categorySettings == nil {
+ return
+ }
+ notificationSettings := categorySettings.NotificationSettings.Incidents
+ if !notificationSettings.Enabled {
+ return
+ }
+ if slack := notificationSettings.Slack; slack != nil && slack.Enabled {
+ n.enqueue(now, project, app, incident, db.IncidentNotificationDestination{IntegrationType: db.IntegrationTypeSlack, SlackChannel: slack.Channel})
+ }
+ if teams := notificationSettings.Teams; teams != nil && teams.Enabled {
+ n.enqueue(now, project, app, incident, db.IncidentNotificationDestination{IntegrationType: db.IntegrationTypeTeams})
+ }
+ if pagerduty := notificationSettings.Pagerduty; pagerduty != nil && pagerduty.Enabled {
+ n.enqueue(now, project, app, incident, db.IncidentNotificationDestination{IntegrationType: db.IntegrationTypePagerduty})
+ }
+ if opsgenie := notificationSettings.Opsgenie; opsgenie != nil && opsgenie.Enabled {
+ n.enqueue(now, project, app, incident, db.IncidentNotificationDestination{IntegrationType: db.IntegrationTypeOpsgenie})
+ }
+ if webhook := notificationSettings.Webhook; webhook != nil && webhook.Enabled {
+ n.enqueue(now, project, app, incident, db.IncidentNotificationDestination{IntegrationType: db.IntegrationTypeWebhook})
}
n.sendIncidents()
}
-type destinationKey struct {
- integration db.IntegrationType
- projectId db.ProjectId
-}
-
func (n *IncidentNotifier) sendIncidents() {
ps, err := n.db.GetProjects()
if err != nil {
@@ -53,6 +65,11 @@ func (n *IncidentNotifier) sendIncidents() {
for _, p := range ps {
projects[p.Id] = p
}
+
+ type destinationKey struct {
+ projectId db.ProjectId
+ destination db.IncidentNotificationDestination
+ }
failedDestinations := map[destinationKey]bool{}
notifications, err := n.db.GetNotSentIncidentNotifications(timeseries.Now().Add(-retryWindow))
if err != nil {
@@ -60,7 +77,7 @@ func (n *IncidentNotifier) sendIncidents() {
return
}
for _, notification := range notifications {
- dKey := destinationKey{integration: notification.Destination, projectId: notification.ProjectId}
+ dKey := destinationKey{projectId: notification.ProjectId, destination: notification.Destination}
if failedDestinations[dKey] {
continue
}
@@ -72,13 +89,13 @@ func (n *IncidentNotifier) sendIncidents() {
var sendErr error
client := getClient(notification.Destination, integrations)
if client != nil {
- if notification.Destination == db.IntegrationTypeSlack {
+ if notification.Destination.IntegrationType == db.IntegrationTypeSlack {
if prevNotifications, err := n.db.GetPreviousIncidentNotifications(notification); err != nil {
klog.Errorln(err)
} else {
- for _, n := range prevNotifications {
- if n.ExternalKey != "" {
- notification.ExternalKey = n.ExternalKey
+ for _, pn := range prevNotifications {
+ if pn.ExternalKey != "" {
+ notification.ExternalKey = pn.ExternalKey
}
}
}
@@ -88,18 +105,18 @@ func (n *IncidentNotifier) sendIncidents() {
cancel()
}
if sendErr != nil {
- klog.Errorf("send error %s: %s", notification.Destination, sendErr)
+ klog.Errorf("failed to send to %s: %s", notification.Destination.IntegrationType, sendErr)
failedDestinations[dKey] = true
} else {
notification.SentAt = timeseries.Now()
- if err := n.db.UpdateIncidentNotification(notification); err != nil {
+ if err = n.db.UpdateIncidentNotification(notification); err != nil {
klog.Errorln(err)
}
}
}
}
-func (n *IncidentNotifier) enqueue(project *db.Project, app *model.Application, incident *model.ApplicationIncident, destination db.IntegrationType, now timeseries.Time) {
+func (n *IncidentNotifier) enqueue(now timeseries.Time, project *db.Project, app *model.Application, incident *model.ApplicationIncident, destination db.IncidentNotificationDestination) {
notification := db.IncidentNotification{
ProjectId: project.Id,
ApplicationId: app.Id,
@@ -108,7 +125,7 @@ func (n *IncidentNotifier) enqueue(project *db.Project, app *model.Application,
Timestamp: now,
Status: incident.Severity,
}
- switch destination {
+ switch destination.IntegrationType {
case db.IntegrationTypeSlack, db.IntegrationTypeTeams, db.IntegrationTypeWebhook:
if incident.Resolved() {
n.onResolve("", notification, incidentDetails(app, incident))
@@ -162,18 +179,19 @@ func (n *IncidentNotifier) getOpenIncidents(notification db.IncidentNotification
return "", "", err
}
var openCriticalKey, openWarningKey string
- for _, n := range prevNotifications {
- switch n.Status {
+ for _, prev := range prevNotifications {
+ switch prev.Status {
case model.CRITICAL:
- openCriticalKey = n.ExternalKey
+ openCriticalKey = prev.ExternalKey
case model.WARNING:
- openWarningKey = n.ExternalKey
+ openWarningKey = prev.ExternalKey
case model.OK:
- if openWarningKey == n.ExternalKey {
+ if openWarningKey == prev.ExternalKey {
openWarningKey = ""
- } else if openCriticalKey == n.ExternalKey {
+ } else if openCriticalKey == prev.ExternalKey {
openCriticalKey = ""
}
+ default:
}
}
return openCriticalKey, openWarningKey, nil
diff --git a/notifications/notifications.go b/notifications/notifications.go
index 0829e646b..818e692d9 100644
--- a/notifications/notifications.go
+++ b/notifications/notifications.go
@@ -1,6 +1,7 @@
package notifications
import (
+ "cmp"
"context"
"fmt"
"time"
@@ -18,13 +19,14 @@ const (
type NotificationClient interface {
SendIncident(ctx context.Context, baseUrl string, n *db.IncidentNotification) error
+ SendDeployment(ctx context.Context, project *db.Project, ds model.ApplicationDeploymentStatus) error
}
-func getClient(destination db.IntegrationType, integrations db.Integrations) NotificationClient {
- switch destination {
+func getClient(destination db.IncidentNotificationDestination, integrations db.Integrations) NotificationClient {
+ switch destination.IntegrationType {
case db.IntegrationTypeSlack:
if cfg := integrations.Slack; cfg != nil && cfg.Incidents {
- return NewSlack(cfg.Token, cfg.DefaultChannel)
+ return NewSlack(cfg.Token, cmp.Or(destination.SlackChannel, cfg.DefaultChannel))
}
case db.IntegrationTypeTeams:
if cfg := integrations.Teams; cfg != nil && cfg.Incidents {
diff --git a/notifications/opsgenie.go b/notifications/opsgenie.go
index 00465e8b0..9169ae3e3 100644
--- a/notifications/opsgenie.go
+++ b/notifications/opsgenie.go
@@ -64,3 +64,7 @@ func (og *Opsgenie) SendIncident(ctx context.Context, baseUrl string, n *db.Inci
_, err := og.client.Create(ctx, req)
return err
}
+
+func (og *Opsgenie) SendDeployment(ctx context.Context, project *db.Project, ds model.ApplicationDeploymentStatus) error {
+ return fmt.Errorf("not supported")
+}
diff --git a/notifications/pagerduty.go b/notifications/pagerduty.go
index 16f579dd4..bef12bd46 100644
--- a/notifications/pagerduty.go
+++ b/notifications/pagerduty.go
@@ -46,3 +46,7 @@ func (pd *Pagerduty) SendIncident(ctx context.Context, baseUrl string, n *db.Inc
_, err := pagerduty.ManageEventWithContext(ctx, e)
return err
}
+
+func (pd *Pagerduty) SendDeployment(ctx context.Context, project *db.Project, ds model.ApplicationDeploymentStatus) error {
+ return fmt.Errorf("not supported")
+}
diff --git a/notifications/slack.go b/notifications/slack.go
index e85b48ec8..6a7bf1721 100644
--- a/notifications/slack.go
+++ b/notifications/slack.go
@@ -108,9 +108,6 @@ func (s *Slack) SendDeployment(ctx context.Context, project *db.Project, ds mode
if err != nil {
return fmt.Errorf("slack error: %w", err)
}
- if err != nil {
- return err
- }
d.Notifications.Slack.Channel = ch
d.Notifications.Slack.ThreadTs = ts
diff --git a/notifications/teams.go b/notifications/teams.go
index ef4319112..968fb5b9a 100644
--- a/notifications/teams.go
+++ b/notifications/teams.go
@@ -6,7 +6,7 @@ import (
"strings"
goteamsnotify "github.com/atc0005/go-teams-notify/v2"
- "github.com/atc0005/go-teams-notify/v2/messagecard"
+ "github.com/atc0005/go-teams-notify/v2/adaptivecard"
"github.com/coroot/coroot/db"
"github.com/coroot/coroot/model"
)
@@ -32,22 +32,32 @@ func (t *Teams) SendIncident(ctx context.Context, baseUrl string, n *db.Incident
} else {
title = fmt.Sprintf("[%s] **%s** is not meeting its SLOs", strings.ToUpper(n.Status.String()), n.ApplicationId.Name)
}
-
- msg := messagecard.NewMessageCard()
- msg.Summary = title
- msg.ThemeColor = n.Status.Color()
- msg.Text = "# " + title + "\n"
+ text := ""
if n.Details != nil {
- s := &messagecard.Section{}
for _, r := range n.Details.Reports {
- s.Text += fmt.Sprintf("• **%s** / %s: %s
", r.Name, r.Check, r.Message)
+ text += fmt.Sprintf("* **%s** / %s: %s\n", r.Name, r.Check, r.Message)
}
- _ = msg.AddSection(s)
}
- action, _ := messagecard.NewPotentialAction(messagecard.PotentialActionOpenURIType, "View incident")
- action.Targets = []messagecard.PotentialActionOpenURITarget{{OS: "default", URI: incidentUrl(baseUrl, n)}}
- _ = msg.AddPotentialAction(action)
- if err := t.client.SendWithContext(ctx, t.webhookUrl, msg); err != nil {
+ if text == "" {
+ text = " "
+ }
+ card, err := adaptivecard.NewTextBlockCard(text, title, true)
+ if err != nil {
+ return err
+ }
+ action, err := adaptivecard.NewActionOpenURL(incidentUrl(baseUrl, n), "View incident")
+ if err != nil {
+ return err
+ }
+ err = card.AddAction(true, action)
+ if err != nil {
+ return err
+ }
+ msg, err := adaptivecard.NewMessageFromCard(card)
+ if err != nil {
+ return err
+ }
+ if err = t.client.SendWithContext(ctx, t.webhookUrl, msg); err != nil {
return err
}
return nil
@@ -68,33 +78,38 @@ func (t *Teams) SendDeployment(ctx context.Context, project *db.Project, ds mode
title := fmt.Sprintf("Deployment of **%s** to **%s**", d.ApplicationId.Name, project.Name)
- msg := messagecard.NewMessageCard()
- msg.Summary = title
- msg.ThemeColor = ds.Status.Color()
- _ = msg.AddSection(&messagecard.Section{
- Text: "# " + title,
- Facts: []messagecard.SectionFact{
- {Name: "Status", Value: status},
- {Name: "Version", Value: d.Version()},
- },
- })
-
+ text := fmt.Sprintf("**Status**: %s\n\n", status)
+ text += fmt.Sprintf("**Version**: %s\n\n", d.Version())
if ds.State == model.ApplicationDeploymentStateSummary {
- summary := "No notable changes"
+ summary := ""
if len(ds.Summary) > 0 {
- summary = ""
for _, s := range ds.Summary {
- summary += fmt.Sprintf("%s %s
", s.Emoji(), s.Message)
+ summary += fmt.Sprintf("* %s %s\n", s.Emoji(), s.Message)
}
+ } else {
+ summary = "No notable changes"
}
- _ = msg.AddSection(&messagecard.Section{Text: "**Summary**
" + summary})
+ text += "**Summary:**\n\n"
+ text += summary
}
- action, _ := messagecard.NewPotentialAction(messagecard.PotentialActionOpenURIType, "View deployment")
- action.Targets = []messagecard.PotentialActionOpenURITarget{{OS: "default", URI: deploymentUrl(project.Settings.Integrations.BaseUrl, project.Id, d)}}
- _ = msg.AddPotentialAction(action)
-
- if err := t.client.SendWithContext(ctx, t.webhookUrl, msg); err != nil {
+ card, err := adaptivecard.NewTextBlockCard(text, title, true)
+ if err != nil {
+ return err
+ }
+ action, err := adaptivecard.NewActionOpenURL(deploymentUrl(project.Settings.Integrations.BaseUrl, project.Id, d), "View deployment")
+ if err != nil {
+ return err
+ }
+ err = card.AddAction(true, action)
+ if err != nil {
+ return err
+ }
+ msg, err := adaptivecard.NewMessageFromCard(card)
+ if err != nil {
+ return err
+ }
+ if err = t.client.SendWithContext(ctx, t.webhookUrl, msg); err != nil {
return err
}
diff --git a/prom/client.go b/prom/client.go
index 390a58efb..0066bba63 100644
--- a/prom/client.go
+++ b/prom/client.go
@@ -4,8 +4,10 @@ import (
"bytes"
"context"
"crypto/tls"
+ "encoding/json"
"errors"
"fmt"
+ "io"
"net"
"net/http"
"net/url"
@@ -97,7 +99,7 @@ func NewClient(config ClientConfig) (*Client, error) {
func (c *Client) Ping(ctx context.Context) error {
now := timeseries.Now()
- _, err := c.QueryRange(ctx, "up", nil, now.Add(-timeseries.Hour), now, timeseries.Minute)
+ _, err := c.QueryRange(ctx, "up", FilterLabelsDropAll, now.Add(-timeseries.Hour), now, timeseries.Minute)
return err
}
@@ -105,7 +107,7 @@ func (c *Client) GetStep(from, to timeseries.Time) (timeseries.Duration, error)
return c.config.Step, nil
}
-func (c *Client) QueryRange(ctx context.Context, query string, labels *utils.StringSet, from, to timeseries.Time, step timeseries.Duration) ([]*model.MetricValues, error) {
+func (c *Client) QueryRange(ctx context.Context, query string, filterLabels FilterLabelsF, from, to timeseries.Time, step timeseries.Duration) ([]*model.MetricValues, error) {
query = strings.ReplaceAll(query, "$RANGE", fmt.Sprintf(`%.0fs`, (step*3).ToStandard().Seconds()))
var err error
query, err = addExtraSelector(query, c.config.ExtraSelector)
@@ -140,6 +142,14 @@ func (c *Client) QueryRange(ctx context.Context, query string, labels *utils.Str
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
+ d, _ := io.ReadAll(resp.Body)
+ var j struct {
+ Error string `json:"error"`
+ }
+ _ = json.Unmarshal(d, &j)
+ if j.Error != "" {
+ return nil, fmt.Errorf(j.Error)
+ }
return nil, errors.New(resp.Status)
}
buf := pool.Get().(*bytes.Buffer)
@@ -158,7 +168,7 @@ func (c *Client) QueryRange(ctx context.Context, query string, labels *utils.Str
return err
}
k := string(key)
- if labels.Has(k) {
+ if filterLabels(k) {
ls[string(key)] = v
}
return nil
diff --git a/prom/client_test.go b/prom/client_test.go
index 76b8ea520..ff44360bd 100644
--- a/prom/client_test.go
+++ b/prom/client_test.go
@@ -42,7 +42,7 @@ func TestQueryRange(t *testing.T) {
ctx := context.Background()
- res, err := client.QueryRange(ctx, `metric`, utils.NewStringSet("instance", "job"), from, to, step)
+ res, err := client.QueryRange(ctx, `metric`, utils.NewStringSet("instance", "job").Has, from, to, step)
assert.NoError(t, err)
assert.Len(t, res, 2)
diff --git a/prom/utils.go b/prom/utils.go
index 0a0562934..04359a9ab 100644
--- a/prom/utils.go
+++ b/prom/utils.go
@@ -11,3 +11,13 @@ func IsSelectorValid(selector string) bool {
_, err := parser.ParseMetricSelector(selector)
return err == nil
}
+
+type FilterLabelsF func(name string) bool
+
+func FilterLabelsKeepAll(name string) bool {
+ return true
+}
+
+func FilterLabelsDropAll(name string) bool {
+ return false
+}
diff --git a/rbac/action.go b/rbac/action.go
index 526151691..355075eeb 100644
--- a/rbac/action.go
+++ b/rbac/action.go
@@ -10,6 +10,7 @@ const (
ActionEdit Verb = "edit"
ScopeAll Scope = "*"
+ ScopeSettings Scope = "settings"
ScopeUsers Scope = "users"
ScopeRoles Scope = "roles"
ScopeProjectAll Scope = "project.*"
@@ -17,14 +18,18 @@ const (
ScopeProjectIntegrations Scope = "project.integrations"
ScopeProjectApplicationCategories Scope = "project.application_categories"
ScopeProjectCustomApplications Scope = "project.custom_applications"
+ ScopeProjectCustomCloudPricing Scope = "project.custom_cloud_pricing"
ScopeProjectInspections Scope = "project.inspections"
ScopeProjectInstrumentations Scope = "project.instrumentations"
ScopeProjectTraces Scope = "project.traces"
+ ScopeProjectLogs Scope = "project.logs"
ScopeProjectCosts Scope = "project.costs"
ScopeProjectAnomalies Scope = "project.anomalies"
ScopeProjectRisks Scope = "project.risks"
ScopeApplication Scope = "project.application"
ScopeNode Scope = "project.node"
+ ScopeDashboards Scope = "project.dashboards"
+ ScopeDashboard Scope = "project.dashboard"
)
type Action struct {
diff --git a/rbac/actions.go b/rbac/actions.go
index 2e4899894..3e6a71c11 100644
--- a/rbac/actions.go
+++ b/rbac/actions.go
@@ -10,6 +10,10 @@ var (
type ActionSet struct{}
+func (as ActionSet) Settings() Settings {
+ return Settings{}
+}
+
func (as ActionSet) Users() UsersActionSet {
return UsersActionSet{}
}
@@ -24,12 +28,19 @@ func (as ActionSet) Project(id string) ProjectActionSet {
func (as ActionSet) List() []Action {
return append([]Action{
+ as.Settings().Edit(),
as.Users().Edit(),
as.Roles().Edit(),
},
as.Project("").List()...)
}
+type Settings struct{}
+
+func (as Settings) Edit() Action {
+ return NewAction(ScopeSettings, ActionEdit, nil)
+}
+
type UsersActionSet struct{}
func (as UsersActionSet) Edit() Action {
@@ -56,15 +67,19 @@ func (as ProjectActionSet) List() []Action {
as.Integrations().Edit(),
as.ApplicationCategories().Edit(),
as.CustomApplications().Edit(),
+ as.CustomCloudPricing().Edit(),
as.Inspections().Edit(),
as.Instrumentations().Edit(),
as.Traces().View(),
+ as.Logs().View(),
as.Costs().View(),
as.Anomalies().View(),
as.Risks().View(),
as.Risks().Edit(),
as.Application("*", "*", "*", "*").View(),
as.Node("*").View(),
+ as.Dashboards().Edit(),
+ as.Dashboard("*").View(),
}
}
@@ -84,6 +99,10 @@ func (as ProjectActionSet) CustomApplications() ProjectEditAction {
return ProjectEditAction{project: &as, scope: ScopeProjectCustomApplications}
}
+func (as ProjectActionSet) CustomCloudPricing() ProjectEditAction {
+ return ProjectEditAction{project: &as, scope: ScopeProjectCustomCloudPricing}
+}
+
func (as ProjectActionSet) Inspections() ProjectEditAction {
return ProjectEditAction{project: &as, scope: ScopeProjectInspections}
}
@@ -96,6 +115,10 @@ func (as ProjectActionSet) Traces() ProjectViewAction {
return ProjectViewAction{project: &as, scope: ScopeProjectTraces}
}
+func (as ProjectActionSet) Logs() ProjectViewAction {
+ return ProjectViewAction{project: &as, scope: ScopeProjectLogs}
+}
+
func (as ProjectActionSet) Costs() ProjectViewAction {
return ProjectViewAction{project: &as, scope: ScopeProjectCosts}
}
@@ -116,6 +139,14 @@ func (as ProjectActionSet) Node(name string) NodeActionSet {
return NodeActionSet{project: &as, name: name}
}
+func (as ProjectActionSet) Dashboards() ProjectEditAction {
+ return ProjectEditAction{project: &as, scope: ScopeDashboards}
+}
+
+func (as ProjectActionSet) Dashboard(name string) DashboardActionSet {
+ return DashboardActionSet{project: &as, name: name}
+}
+
type ProjectViewAction struct {
project *ProjectActionSet
scope Scope
@@ -182,3 +213,18 @@ func (as NodeActionSet) object() Object {
func (as NodeActionSet) View() Action {
return NewAction(ScopeNode, ActionView, as.object())
}
+
+type DashboardActionSet struct {
+ project *ProjectActionSet
+ name string
+}
+
+func (as DashboardActionSet) object() Object {
+ o := as.project.object()
+ o["dashboard_name"] = as.name
+ return o
+}
+
+func (as DashboardActionSet) View() Action {
+ return NewAction(ScopeDashboard, ActionView, as.object())
+}
diff --git a/rbac/role.go b/rbac/role.go
index 9f582ee26..c698ff164 100644
--- a/rbac/role.go
+++ b/rbac/role.go
@@ -19,7 +19,10 @@ var (
NewPermission(ScopeAll, ActionView, nil),
NewPermission(ScopeProjectApplicationCategories, ActionEdit, nil),
NewPermission(ScopeProjectCustomApplications, ActionEdit, nil),
+ NewPermission(ScopeProjectCustomCloudPricing, ActionEdit, nil),
NewPermission(ScopeProjectInspections, ActionEdit, nil),
+ NewPermission(ScopeProjectRisks, ActionEdit, nil),
+ NewPermission(ScopeDashboards, ActionEdit, nil),
),
NewRole(RoleViewer,
NewPermission(ScopeAll, ActionView, nil),
diff --git a/stats/stats.go b/stats/stats.go
index f9565b6df..a28655c5e 100644
--- a/stats/stats.go
+++ b/stats/stats.go
@@ -7,6 +7,7 @@ import (
"encoding/json"
"io"
"net/http"
+ "os"
"runtime/pprof"
"strings"
"sync"
@@ -20,6 +21,7 @@ import (
"github.com/coroot/coroot/model"
"github.com/coroot/coroot/timeseries"
"github.com/coroot/coroot/utils"
+ "github.com/gorilla/mux"
"github.com/grafana/pyroscope-go/godeltaprof"
"k8s.io/klog"
)
@@ -33,9 +35,11 @@ const (
type Stats struct {
Instance struct {
- Uuid string `json:"uuid"`
- Version string `json:"version"`
- DatabaseType string `json:"database_type"`
+ Uuid string `json:"uuid"`
+ Version string `json:"version"`
+ DatabaseType string `json:"database_type"`
+ Edition string `json:"edition"`
+ InstallationType string `json:"installation_type,omitempty"`
} `json:"instance"`
Integration struct {
Prometheus bool `json:"prometheus"`
@@ -76,6 +80,7 @@ type Stats struct {
Users *utils.StringSet `json:"users"`
UsersByRole map[string]int `json:"users_by_role"`
PageViews map[string]int `json:"page_views"`
+ ApiCalls map[string]int `json:"api_calls"`
SentNotifications map[db.IntegrationType]int `json:"sent_notifications"`
} `json:"ux"`
Performance struct {
@@ -143,12 +148,15 @@ type Collector struct {
disabled bool
- instanceUuid string
- instanceVersion string
+ instanceUuid string
+ instanceVersion string
+ edition string
+ installationType string
usersByScreenSize map[string]*utils.StringSet
usersByTheme map[string]*utils.StringSet
pageViews map[string]int
+ apiCalls map[string]int
lock sync.Mutex
heapProfiler *godeltaprof.HeapProfiler
@@ -156,7 +164,7 @@ type Collector struct {
globalClickHouse *db.IntegrationClickhouse
}
-func NewCollector(disabled bool, instanceUuid, version string, db *db.DB, cache *cache.Cache, pricing *cloud_pricing.Manager, globalClickHouse *db.IntegrationClickhouse) *Collector {
+func NewCollector(disabled bool, instanceUuid, version string, edition string, db *db.DB, cache *cache.Cache, pricing *cloud_pricing.Manager, globalClickHouse *db.IntegrationClickhouse) *Collector {
c := &Collector{
db: db,
cache: cache,
@@ -164,12 +172,15 @@ func NewCollector(disabled bool, instanceUuid, version string, db *db.DB, cache
client: &http.Client{Timeout: sendTimeout},
- instanceUuid: instanceUuid,
- instanceVersion: version,
+ instanceUuid: instanceUuid,
+ instanceVersion: version,
+ edition: edition,
+ installationType: os.Getenv("INSTALLATION_TYPE"),
usersByScreenSize: map[string]*utils.StringSet{},
usersByTheme: map[string]*utils.StringSet{},
pageViews: map[string]int{},
+ apiCalls: map[string]int{},
heapProfiler: godeltaprof.NewHeapProfiler(),
@@ -207,6 +218,25 @@ func (c *Collector) Stats(r *http.Request, w http.ResponseWriter) {
utils.WriteJson(w, c.collect())
}
+func (c *Collector) MiddleWare(next http.Handler) http.Handler {
+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ route := mux.CurrentRoute(r)
+ if route != nil {
+ pathTemplate, _ := route.GetPathTemplate()
+ if strings.HasPrefix(pathTemplate, "/api/") {
+ vars := mux.Vars(r)
+ if v := vars["view"]; v != "" {
+ pathTemplate = strings.ReplaceAll(pathTemplate, "{view}", v)
+ }
+ c.lock.Lock()
+ c.apiCalls[pathTemplate]++
+ c.lock.Unlock()
+ }
+ }
+ next.ServeHTTP(w, r)
+ })
+}
+
func (c *Collector) RegisterRequest(r *http.Request) {
if c == nil || c.disabled {
return
@@ -274,6 +304,8 @@ func (c *Collector) collect() Stats {
stats.Instance.Uuid = c.instanceUuid
stats.Instance.Version = c.instanceVersion
stats.Instance.DatabaseType = string(c.db.Type())
+ stats.Instance.Edition = c.edition
+ stats.Instance.InstallationType = c.installationType
stats.UX.UsersByScreenSize = map[string]int{}
stats.UX.Users = utils.NewStringSet()
@@ -282,6 +314,10 @@ func (c *Collector) collect() Stats {
stats.UX.PageViews = c.pageViews
c.pageViews = map[string]int{}
+
+ stats.UX.ApiCalls = c.apiCalls
+ c.apiCalls = map[string]int{}
+
for size, us := range c.usersByScreenSize {
stats.UX.UsersByScreenSize[size] = us.Len()
stats.UX.Users.Add(us.Items()...)
@@ -342,7 +378,7 @@ func (c *Collector) collect() Stats {
stats.Integration.Profiles = true
}
- for category := range p.Settings.ApplicationCategories {
+ for category := range p.Settings.ApplicationCategorySettings {
applicationCategories.Add(string(category))
}
diff --git a/timeseries/time.go b/timeseries/time.go
index fdd4e80b5..25256a242 100644
--- a/timeseries/time.go
+++ b/timeseries/time.go
@@ -26,6 +26,14 @@ type Context struct {
RawStep Duration `json:"raw_step"`
}
+func NewContext(from, to Time, step Duration) Context {
+ return Context{From: from, To: to, Step: step, RawStep: step}
+}
+
+func (c Context) PointsCount() int {
+ return int(c.To.Sub(c.From) / c.Step)
+}
+
type Duration int64
func DurationFromStandard(d time.Duration) Duration {
diff --git a/utils/format.go b/utils/format.go
index 3239061fe..68698a32c 100644
--- a/utils/format.go
+++ b/utils/format.go
@@ -117,3 +117,13 @@ func FormatLinkStats(requests, latency, bytesSent, bytesReceived float32, issue
}
return res
}
+
+func Capitalize(s string) string {
+ if len(s) == 0 {
+ return ""
+ }
+ if len(s) == 1 {
+ return strings.ToUpper(s)
+ }
+ return strings.ToUpper(s[0:1]) + s[1:]
+}
diff --git a/utils/stringset.go b/utils/stringset.go
index 3edecb697..693fd4eae 100644
--- a/utils/stringset.go
+++ b/utils/stringset.go
@@ -3,6 +3,8 @@ package utils
import (
"encoding/json"
"sort"
+
+ "golang.org/x/exp/maps"
)
type StringSet struct {
@@ -46,25 +48,21 @@ func (ss *StringSet) Len() int {
}
func (ss *StringSet) Items() []string {
- var res []string
if ss == nil {
- return res
- }
- for s := range ss.m {
- res = append(res, s)
+ return []string{}
}
+ res := maps.Keys(ss.m)
sort.Strings(res)
return res
}
-func (ss *StringSet) MarshalJSON() ([]byte, error) {
- if ss == nil {
- return json.Marshal([]string{})
+func (ss *StringSet) GetFirst() string {
+ if ss.Len() == 0 {
+ return ""
}
- sl := make([]string, 0, len(ss.m))
- for el := range ss.m {
- sl = append(sl, el)
- }
- sort.Strings(sl)
- return json.Marshal(sl)
+ return ss.Items()[0]
+}
+
+func (ss *StringSet) MarshalJSON() ([]byte, error) {
+ return json.Marshal(ss.Items())
}
diff --git a/utils/url.go b/utils/url.go
index 22d48d18c..b33c9b067 100644
--- a/utils/url.go
+++ b/utils/url.go
@@ -13,8 +13,8 @@ import (
)
type Header struct {
- Key string `json:"key"`
- Value string `json:"value"`
+ Key string `json:"key" yaml:"key"`
+ Value string `json:"value" yaml:"value"`
}
func (h Header) Valid() bool {
@@ -22,8 +22,8 @@ func (h Header) Valid() bool {
}
type BasicAuth struct {
- User string `json:"user"`
- Password string `json:"password"`
+ User string `json:"user" yaml:"username"`
+ Password string `json:"password" yaml:"password"`
}
func (ba *BasicAuth) AddTo(address string) (string, error) {
diff --git a/utils/utils.go b/utils/utils.go
index 947538cda..5a08a3ca7 100644
--- a/utils/utils.go
+++ b/utils/utils.go
@@ -1,5 +1,28 @@
package utils
+import (
+ "cmp"
+ "sort"
+)
+
func Ptr[T any](v T) *T {
return &v
}
+
+func Uniq[T comparable](s []T) []T {
+ m := map[T]struct{}{}
+ for _, v := range s {
+ m[v] = struct{}{}
+ }
+ res := make([]T, 0, len(m))
+ for v := range m {
+ res = append(res, v)
+ }
+ return res
+}
+
+func SortSlice[T cmp.Ordered](s []T) {
+ sort.Slice(s, func(i, j int) bool {
+ return s[i] < s[j]
+ })
+}
diff --git a/watchers/deployments.go b/watchers/deployments.go
index 89e8c9f53..afcfb3507 100644
--- a/watchers/deployments.go
+++ b/watchers/deployments.go
@@ -1,6 +1,7 @@
package watchers
import (
+ "cmp"
"context"
"fmt"
"sort"
@@ -95,12 +96,17 @@ func (w *Deployments) snapshotDeploymentMetrics(project *db.Project, world *mode
func (w *Deployments) sendNotifications(project *db.Project, world *model.World) {
integrations := project.Settings.Integrations
- categorySettings := project.Settings.ApplicationCategorySettings
now := world.Ctx.To
for _, app := range world.Applications {
- if !categorySettings[app.Category].NotifyOfDeployments {
+ categorySettings := project.Settings.ApplicationCategorySettings[app.Category]
+ if categorySettings == nil {
continue
}
+ notificationSettings := categorySettings.NotificationSettings.Deployments
+ if !notificationSettings.Enabled {
+ continue
+ }
+
for _, ds := range model.CalcApplicationDeploymentStatuses(app, world.CheckConfigs, now) {
d := ds.Deployment
if now.Sub(d.StartedAt) > timeseries.Day {
@@ -113,8 +119,8 @@ func (w *Deployments) sendNotifications(project *db.Project, world *model.World)
continue
}
needSave := false
- if cfg := integrations.Slack; cfg != nil && cfg.Deployments && d.Notifications.Slack.State < ds.State {
- client := notifications.NewSlack(cfg.Token, cfg.DefaultChannel)
+ if slack := integrations.Slack; slack != nil && slack.Deployments && notificationSettings.Slack != nil && notificationSettings.Slack.Enabled && d.Notifications.Slack.State < ds.State {
+ client := notifications.NewSlack(slack.Token, cmp.Or(notificationSettings.Slack.Channel, slack.DefaultChannel))
ctx, cancel := context.WithTimeout(context.Background(), sendTimeout)
err := client.SendDeployment(ctx, project, ds)
cancel()
@@ -125,8 +131,8 @@ func (w *Deployments) sendNotifications(project *db.Project, world *model.World)
needSave = true
}
}
- if cfg := integrations.Teams; cfg != nil && cfg.Deployments && d.Notifications.Teams.State < ds.State {
- client := notifications.NewTeams(cfg.WebhookUrl)
+ if teams := integrations.Teams; teams != nil && teams.Deployments && notificationSettings.Teams != nil && notificationSettings.Teams.Enabled && d.Notifications.Teams.State < ds.State {
+ client := notifications.NewTeams(teams.WebhookUrl)
ctx, cancel := context.WithTimeout(context.Background(), sendTimeout)
err := client.SendDeployment(ctx, project, ds)
cancel()
@@ -137,8 +143,8 @@ func (w *Deployments) sendNotifications(project *db.Project, world *model.World)
needSave = true
}
}
- if cfg := integrations.Webhook; cfg != nil && cfg.Deployments && d.Notifications.Webhook.State < ds.State {
- client := notifications.NewWebhook(cfg)
+ if webhook := integrations.Webhook; webhook != nil && webhook.Deployments && notificationSettings.Webhook != nil && notificationSettings.Webhook.Enabled && d.Notifications.Webhook.State < ds.State {
+ client := notifications.NewWebhook(webhook)
ctx, cancel := context.WithTimeout(context.Background(), sendTimeout)
err := client.SendDeployment(ctx, project, ds)
cancel()
@@ -299,9 +305,9 @@ func calcMetricsSnapshot(app *model.Application, from, to timeseries.Time, step
for level, msgs := range app.LogMessages {
switch level {
- case model.LogLevelCritical, model.LogLevelError:
+ case model.SeverityError, model.SeverityFatal:
logErrors.Add(msgs.Messages)
- case model.LogLevelWarning:
+ case model.SeverityWarning:
logWarnings.Add(msgs.Messages)
}
}