diff --git a/README.md b/README.md index d1e80dafc1..8274e87429 100644 --- a/README.md +++ b/README.md @@ -10,45 +10,68 @@

-


-

Generate distributed traces for any application in k8s without code changes.

+

Generate distributed traces for any application in Kubernetes without code changes.

Demo Video β€’ Documentation β€’ Join Slack Community

+## What is Odigos? + +Odigos is an open-source distributed tracing solution that simplifyes and improves observability for Kubernetes environments. It provides instant tracing capabilities without requiring any code changes to your applications. + +## Key Features + +* **Code-Free Instrumentation** : Set up distributed tracing in minutes, eliminating manual code modifications. +* **Multi-Language Support** : Works with Java, Python, .NET, Node.js, and Go applications. +* **eBPF-Powered** : Utilizes eBPF technology for high-performance instrumentation of Go applications. eBPF-based instrumentation for Java, Python, and Node.js is available in the enterprise edition. +* **OpenTelemetry Compatible** : Generates traces in OpenTelemetry format for broad tool compatibility. +* **Vendor Agnostic** : Integrates with various monitoring solutions, avoiding vendor lock-in. +* **Automatic Scaling** : Manages and scales OpenTelemetry collectors based on data volume. +* **Opinionated Defaults** : Supplies common defaults and best practices out-of-the-box, requiring no deep knowledge of OpenTelemetry. + +## Why Choose Odigos + +1. **Simplicity** : Implement distributed tracing with minimal effort and complexity. +2. **Performance** : Separates data recording and processing to minimize runtime impact. +3. **Community-Backed** : With 3,000+ GitHub stars and a growing contributor base. +4. **Expertise** : Created by multiple maintainers of OpenTelemetry, ensuring deep integration and alignment with industry standards. + +Odigos empowers platform engineers, DevOps professionals, and SREs to enhance their observability strategies quickly and effectively. It is an ideal solution for modern cloud-native environments, combining simplicity, performance, and industry expertise. + +## Features ### ✨ Language Agnostic Auto-instrumentation -Odigos supports any application written in Java, Python, .NET, Node.js, and **Go**. +Odigos supports any application written in Java, Python, .NET, Node.js, and **Go**. Historically, compiled languages like Go have been difficult to instrument without code changes. Odigos solves this problem by uniquely leveraging [eBPF](https://ebpf.io). -![Works on any application](assets/choose_apps.png) - +![Works on any application](docs/images/ui_choose_apps.png) ### 🀝 Keep your existing observability tools -Odigos currently supports all the popular managed and open-source destinations. + +Odigos currently supports all the popular managed and open-source destinations. By producing data in the [OpenTelemetry](https://opentelemetry.io) format, Odigos can be used with any observability tool that supports OTLP. For a complete list of supported destinations, see [here](#supported-destinations). -![Works with any observability tool](assets/choose_dest.png) +![Works with any observability tool](docs/images/ui_choose_dest.png) + +### πŸŽ›οΈ Collectors Management -### πŸŽ›οΈ Collectors Management -Odigos automatically scales OpenTelemetry collectors based on observability data volume. +Odigos automatically scales OpenTelemetry collectors based on observability data volume. Manage and configure collectors via a convenient web UI. -![Collectors Management](assets/overview_page.png) +![Collectors Management](docs/images/ui_overview.png) ## Installation Installing Odigos takes less than 5 minutes and requires no code changes. Download our [CLI](https://docs.odigos.io/installation) and run the following command: - ```bash odigos install ``` @@ -61,32 +84,34 @@ For more details, see our [quickstart guide](https://docs.odigos.io/intro). ### Managed -| | Traces | Metrics | Logs | -|-------------------------| ------- | ------- |------| -| New Relic | βœ… | βœ… | βœ… | -| Datadog | βœ… | βœ… | βœ… | -| Grafana Cloud | βœ… | βœ… | βœ… | -| Honeycomb | βœ… | βœ… | βœ… | -| Chronosphere | βœ… | βœ… | | -| Logz.io | βœ… | βœ… | βœ… | -| qryn.cloud | βœ… | βœ… | βœ… | -| OpsVerse | βœ… | βœ… | βœ… | -| Dynatrace | βœ… | βœ… | βœ… | -| AWS S3 | βœ… | βœ… | βœ… | -| Google Cloud Monitoring | βœ… | | βœ… | -| Google Cloud Storage | βœ… | | βœ… | -| Azure Blob Storage | βœ… | | βœ… | -| Splunk | βœ… | | | -| Lightstep | βœ… | | | -| Sentry | βœ… | | | -| Axiom | βœ… | | βœ… | -| Sumo Logic | βœ… | βœ… | βœ… | -| Coralogix | βœ… | βœ… | βœ… | + +| | Traces | Metrics | Logs | +| ------------------------- | -------- | --------- | ------ | +| New Relic | βœ… | βœ… | βœ… | +| Datadog | βœ… | βœ… | βœ… | +| Grafana Cloud | βœ… | βœ… | βœ… | +| Honeycomb | βœ… | βœ… | βœ… | +| Chronosphere | βœ… | βœ… | | +| Logz.io | βœ… | βœ… | βœ… | +| qryn.cloud | βœ… | βœ… | βœ… | +| OpsVerse | βœ… | βœ… | βœ… | +| Dynatrace | βœ… | βœ… | βœ… | +| AWS S3 | βœ… | βœ… | βœ… | +| Google Cloud Monitoring | βœ… | | βœ… | +| Google Cloud Storage | βœ… | | βœ… | +| Azure Blob Storage | βœ… | | βœ… | +| Splunk | βœ… | | | +| Lightstep | βœ… | | | +| Sentry | βœ… | | | +| Axiom | βœ… | | βœ… | +| Sumo Logic | βœ… | βœ… | βœ… | +| Coralogix | βœ… | βœ… | βœ… | ### Open Source + | | Traces | Metrics | Logs | -| ------------- | ------ | ------- | ---- | +| --------------- | -------- | --------- | ------ | | Prometheus | | βœ… | | | Tempo | βœ… | | | | Loki | | | βœ… | diff --git a/api/config/crd/bases/odigos.io_collectorsgroups.yaml b/api/config/crd/bases/odigos.io_collectorsgroups.yaml index b8dca11fc5..e278eb3be7 100644 --- a/api/config/crd/bases/odigos.io_collectorsgroups.yaml +++ b/api/config/crd/bases/odigos.io_collectorsgroups.yaml @@ -48,20 +48,32 @@ spec: This can be used to resolve conflicting ports when a collector is using the host network. format: int32 type: integer - memorySettings: + resourcesSettings: description: |- - Memory settings for the collectors group. + Resources [memory/cpu] settings for the collectors group. these settings are used to protect the collectors instances from: - running out of memory and being killed by the k8s OOM killer - consuming all available memory on the node which can lead to node instability - pushing back pressure to the instrumented applications properties: + cpuLimitMillicores: + description: |- + CPU resource limit to be used on the pod template. + it will be embedded in the as a resource limit of the form "cpu: m" + type: integer + cpuRequestMillicores: + description: |- + CPU resource request to be used on the pod template. + it will be embedded in the as a resource request of the form "cpu: m" + type: integer gomemlimitMiB: description: |- the GOMEMLIMIT environment variable value for the collector pod. this is when go runtime will start garbage collection. it is recommended to be set to 80% of the hard limit of the memory limiter. type: integer + maxReplicas: + type: integer memoryLimitMiB: description: |- This option sets the limit on the memory usage of the collector. @@ -92,7 +104,13 @@ spec: MemoryRequestMiB is the memory resource request to be used on the pod template. it will be embedded in the as a resource request of the form "memory: Mi" type: integer + minReplicas: + description: Minumum + Maximum number of replicas for the collector + - these relevant only for gateway. + type: integer required: + - cpuLimitMillicores + - cpuRequestMillicores - gomemlimitMiB - memoryLimitMiB - memoryLimiterLimitMiB @@ -106,7 +124,7 @@ spec: type: string required: - collectorOwnMetricsPort - - memorySettings + - resourcesSettings - role type: object status: diff --git a/api/generated/odigos/applyconfiguration/odigos/v1alpha1/collectorsgroupmemorysettings.go b/api/generated/odigos/applyconfiguration/odigos/v1alpha1/collectorsgroupmemorysettings.go deleted file mode 100644 index 4bd5e45d61..0000000000 --- a/api/generated/odigos/applyconfiguration/odigos/v1alpha1/collectorsgroupmemorysettings.go +++ /dev/null @@ -1,74 +0,0 @@ -/* -Copyright 2022. - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. -*/ -// Code generated by applyconfiguration-gen. DO NOT EDIT. - -package v1alpha1 - -// CollectorsGroupMemorySettingsApplyConfiguration represents a declarative configuration of the CollectorsGroupMemorySettings type for use -// with apply. -type CollectorsGroupMemorySettingsApplyConfiguration struct { - MemoryRequestMiB *int `json:"memoryRequestMiB,omitempty"` - MemoryLimitMiB *int `json:"memoryLimitMiB,omitempty"` - MemoryLimiterLimitMiB *int `json:"memoryLimiterLimitMiB,omitempty"` - MemoryLimiterSpikeLimitMiB *int `json:"memoryLimiterSpikeLimitMiB,omitempty"` - GomemlimitMiB *int `json:"gomemlimitMiB,omitempty"` -} - -// CollectorsGroupMemorySettingsApplyConfiguration constructs a declarative configuration of the CollectorsGroupMemorySettings type for use with -// apply. -func CollectorsGroupMemorySettings() *CollectorsGroupMemorySettingsApplyConfiguration { - return &CollectorsGroupMemorySettingsApplyConfiguration{} -} - -// WithMemoryRequestMiB sets the MemoryRequestMiB field in the declarative configuration to the given value -// and returns the receiver, so that objects can be built by chaining "With" function invocations. -// If called multiple times, the MemoryRequestMiB field is set to the value of the last call. -func (b *CollectorsGroupMemorySettingsApplyConfiguration) WithMemoryRequestMiB(value int) *CollectorsGroupMemorySettingsApplyConfiguration { - b.MemoryRequestMiB = &value - return b -} - -// WithMemoryLimitMiB sets the MemoryLimitMiB field in the declarative configuration to the given value -// and returns the receiver, so that objects can be built by chaining "With" function invocations. -// If called multiple times, the MemoryLimitMiB field is set to the value of the last call. -func (b *CollectorsGroupMemorySettingsApplyConfiguration) WithMemoryLimitMiB(value int) *CollectorsGroupMemorySettingsApplyConfiguration { - b.MemoryLimitMiB = &value - return b -} - -// WithMemoryLimiterLimitMiB sets the MemoryLimiterLimitMiB field in the declarative configuration to the given value -// and returns the receiver, so that objects can be built by chaining "With" function invocations. -// If called multiple times, the MemoryLimiterLimitMiB field is set to the value of the last call. -func (b *CollectorsGroupMemorySettingsApplyConfiguration) WithMemoryLimiterLimitMiB(value int) *CollectorsGroupMemorySettingsApplyConfiguration { - b.MemoryLimiterLimitMiB = &value - return b -} - -// WithMemoryLimiterSpikeLimitMiB sets the MemoryLimiterSpikeLimitMiB field in the declarative configuration to the given value -// and returns the receiver, so that objects can be built by chaining "With" function invocations. -// If called multiple times, the MemoryLimiterSpikeLimitMiB field is set to the value of the last call. -func (b *CollectorsGroupMemorySettingsApplyConfiguration) WithMemoryLimiterSpikeLimitMiB(value int) *CollectorsGroupMemorySettingsApplyConfiguration { - b.MemoryLimiterSpikeLimitMiB = &value - return b -} - -// WithGomemlimitMiB sets the GomemlimitMiB field in the declarative configuration to the given value -// and returns the receiver, so that objects can be built by chaining "With" function invocations. -// If called multiple times, the GomemlimitMiB field is set to the value of the last call. -func (b *CollectorsGroupMemorySettingsApplyConfiguration) WithGomemlimitMiB(value int) *CollectorsGroupMemorySettingsApplyConfiguration { - b.GomemlimitMiB = &value - return b -} diff --git a/api/generated/odigos/applyconfiguration/odigos/v1alpha1/collectorsgroupresourcessettings.go b/api/generated/odigos/applyconfiguration/odigos/v1alpha1/collectorsgroupresourcessettings.go new file mode 100644 index 0000000000..635172f561 --- /dev/null +++ b/api/generated/odigos/applyconfiguration/odigos/v1alpha1/collectorsgroupresourcessettings.go @@ -0,0 +1,110 @@ +/* +Copyright 2022. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ +// Code generated by applyconfiguration-gen. DO NOT EDIT. + +package v1alpha1 + +// CollectorsGroupResourcesSettingsApplyConfiguration represents a declarative configuration of the CollectorsGroupResourcesSettings type for use +// with apply. +type CollectorsGroupResourcesSettingsApplyConfiguration struct { + MinReplicas *int `json:"minReplicas,omitempty"` + MaxReplicas *int `json:"maxReplicas,omitempty"` + MemoryRequestMiB *int `json:"memoryRequestMiB,omitempty"` + MemoryLimitMiB *int `json:"memoryLimitMiB,omitempty"` + CpuRequestMillicores *int `json:"cpuRequestMillicores,omitempty"` + CpuLimitMillicores *int `json:"cpuLimitMillicores,omitempty"` + MemoryLimiterLimitMiB *int `json:"memoryLimiterLimitMiB,omitempty"` + MemoryLimiterSpikeLimitMiB *int `json:"memoryLimiterSpikeLimitMiB,omitempty"` + GomemlimitMiB *int `json:"gomemlimitMiB,omitempty"` +} + +// CollectorsGroupResourcesSettingsApplyConfiguration constructs a declarative configuration of the CollectorsGroupResourcesSettings type for use with +// apply. +func CollectorsGroupResourcesSettings() *CollectorsGroupResourcesSettingsApplyConfiguration { + return &CollectorsGroupResourcesSettingsApplyConfiguration{} +} + +// WithMinReplicas sets the MinReplicas field in the declarative configuration to the given value +// and returns the receiver, so that objects can be built by chaining "With" function invocations. +// If called multiple times, the MinReplicas field is set to the value of the last call. +func (b *CollectorsGroupResourcesSettingsApplyConfiguration) WithMinReplicas(value int) *CollectorsGroupResourcesSettingsApplyConfiguration { + b.MinReplicas = &value + return b +} + +// WithMaxReplicas sets the MaxReplicas field in the declarative configuration to the given value +// and returns the receiver, so that objects can be built by chaining "With" function invocations. +// If called multiple times, the MaxReplicas field is set to the value of the last call. +func (b *CollectorsGroupResourcesSettingsApplyConfiguration) WithMaxReplicas(value int) *CollectorsGroupResourcesSettingsApplyConfiguration { + b.MaxReplicas = &value + return b +} + +// WithMemoryRequestMiB sets the MemoryRequestMiB field in the declarative configuration to the given value +// and returns the receiver, so that objects can be built by chaining "With" function invocations. +// If called multiple times, the MemoryRequestMiB field is set to the value of the last call. +func (b *CollectorsGroupResourcesSettingsApplyConfiguration) WithMemoryRequestMiB(value int) *CollectorsGroupResourcesSettingsApplyConfiguration { + b.MemoryRequestMiB = &value + return b +} + +// WithMemoryLimitMiB sets the MemoryLimitMiB field in the declarative configuration to the given value +// and returns the receiver, so that objects can be built by chaining "With" function invocations. +// If called multiple times, the MemoryLimitMiB field is set to the value of the last call. +func (b *CollectorsGroupResourcesSettingsApplyConfiguration) WithMemoryLimitMiB(value int) *CollectorsGroupResourcesSettingsApplyConfiguration { + b.MemoryLimitMiB = &value + return b +} + +// WithCpuRequestMillicores sets the CpuRequestMillicores field in the declarative configuration to the given value +// and returns the receiver, so that objects can be built by chaining "With" function invocations. +// If called multiple times, the CpuRequestMillicores field is set to the value of the last call. +func (b *CollectorsGroupResourcesSettingsApplyConfiguration) WithCpuRequestMillicores(value int) *CollectorsGroupResourcesSettingsApplyConfiguration { + b.CpuRequestMillicores = &value + return b +} + +// WithCpuLimitMillicores sets the CpuLimitMillicores field in the declarative configuration to the given value +// and returns the receiver, so that objects can be built by chaining "With" function invocations. +// If called multiple times, the CpuLimitMillicores field is set to the value of the last call. +func (b *CollectorsGroupResourcesSettingsApplyConfiguration) WithCpuLimitMillicores(value int) *CollectorsGroupResourcesSettingsApplyConfiguration { + b.CpuLimitMillicores = &value + return b +} + +// WithMemoryLimiterLimitMiB sets the MemoryLimiterLimitMiB field in the declarative configuration to the given value +// and returns the receiver, so that objects can be built by chaining "With" function invocations. +// If called multiple times, the MemoryLimiterLimitMiB field is set to the value of the last call. +func (b *CollectorsGroupResourcesSettingsApplyConfiguration) WithMemoryLimiterLimitMiB(value int) *CollectorsGroupResourcesSettingsApplyConfiguration { + b.MemoryLimiterLimitMiB = &value + return b +} + +// WithMemoryLimiterSpikeLimitMiB sets the MemoryLimiterSpikeLimitMiB field in the declarative configuration to the given value +// and returns the receiver, so that objects can be built by chaining "With" function invocations. +// If called multiple times, the MemoryLimiterSpikeLimitMiB field is set to the value of the last call. +func (b *CollectorsGroupResourcesSettingsApplyConfiguration) WithMemoryLimiterSpikeLimitMiB(value int) *CollectorsGroupResourcesSettingsApplyConfiguration { + b.MemoryLimiterSpikeLimitMiB = &value + return b +} + +// WithGomemlimitMiB sets the GomemlimitMiB field in the declarative configuration to the given value +// and returns the receiver, so that objects can be built by chaining "With" function invocations. +// If called multiple times, the GomemlimitMiB field is set to the value of the last call. +func (b *CollectorsGroupResourcesSettingsApplyConfiguration) WithGomemlimitMiB(value int) *CollectorsGroupResourcesSettingsApplyConfiguration { + b.GomemlimitMiB = &value + return b +} diff --git a/api/generated/odigos/applyconfiguration/odigos/v1alpha1/collectorsgroupspec.go b/api/generated/odigos/applyconfiguration/odigos/v1alpha1/collectorsgroupspec.go index f4ff9a4605..0fdc93d964 100644 --- a/api/generated/odigos/applyconfiguration/odigos/v1alpha1/collectorsgroupspec.go +++ b/api/generated/odigos/applyconfiguration/odigos/v1alpha1/collectorsgroupspec.go @@ -24,9 +24,9 @@ import ( // CollectorsGroupSpecApplyConfiguration represents a declarative configuration of the CollectorsGroupSpec type for use // with apply. type CollectorsGroupSpecApplyConfiguration struct { - Role *v1alpha1.CollectorsGroupRole `json:"role,omitempty"` - CollectorOwnMetricsPort *int32 `json:"collectorOwnMetricsPort,omitempty"` - MemorySettings *CollectorsGroupMemorySettingsApplyConfiguration `json:"memorySettings,omitempty"` + Role *v1alpha1.CollectorsGroupRole `json:"role,omitempty"` + CollectorOwnMetricsPort *int32 `json:"collectorOwnMetricsPort,omitempty"` + ResourcesSettings *CollectorsGroupResourcesSettingsApplyConfiguration `json:"resourcesSettings,omitempty"` } // CollectorsGroupSpecApplyConfiguration constructs a declarative configuration of the CollectorsGroupSpec type for use with @@ -51,10 +51,10 @@ func (b *CollectorsGroupSpecApplyConfiguration) WithCollectorOwnMetricsPort(valu return b } -// WithMemorySettings sets the MemorySettings field in the declarative configuration to the given value +// WithResourcesSettings sets the ResourcesSettings field in the declarative configuration to the given value // and returns the receiver, so that objects can be built by chaining "With" function invocations. -// If called multiple times, the MemorySettings field is set to the value of the last call. -func (b *CollectorsGroupSpecApplyConfiguration) WithMemorySettings(value *CollectorsGroupMemorySettingsApplyConfiguration) *CollectorsGroupSpecApplyConfiguration { - b.MemorySettings = value +// If called multiple times, the ResourcesSettings field is set to the value of the last call. +func (b *CollectorsGroupSpecApplyConfiguration) WithResourcesSettings(value *CollectorsGroupResourcesSettingsApplyConfiguration) *CollectorsGroupSpecApplyConfiguration { + b.ResourcesSettings = value return b } diff --git a/api/generated/odigos/applyconfiguration/utils.go b/api/generated/odigos/applyconfiguration/utils.go index 954bd9be8b..c4383287bf 100644 --- a/api/generated/odigos/applyconfiguration/utils.go +++ b/api/generated/odigos/applyconfiguration/utils.go @@ -41,8 +41,8 @@ func ForKind(kind schema.GroupVersionKind) interface{} { return &odigosv1alpha1.CollectorGatewayConfigurationApplyConfiguration{} case v1alpha1.SchemeGroupVersion.WithKind("CollectorsGroup"): return &odigosv1alpha1.CollectorsGroupApplyConfiguration{} - case v1alpha1.SchemeGroupVersion.WithKind("CollectorsGroupMemorySettings"): - return &odigosv1alpha1.CollectorsGroupMemorySettingsApplyConfiguration{} + case v1alpha1.SchemeGroupVersion.WithKind("CollectorsGroupResourcesSettings"): + return &odigosv1alpha1.CollectorsGroupResourcesSettingsApplyConfiguration{} case v1alpha1.SchemeGroupVersion.WithKind("CollectorsGroupSpec"): return &odigosv1alpha1.CollectorsGroupSpecApplyConfiguration{} case v1alpha1.SchemeGroupVersion.WithKind("CollectorsGroupStatus"): diff --git a/api/odigos/v1alpha1/collectorsgroup_types.go b/api/odigos/v1alpha1/collectorsgroup_types.go index a8187f53b3..5a674ee132 100644 --- a/api/odigos/v1alpha1/collectorsgroup_types.go +++ b/api/odigos/v1alpha1/collectorsgroup_types.go @@ -30,11 +30,15 @@ const ( CollectorsGroupRoleNodeCollector CollectorsGroupRole = CollectorsGroupRole(k8sconsts.CollectorsRoleNodeCollector) ) -// The raw values of the memory settings for the collectors group. +// The raw values to control the collectors group resources and behavior. // any defaulting, validations and calculations should be done in the controllers // that create this CR. // Values will be used as is without any further processing. -type CollectorsGroupMemorySettings struct { +type CollectorsGroupResourcesSettings struct { + + // Minumum + Maximum number of replicas for the collector - these relevant only for gateway. + MinReplicas *int `json:"minReplicas,omitempty"` + MaxReplicas *int `json:"maxReplicas,omitempty"` // MemoryRequestMiB is the memory resource request to be used on the pod template. // it will be embedded in the as a resource request of the form "memory: Mi" @@ -47,6 +51,13 @@ type CollectorsGroupMemorySettings struct { // so one can set this to the same value as the memory request or higher to allow for some buffer for bursts. MemoryLimitMiB int `json:"memoryLimitMiB"` + // CPU resource request to be used on the pod template. + // it will be embedded in the as a resource request of the form "cpu: m" + CpuRequestMillicores int `json:"cpuRequestMillicores"` + // CPU resource limit to be used on the pod template. + // it will be embedded in the as a resource limit of the form "cpu: m" + CpuLimitMillicores int `json:"cpuLimitMillicores"` + // this parameter sets the "limit_mib" parameter in the memory limiter configuration for the collector. // it is the hard limit after which a force garbage collection will be performed. // this value will end up comparing against the go runtime reported heap Alloc value. @@ -76,12 +87,12 @@ type CollectorsGroupSpec struct { // This can be used to resolve conflicting ports when a collector is using the host network. CollectorOwnMetricsPort int32 `json:"collectorOwnMetricsPort"` - // Memory settings for the collectors group. + // Resources [memory/cpu] settings for the collectors group. // these settings are used to protect the collectors instances from: // - running out of memory and being killed by the k8s OOM killer // - consuming all available memory on the node which can lead to node instability // - pushing back pressure to the instrumented applications - MemorySettings CollectorsGroupMemorySettings `json:"memorySettings"` + ResourcesSettings CollectorsGroupResourcesSettings `json:"resourcesSettings"` } // CollectorsGroupStatus defines the observed state of Collector diff --git a/api/odigos/v1alpha1/zz_generated.deepcopy.go b/api/odigos/v1alpha1/zz_generated.deepcopy.go index 07567069bf..f825166ae7 100644 --- a/api/odigos/v1alpha1/zz_generated.deepcopy.go +++ b/api/odigos/v1alpha1/zz_generated.deepcopy.go @@ -99,7 +99,7 @@ func (in *CollectorsGroup) DeepCopyInto(out *CollectorsGroup) { *out = *in out.TypeMeta = in.TypeMeta in.ObjectMeta.DeepCopyInto(&out.ObjectMeta) - out.Spec = in.Spec + in.Spec.DeepCopyInto(&out.Spec) in.Status.DeepCopyInto(&out.Status) } @@ -154,16 +154,26 @@ func (in *CollectorsGroupList) DeepCopyObject() runtime.Object { } // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. -func (in *CollectorsGroupMemorySettings) DeepCopyInto(out *CollectorsGroupMemorySettings) { +func (in *CollectorsGroupResourcesSettings) DeepCopyInto(out *CollectorsGroupResourcesSettings) { *out = *in + if in.MinReplicas != nil { + in, out := &in.MinReplicas, &out.MinReplicas + *out = new(int) + **out = **in + } + if in.MaxReplicas != nil { + in, out := &in.MaxReplicas, &out.MaxReplicas + *out = new(int) + **out = **in + } } -// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new CollectorsGroupMemorySettings. -func (in *CollectorsGroupMemorySettings) DeepCopy() *CollectorsGroupMemorySettings { +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new CollectorsGroupResourcesSettings. +func (in *CollectorsGroupResourcesSettings) DeepCopy() *CollectorsGroupResourcesSettings { if in == nil { return nil } - out := new(CollectorsGroupMemorySettings) + out := new(CollectorsGroupResourcesSettings) in.DeepCopyInto(out) return out } @@ -171,7 +181,7 @@ func (in *CollectorsGroupMemorySettings) DeepCopy() *CollectorsGroupMemorySettin // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *CollectorsGroupSpec) DeepCopyInto(out *CollectorsGroupSpec) { *out = *in - out.MemorySettings = in.MemorySettings + in.ResourcesSettings.DeepCopyInto(&out.ResourcesSettings) } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new CollectorsGroupSpec. diff --git a/assets/choose_apps.png b/assets/choose_apps.png deleted file mode 100644 index 2f426df636..0000000000 Binary files a/assets/choose_apps.png and /dev/null differ diff --git a/assets/choose_dest.png b/assets/choose_dest.png deleted file mode 100644 index ae9d159958..0000000000 Binary files a/assets/choose_dest.png and /dev/null differ diff --git a/assets/dests.png b/assets/dests.png deleted file mode 100644 index e923d143be..0000000000 Binary files a/assets/dests.png and /dev/null differ diff --git a/assets/hacktoberfest_tee.png b/assets/hacktoberfest_tee.png deleted file mode 100644 index 41d5e5a01c..0000000000 Binary files a/assets/hacktoberfest_tee.png and /dev/null differ diff --git a/assets/odigos-cover.jpg b/assets/odigos-cover.jpg deleted file mode 100644 index c95e56cbc3..0000000000 Binary files a/assets/odigos-cover.jpg and /dev/null differ diff --git a/assets/overview_page.png b/assets/overview_page.png deleted file mode 100644 index faa88c1ab9..0000000000 Binary files a/assets/overview_page.png and /dev/null differ diff --git a/autoscaler/controllers/gateway/configmap.go b/autoscaler/controllers/gateway/configmap.go index 1547517e1f..1c4f0d7ff4 100644 --- a/autoscaler/controllers/gateway/configmap.go +++ b/autoscaler/controllers/gateway/configmap.go @@ -116,8 +116,8 @@ func syncConfigMap(dests *odigosv1.DestinationList, allProcessors *odigosv1.Proc memoryLimiterConfiguration := config.GenericMap{ "check_interval": "1s", - "limit_mib": gateway.Spec.MemorySettings.MemoryLimiterLimitMiB, - "spike_limit_mib": gateway.Spec.MemorySettings.MemoryLimiterSpikeLimitMiB, + "limit_mib": gateway.Spec.ResourcesSettings.MemoryLimiterLimitMiB, + "spike_limit_mib": gateway.Spec.ResourcesSettings.MemoryLimiterSpikeLimitMiB, } processors := common.FilterAndSortProcessorsByOrderHint(allProcessors, odigosv1.CollectorsGroupRoleClusterGateway) diff --git a/autoscaler/controllers/gateway/deployment.go b/autoscaler/controllers/gateway/deployment.go index e15f98a45c..207d7ae65c 100644 --- a/autoscaler/controllers/gateway/deployment.go +++ b/autoscaler/controllers/gateway/deployment.go @@ -90,8 +90,18 @@ func patchDeployment(existing *appsv1.Deployment, desired *appsv1.Deployment, ct func getDesiredDeployment(dests *odigosv1.DestinationList, configDataHash string, gateway *odigosv1.CollectorsGroup, scheme *runtime.Scheme, imagePullSecrets []string, odigosVersion string) (*appsv1.Deployment, error) { - requestMemoryQuantity := resource.MustParse(fmt.Sprintf("%dMi", gateway.Spec.MemorySettings.MemoryRequestMiB)) - limitMemoryQuantity := resource.MustParse(fmt.Sprintf("%dMi", gateway.Spec.MemorySettings.MemoryLimitMiB)) + // request + limits for memory and cpu + requestMemoryQuantity := resource.MustParse(fmt.Sprintf("%dMi", gateway.Spec.ResourcesSettings.MemoryRequestMiB)) + limitMemoryQuantity := resource.MustParse(fmt.Sprintf("%dMi", gateway.Spec.ResourcesSettings.MemoryLimitMiB)) + + requestCPU := resource.MustParse(fmt.Sprintf("%dm", gateway.Spec.ResourcesSettings.CpuRequestMillicores)) + limitCPU := resource.MustParse(fmt.Sprintf("%dm", gateway.Spec.ResourcesSettings.CpuLimitMillicores)) + + // deployment replicas + var gatewayReplicas int32 = 1 + if gateway.Spec.ResourcesSettings.MinReplicas != nil { + gatewayReplicas = int32(*gateway.Spec.ResourcesSettings.MinReplicas) + } desiredDeployment := &appsv1.Deployment{ ObjectMeta: v1.ObjectMeta{ @@ -100,7 +110,7 @@ func getDesiredDeployment(dests *odigosv1.DestinationList, configDataHash string Labels: ClusterCollectorGateway, }, Spec: appsv1.DeploymentSpec{ - Replicas: intPtr(1), + Replicas: intPtr(gatewayReplicas), Selector: &v1.LabelSelector{ MatchLabels: ClusterCollectorGateway, }, @@ -159,7 +169,7 @@ func getDesiredDeployment(dests *odigosv1.DestinationList, configDataHash string }, { Name: "GOMEMLIMIT", - Value: fmt.Sprintf("%dMiB", gateway.Spec.MemorySettings.GomemlimitMiB), + Value: fmt.Sprintf("%dMiB", gateway.Spec.ResourcesSettings.GomemlimitMiB), }, }, SecurityContext: &corev1.SecurityContext{ @@ -190,9 +200,11 @@ func getDesiredDeployment(dests *odigosv1.DestinationList, configDataHash string Resources: corev1.ResourceRequirements{ Requests: corev1.ResourceList{ corev1.ResourceMemory: requestMemoryQuantity, + corev1.ResourceCPU: requestCPU, }, Limits: corev1.ResourceList{ corev1.ResourceMemory: limitMemoryQuantity, + corev1.ResourceCPU: limitCPU, }, }, }, diff --git a/autoscaler/controllers/gateway/hpa.go b/autoscaler/controllers/gateway/hpa.go index a718cde6a1..6887f9f9fb 100644 --- a/autoscaler/controllers/gateway/hpa.go +++ b/autoscaler/controllers/gateway/hpa.go @@ -22,11 +22,12 @@ import ( const ( memoryLimitPercentageForHPA = 75 + cpuLimitPercentageForHPA = 75 ) var ( - minReplicas = intPtr(1) - maxReplicas = int32(10) + defaultMinReplicas = intPtr(1) + defaultMaxReplicas = int32(10) stabilizationWindowSeconds = intPtr(300) // cooldown period for scaling down ) @@ -35,9 +36,24 @@ func syncHPA(gateway *odigosv1.CollectorsGroup, ctx context.Context, c client.Cl var hpa client.Object - memLimit := gateway.Spec.MemorySettings.GomemlimitMiB * memoryLimitPercentageForHPA / 100.0 + // Memory metric calculation + memLimit := gateway.Spec.ResourcesSettings.GomemlimitMiB * memoryLimitPercentageForHPA / 100 metricQuantity := resource.MustParse(fmt.Sprintf("%dMi", memLimit)) + // CPU metric calculation + cpuTargetMillicores := gateway.Spec.ResourcesSettings.CpuLimitMillicores * cpuLimitPercentageForHPA / 100 + metricQuantityCPU := resource.MustParse(fmt.Sprintf("%dm", cpuTargetMillicores)) + + minReplicas := defaultMinReplicas + if gateway.Spec.ResourcesSettings.MinReplicas != nil && *gateway.Spec.ResourcesSettings.MinReplicas > 0 { + minReplicas = intPtr(int32(*gateway.Spec.ResourcesSettings.MinReplicas)) + } + + maxReplicas := defaultMaxReplicas + if gateway.Spec.ResourcesSettings.MaxReplicas != nil && *gateway.Spec.ResourcesSettings.MaxReplicas > 0 { + maxReplicas = int32(*gateway.Spec.ResourcesSettings.MaxReplicas) + } + switch { case kubeVersion.LessThan(version.MustParse("1.23.0")): hpa = &autoscalingv2beta1.HorizontalPodAutoscaler{ @@ -62,6 +78,13 @@ func syncHPA(gateway *odigosv1.CollectorsGroup, ctx context.Context, c client.Cl TargetAverageValue: &metricQuantity, }, }, + { + Type: autoscalingv2beta1.ResourceMetricSourceType, + Resource: &autoscalingv2beta1.ResourceMetricSource{ + Name: "cpu", + TargetAverageValue: &metricQuantityCPU, + }, + }, }, }, } @@ -91,6 +114,16 @@ func syncHPA(gateway *odigosv1.CollectorsGroup, ctx context.Context, c client.Cl }, }, }, + { + Type: autoscalingv2beta2.ResourceMetricSourceType, + Resource: &autoscalingv2beta2.ResourceMetricSource{ + Name: "cpu", + Target: autoscalingv2beta2.MetricTarget{ + Type: autoscalingv2beta2.AverageValueMetricType, + AverageValue: &metricQuantityCPU, + }, + }, + }, }, Behavior: &autoscalingv2beta2.HorizontalPodAutoscalerBehavior{ ScaleDown: &autoscalingv2beta2.HPAScalingRules{ @@ -125,6 +158,16 @@ func syncHPA(gateway *odigosv1.CollectorsGroup, ctx context.Context, c client.Cl }, }, }, + { + Type: autoscalingv2.ResourceMetricSourceType, + Resource: &autoscalingv2.ResourceMetricSource{ + Name: "cpu", + Target: autoscalingv2.MetricTarget{ + Type: autoscalingv2.AverageValueMetricType, + AverageValue: &metricQuantityCPU, + }, + }, + }, }, Behavior: &autoscalingv2.HorizontalPodAutoscalerBehavior{ ScaleDown: &autoscalingv2.HPAScalingRules{ diff --git a/cli/cmd/resources/profiles/semconv-deprecated.yaml b/cli/cmd/resources/profiles/semconv-deprecated.yaml new file mode 100644 index 0000000000..f4925ae38a --- /dev/null +++ b/cli/cmd/resources/profiles/semconv-deprecated.yaml @@ -0,0 +1,19 @@ +apiVersion: actions.odigos.io/v1alpha1 +kind: RenameAttribute +metadata: + name: semconv +spec: + actionName: "semconv" + notes: "Auto generated rule from semconv profile. Do not edit." + renames: + net.peer.address: network.peer.address + net.local.address: network.local.address + net.peer.ip: network.peer.address + net.peer.port: network.peer.port + net.host.ip: network.local.address + net.host.name: server.address + net.host.port: server.port + net.transport: network.transport + db.operation: db.operation.name + signals: + - TRACES \ No newline at end of file diff --git a/cli/cmd/resources/profiles/semconv.yaml b/cli/cmd/resources/profiles/semconv.yaml index 450de49409..6f872d391f 100644 --- a/cli/cmd/resources/profiles/semconv.yaml +++ b/cli/cmd/resources/profiles/semconv.yaml @@ -1,19 +1,63 @@ -apiVersion: actions.odigos.io/v1alpha1 -kind: RenameAttribute +apiVersion: odigos.io/v1alpha1 +kind: Processor metadata: name: semconv + namespace: odigos-system spec: - actionName: "semconv" + type: attributes + processorName: semconv-attributes notes: "Auto generated rule from semconv profile. Do not edit." - renames: - net.peer.address: network.peer.address - net.local.address: network.local.address - net.peer.ip: network.peer.address - net.peer.port: network.peer.port - net.host.ip: network.local.address - net.host.name: server.address - net.host.port: server.port - net.transport: network.transport - db.operation: db.operation.name + processorConfig: + actions: + - key: network.peer.address + from_attribute: net.peer.address + action: upsert + - key: net.peer.address + action: delete + - key: network.local.address + from_attribute: net.local.address + action: upsert + - key: net.local.address + action: delete + - key: network.peer.address + from_attribute: net.peer.ip + action: upsert + - key: net.peer.ip + action: delete + - key: network.peer.port + from_attribute: net.peer.port + action: upsert + - key: net.peer.port + action: delete + - key: network.local.address + from_attribute: net.host.ip + action: upsert + - key: net.host.ip + action: delete + - key: server.address + from_attribute: net.host.name + action: upsert + - key: net.host.name + action: delete + - key: server.port + from_attribute: net.host.port + action: upsert + - key: net.host.port + action: delete + - key: network.transport + from_attribute: net.transport + action: upsert + - key: net.transport + action: delete + - key: db.operation.name + from_attribute: db.operation + action: upsert + - key: db.operation + action: delete + + + signals: - TRACES + collectorRoles: + - CLUSTER_GATEWAY \ No newline at end of file diff --git a/common/config/last9.go b/common/config/last9.go new file mode 100644 index 0000000000..938d4d4b4c --- /dev/null +++ b/common/config/last9.go @@ -0,0 +1,60 @@ +package config + +import ( + "errors" + + "github.com/odigos-io/odigos/common" +) + +const ( + l9OtlpEndpointKey = "LAST9_OTLP_ENDPOINT" + l9OtlpAuthHeaderKey = "LAST9_OTLP_BASIC_AUTH_HEADER" +) + +type Last9 struct{} + +func (m *Last9) DestType() common.DestinationType { + // DestinationType defined in common/dests.go + return common.Last9DestinationType +} + +func (m *Last9) ModifyConfig(dest ExporterConfigurer, currentConfig *Config) error { + config := dest.GetConfig() + l9OtlpEndpoint, exists := config[l9OtlpEndpointKey] + if !exists { + return errors.New("Last9 OpenTelemetry Endpoint key(\"LAST9_OTLP_ENDPOINT\") not specified, Last9 will not be configured") + } + + // to make sure that the exporter name is unique, we'll ask a ID from destination + exporterName := "otlp/last9-" + dest.GetID() + currentConfig.Exporters[exporterName] = GenericMap{ + "endpoint": l9OtlpEndpoint, + "headers": GenericMap{ + "Authorization": "${LAST9_OTLP_BASIC_AUTH_HEADER}", + }, + } + + // Modify the config here + if isTracingEnabled(dest) { + tracesPipelineName := "traces/last9-" + dest.GetID() + currentConfig.Service.Pipelines[tracesPipelineName] = Pipeline{ + Exporters: []string{exporterName}, + } + } + + if isMetricsEnabled(dest) { + metricsPipelineName := "metrics/last9-" + dest.GetID() + currentConfig.Service.Pipelines[metricsPipelineName] = Pipeline{ + Exporters: []string{exporterName}, + } + } + + if isLoggingEnabled(dest) { + logsPipelineName := "logs/last9-" + dest.GetID() + currentConfig.Service.Pipelines[logsPipelineName] = Pipeline{ + Exporters: []string{exporterName}, + } + } + + return nil +} diff --git a/common/config/root.go b/common/config/root.go index b619cda4e6..643cd55ec5 100644 --- a/common/config/root.go +++ b/common/config/root.go @@ -16,7 +16,7 @@ const ( var availableConfigers = []Configer{ &Middleware{}, &Honeycomb{}, &GrafanaCloudPrometheus{}, &GrafanaCloudTempo{}, - &GrafanaCloudLoki{}, &Datadog{}, &NewRelic{}, &Logzio{}, &Prometheus{}, + &GrafanaCloudLoki{}, &Datadog{}, &NewRelic{}, &Logzio{}, &Last9{}, &Prometheus{}, &Tempo{}, &Loki{}, &Jaeger{}, &GenericOTLP{}, &OTLPHttp{}, &Elasticsearch{}, &Quickwit{}, &Signoz{}, &Qryn{}, &OpsVerse{}, &Splunk{}, &Lightstep{}, &GoogleCloud{}, &GoogleCloudStorage{}, &Sentry{}, &AzureBlobStorage{}, &AWSS3{}, &Dynatrace{}, &Chronosphere{}, &ElasticAPM{}, &Axiom{}, &SumoLogic{}, &Coralogix{}, &Clickhouse{}, diff --git a/common/dests.go b/common/dests.go index 014bc09dee..5c2f1dad5c 100644 --- a/common/dests.go +++ b/common/dests.go @@ -23,6 +23,7 @@ const ( GrafanaCloudTempoDestinationType DestinationType = "grafanacloudtempo" HoneycombDestinationType DestinationType = "honeycomb" JaegerDestinationType DestinationType = "jaeger" + Last9DestinationType DestinationType = "last9" LightstepDestinationType DestinationType = "lightstep" LogzioDestinationType DestinationType = "logzio" LokiDestinationType DestinationType = "loki" diff --git a/common/odigos_config.go b/common/odigos_config.go index d6434dda4e..196cb7641e 100644 --- a/common/odigos_config.go +++ b/common/odigos_config.go @@ -9,11 +9,28 @@ type CollectorNodeConfiguration struct { } type CollectorGatewayConfiguration struct { + // MinReplicas is the number of replicas for the cluster gateway collector deployment. + // Also set the minReplicas for the HPA to this value. + MinReplicas int `json:"minReplicas,omitempty"` + + // MaxReplicas set the maxReplicas for the HPA to this value. + MaxReplicas int `json:"maxReplicas,omitempty"` + // RequestMemoryMiB is the memory request for the cluster gateway collector deployment. // it will be embedded in the deployment as a resource request of the form "memory: Mi" // default value is 500Mi RequestMemoryMiB int `json:"requestMemoryMiB,omitempty"` + // RequestCPUm is the CPU request for the cluster gateway collector deployment. + // it will be embedded in the deployment as a resource request of the form "cpu: m" + // default value is 500m + RequestCPUm int `json:"requestCPUm,omitempty"` + + // LimitCPUm is the CPU limit for the cluster gateway collector deployment. + // it will be embedded in the deployment as a resource limit of the form "cpu: m" + // default value is 1000m + LimitCPUm int `json:"limitCPUm,omitempty"` + // this parameter sets the "limit_mib" parameter in the memory limiter configuration for the collector gateway. // it is the hard limit after which a force garbage collection will be performed. // if not set, it will be 50Mi below the memory request. @@ -32,19 +49,19 @@ type CollectorGatewayConfiguration struct { // OdigosConfiguration defines the desired state of OdigosConfiguration type OdigosConfiguration struct { - ConfigVersion int `json:"configVersion"` - TelemetryEnabled bool `json:"telemetryEnabled,omitempty"` - OpenshiftEnabled bool `json:"openshiftEnabled,omitempty"` - IgnoredNamespaces []string `json:"ignoredNamespaces,omitempty"` - IgnoredContainers []string `json:"ignoredContainers,omitempty"` - Psp bool `json:"psp,omitempty"` - ImagePrefix string `json:"imagePrefix,omitempty"` - OdigletImage string `json:"odigletImage,omitempty"` - InstrumentorImage string `json:"instrumentorImage,omitempty"` - AutoscalerImage string `json:"autoscalerImage,omitempty"` - CollectorGateway *CollectorGatewayConfiguration `json:"collectorGateway,omitempty"` - CollectorNode *CollectorNodeConfiguration `json:"collectorNode,omitempty"` - Profiles []ProfileName `json:"profiles,omitempty"` + ConfigVersion int `json:"configVersion"` + TelemetryEnabled bool `json:"telemetryEnabled,omitempty"` + OpenshiftEnabled bool `json:"openshiftEnabled,omitempty"` + IgnoredNamespaces []string `json:"ignoredNamespaces,omitempty"` + IgnoredContainers []string `json:"ignoredContainers,omitempty"` + Psp bool `json:"psp,omitempty"` + ImagePrefix string `json:"imagePrefix,omitempty"` + OdigletImage string `json:"odigletImage,omitempty"` + InstrumentorImage string `json:"instrumentorImage,omitempty"` + AutoscalerImage string `json:"autoscalerImage,omitempty"` + CollectorGateway *CollectorGatewayConfiguration `json:"collectorGateway,omitempty"` + CollectorNode *CollectorNodeConfiguration `json:"collectorNode,omitempty"` + Profiles []ProfileName `json:"profiles,omitempty"` // this is internal currently, and is not exposed on the CLI / helm // used for odigos enterprise diff --git a/destinations/data/last9.yaml b/destinations/data/last9.yaml new file mode 100644 index 0000000000..a345a4b0ce --- /dev/null +++ b/destinations/data/last9.yaml @@ -0,0 +1,31 @@ +apiVersion: internal.odigos.io/v1beta1 +kind: Destination +metadata: + type: last9 + displayName: Last9 + category: managed +spec: + image: last9.svg + signals: + traces: + supported: true + metrics: + supported: true + logs: + supported: true + fields: + - name: LAST9_OTLP_ENDPOINT + displayName: Last9 OpenTelemetry Endpoint + componentType: input + componentProps: + type: text + required: true + tooltip: 'Last9 OpenTelemetry Endpoint. Can be found at https://app.last9.io/integrations?category=all&integration=OpenTelemetry' + - name: LAST9_OTLP_BASIC_AUTH_HEADER + displayName: Basic Auth Header + componentType: input + secret: true + componentProps: + type: password + required: true + placeholder: "Basic ..." diff --git a/destinations/logos/last9.svg b/destinations/logos/last9.svg new file mode 100644 index 0000000000..fd6c2b639b --- /dev/null +++ b/destinations/logos/last9.svg @@ -0,0 +1 @@ + diff --git a/docs/backends/last9.mdx b/docs/backends/last9.mdx new file mode 100644 index 0000000000..9f26d42a87 --- /dev/null +++ b/docs/backends/last9.mdx @@ -0,0 +1,72 @@ +--- +title: "Last9" +--- + +## Obtaining Last9 OpenTelemetry Endpoint and Basic Auth Header + +[Click here](https://app.last9.io/integrations?category=all&integration=OpenTelemetry) to visit the Last9 OpenTelemetry integration page. + + + OpenTelemetry integration in Last9 + + +## Configuring Last9 Backend + +- **Endpoint** - Last9 OpenTelemetry Endpoint obtained in above step. +- **Authorization Header**: Last9 OpenTelemetry Basic Auth Header obtained in above step. + +## Adding a Destination to Odigos + +Odigos makes it simple to add and configure destinations, allowing you to select the specific signals [traces/logs/metrics] that you want to send to each destination. There are two primary methods for configuring destinations in Odigos: + +1. **Using the UI** + To add a destination via the UI, follow these steps: + - Use the Odigos CLI to access the UI: [Odigos UI](https://docs.odigos.io/cli/odigos_ui) + ```bash + odigos ui + ``` +- In the left sidebar, navigate to the `Destination` page. + +- Click `Add New Destination` + +- Select `Last9` and follow the on-screen instructions. + + + +2. **Using kubernetes manifests** + +Save the YAML below to a file (e.g., `destination.yaml`) and apply it using `kubectl`: + +```bash +kubectl apply -f destination.yaml +``` + + +```yaml +apiVersion: odigos.io/v1alpha1 +kind: Destination +metadata: + name: last9-example + namespace: odigos-system +spec: + data: + LAST9_OTLP_ENDPOINT: + destinationName: last9 + secretRef: + name: last9-secret + signals: + - TRACES + - METRICS + - LOGS + type: last9 + +--- +apiVersion: v1 +data: + LAST9_OTLP_BASIC_AUTH_HEADER: +kind: Secret +metadata: + name: last9-secret + namespace: odigos-system +type: Opaque +``` diff --git a/docs/images/choose_apps.png b/docs/images/choose_apps.png deleted file mode 100644 index f511209036..0000000000 Binary files a/docs/images/choose_apps.png and /dev/null differ diff --git a/docs/images/choose_dest.png b/docs/images/choose_dest.png deleted file mode 100644 index ae9d159958..0000000000 Binary files a/docs/images/choose_dest.png and /dev/null differ diff --git a/docs/images/choose_jaeger.png b/docs/images/choose_jaeger.png deleted file mode 100644 index 73bdb5ec24..0000000000 Binary files a/docs/images/choose_jaeger.png and /dev/null differ diff --git a/docs/images/jaeger_connection.png b/docs/images/jaeger_connection.png deleted file mode 100644 index 5d2faeaa3f..0000000000 Binary files a/docs/images/jaeger_connection.png and /dev/null differ diff --git a/docs/images/last9.png b/docs/images/last9.png new file mode 100644 index 0000000000..000f04cf1f Binary files /dev/null and b/docs/images/last9.png differ diff --git a/docs/images/observability_pipeline.png b/docs/images/observability_pipeline.png deleted file mode 100644 index 1d49d23413..0000000000 Binary files a/docs/images/observability_pipeline.png and /dev/null differ diff --git a/docs/images/ui_choose_apps.png b/docs/images/ui_choose_apps.png new file mode 100644 index 0000000000..ab92110119 Binary files /dev/null and b/docs/images/ui_choose_apps.png differ diff --git a/docs/images/ui_choose_dest.png b/docs/images/ui_choose_dest.png new file mode 100644 index 0000000000..707a301f4d Binary files /dev/null and b/docs/images/ui_choose_dest.png differ diff --git a/docs/images/ui_jaeger_connection.png b/docs/images/ui_jaeger_connection.png new file mode 100644 index 0000000000..b957e9232e Binary files /dev/null and b/docs/images/ui_jaeger_connection.png differ diff --git a/docs/images/ui_overview.png b/docs/images/ui_overview.png new file mode 100644 index 0000000000..4ccd20f5b2 Binary files /dev/null and b/docs/images/ui_overview.png differ diff --git a/docs/mint.json b/docs/mint.json index b00e7b5b37..32718b0c88 100644 --- a/docs/mint.json +++ b/docs/mint.json @@ -125,7 +125,7 @@ "instrumentations/java/java", "instrumentations/java/ebpf" ] - } + } ] }, { @@ -218,6 +218,7 @@ "backends/honeycomb", "backends/jaeger", "backends/lightstep", + "backends/last9", "backends/logzio", "backends/loki", "backends/newrelic", diff --git a/docs/quickstart/building-a-pipeline.mdx b/docs/quickstart/building-a-pipeline.mdx index 69fcf3fe7e..9756c6e3e7 100644 --- a/docs/quickstart/building-a-pipeline.mdx +++ b/docs/quickstart/building-a-pipeline.mdx @@ -8,7 +8,7 @@ sidebarTitle: "Building a pipeline" You should now see the following page: - Select target applications + Select target applications Select all the applications in the `default` namespace and click `Next`. @@ -22,7 +22,7 @@ In the next step, you will be asked to select a destination for your traces. Scroll down to the self hosted destinations and click on **Jaeger**. - Select Jaeger + Select Jaeger #### Connection details @@ -34,14 +34,14 @@ You will now be asked to provide the connection details for your Jaeger instance Enter any name you want for the destination, and enter `jaeger.tracing:4317` as the host. - Jaeger connection details + Jaeger connection details -**That's it!** You can now click `Next` and finish the wizard. +**That's it!** You can now click `Done` and finish the wizard. Odigos will now instrument your selected applications and deploy the nessesary OpenTelemetry collectors. The following page will show an overview of your observability pipeline: - Observability pipeline + Observability pipeline \ No newline at end of file diff --git a/frontend/graph/conversions.go b/frontend/graph/conversions.go index 71b7df4fa1..a4cb29a9cc 100644 --- a/frontend/graph/conversions.go +++ b/frontend/graph/conversions.go @@ -96,9 +96,16 @@ func instrumentedApplicationToActualSource(instrumentedApp v1alpha1.Instrumented // Map the container runtime details var containers []*gqlmodel.SourceContainerRuntimeDetails for _, container := range instrumentedApp.Spec.RuntimeDetails { + var otherAgentName *string + if container.OtherAgent != nil { + otherAgentName = &container.OtherAgent.Name + } + containers = append(containers, &gqlmodel.SourceContainerRuntimeDetails{ - ContainerName: container.ContainerName, - Language: string(container.Language), + ContainerName: container.ContainerName, + Language: string(container.Language), + RuntimeVersion: container.RuntimeVersion, + OtherAgent: otherAgentName, }) } diff --git a/frontend/graph/generated.go b/frontend/graph/generated.go index 84cbb2e834..12a4888743 100644 --- a/frontend/graph/generated.go +++ b/frontend/graph/generated.go @@ -339,8 +339,10 @@ type ComplexityRoot struct { } SourceContainerRuntimeDetails struct { - ContainerName func(childComplexity int) int - Language func(childComplexity int) int + ContainerName func(childComplexity int) int + Language func(childComplexity int) int + OtherAgent func(childComplexity int) int + RuntimeVersion func(childComplexity int) int } SupportedSignals struct { @@ -1714,6 +1716,20 @@ func (e *executableSchema) Complexity(typeName, field string, childComplexity in return e.complexity.SourceContainerRuntimeDetails.Language(childComplexity), true + case "SourceContainerRuntimeDetails.otherAgent": + if e.complexity.SourceContainerRuntimeDetails.OtherAgent == nil { + break + } + + return e.complexity.SourceContainerRuntimeDetails.OtherAgent(childComplexity), true + + case "SourceContainerRuntimeDetails.runtimeVersion": + if e.complexity.SourceContainerRuntimeDetails.RuntimeVersion == nil { + break + } + + return e.complexity.SourceContainerRuntimeDetails.RuntimeVersion(childComplexity), true + case "SupportedSignals.logs": if e.complexity.SupportedSignals.Logs == nil { break @@ -6391,6 +6407,10 @@ func (ec *executionContext) fieldContext_InstrumentedApplicationDetails_containe return ec.fieldContext_SourceContainerRuntimeDetails_containerName(ctx, field) case "language": return ec.fieldContext_SourceContainerRuntimeDetails_language(ctx, field) + case "runtimeVersion": + return ec.fieldContext_SourceContainerRuntimeDetails_runtimeVersion(ctx, field) + case "otherAgent": + return ec.fieldContext_SourceContainerRuntimeDetails_otherAgent(ctx, field) } return nil, fmt.Errorf("no field named %q was found under type SourceContainerRuntimeDetails", field.Name) }, @@ -10483,6 +10503,91 @@ func (ec *executionContext) fieldContext_SourceContainerRuntimeDetails_language( return fc, nil } +func (ec *executionContext) _SourceContainerRuntimeDetails_runtimeVersion(ctx context.Context, field graphql.CollectedField, obj *model.SourceContainerRuntimeDetails) (ret graphql.Marshaler) { + fc, err := ec.fieldContext_SourceContainerRuntimeDetails_runtimeVersion(ctx, field) + if err != nil { + return graphql.Null + } + ctx = graphql.WithFieldContext(ctx, fc) + defer func() { + if r := recover(); r != nil { + ec.Error(ctx, ec.Recover(ctx, r)) + ret = graphql.Null + } + }() + resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (interface{}, error) { + ctx = rctx // use context from middleware stack in children + return obj.RuntimeVersion, nil + }) + if err != nil { + ec.Error(ctx, err) + return graphql.Null + } + if resTmp == nil { + if !graphql.HasFieldError(ctx, fc) { + ec.Errorf(ctx, "must not be null") + } + return graphql.Null + } + res := resTmp.(string) + fc.Result = res + return ec.marshalNString2string(ctx, field.Selections, res) +} + +func (ec *executionContext) fieldContext_SourceContainerRuntimeDetails_runtimeVersion(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "SourceContainerRuntimeDetails", + Field: field, + IsMethod: false, + IsResolver: false, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return nil, errors.New("field of type String does not have child fields") + }, + } + return fc, nil +} + +func (ec *executionContext) _SourceContainerRuntimeDetails_otherAgent(ctx context.Context, field graphql.CollectedField, obj *model.SourceContainerRuntimeDetails) (ret graphql.Marshaler) { + fc, err := ec.fieldContext_SourceContainerRuntimeDetails_otherAgent(ctx, field) + if err != nil { + return graphql.Null + } + ctx = graphql.WithFieldContext(ctx, fc) + defer func() { + if r := recover(); r != nil { + ec.Error(ctx, ec.Recover(ctx, r)) + ret = graphql.Null + } + }() + resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (interface{}, error) { + ctx = rctx // use context from middleware stack in children + return obj.OtherAgent, nil + }) + if err != nil { + ec.Error(ctx, err) + return graphql.Null + } + if resTmp == nil { + return graphql.Null + } + res := resTmp.(*string) + fc.Result = res + return ec.marshalOString2αš–string(ctx, field.Selections, res) +} + +func (ec *executionContext) fieldContext_SourceContainerRuntimeDetails_otherAgent(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "SourceContainerRuntimeDetails", + Field: field, + IsMethod: false, + IsResolver: false, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return nil, errors.New("field of type String does not have child fields") + }, + } + return fc, nil +} + func (ec *executionContext) _SupportedSignals_traces(ctx context.Context, field graphql.CollectedField, obj *model.SupportedSignals) (ret graphql.Marshaler) { fc, err := ec.fieldContext_SupportedSignals_traces(ctx, field) if err != nil { @@ -15893,6 +15998,13 @@ func (ec *executionContext) _SourceContainerRuntimeDetails(ctx context.Context, if out.Values[i] == graphql.Null { out.Invalids++ } + case "runtimeVersion": + out.Values[i] = ec._SourceContainerRuntimeDetails_runtimeVersion(ctx, field, obj) + if out.Values[i] == graphql.Null { + out.Invalids++ + } + case "otherAgent": + out.Values[i] = ec._SourceContainerRuntimeDetails_otherAgent(ctx, field, obj) default: panic("unknown field " + strconv.Quote(field.Name)) } diff --git a/frontend/graph/model/models_gen.go b/frontend/graph/model/models_gen.go index 24f26f103e..417e1f88f4 100644 --- a/frontend/graph/model/models_gen.go +++ b/frontend/graph/model/models_gen.go @@ -475,8 +475,10 @@ type SingleSourceMetricsResponse struct { } type SourceContainerRuntimeDetails struct { - ContainerName string `json:"containerName"` - Language string `json:"language"` + ContainerName string `json:"containerName"` + Language string `json:"language"` + RuntimeVersion string `json:"runtimeVersion"` + OtherAgent *string `json:"otherAgent,omitempty"` } type TestConnectionResponse struct { diff --git a/frontend/graph/schema.graphqls b/frontend/graph/schema.graphqls index 8d3d231ae2..37a4beaa61 100644 --- a/frontend/graph/schema.graphqls +++ b/frontend/graph/schema.graphqls @@ -47,6 +47,8 @@ enum InstallationStatus { type SourceContainerRuntimeDetails { containerName: String! language: String! + runtimeVersion: String! + otherAgent: String } type InstrumentationOption { @@ -203,11 +205,7 @@ type ComputePlatform { computePlatformType: ComputePlatformType! k8sActualNamespace(name: String!): K8sActualNamespace k8sActualNamespaces: [K8sActualNamespace]! - k8sActualSource( - name: String - namespace: String - kind: String - ): K8sActualSource + k8sActualSource(name: String, namespace: String, kind: String): K8sActualSource k8sActualSources: [K8sActualSource]! destinations: [Destination!]! actions: [IcaInstanceResponse!]! @@ -456,29 +454,16 @@ type Query { type Mutation { createNewDestination(destination: DestinationInput!): Destination! persistK8sNamespace(namespace: PersistNamespaceItemInput!): Boolean! - persistK8sSources( - namespace: String! - sources: [PersistNamespaceSourceInput!]! - ): Boolean! - testConnectionForDestination( - destination: DestinationInput! - ): TestConnectionResponse! - updateK8sActualSource( - sourceId: K8sSourceId! - patchSourceRequest: PatchSourceRequestInput! - ): Boolean! + persistK8sSources(namespace: String!, sources: [PersistNamespaceSourceInput!]!): Boolean! + testConnectionForDestination(destination: DestinationInput!): TestConnectionResponse! + updateK8sActualSource(sourceId: K8sSourceId!, patchSourceRequest: PatchSourceRequestInput!): Boolean! updateDestination(id: ID!, destination: DestinationInput!): Destination! deleteDestination(id: ID!): Boolean! createAction(action: ActionInput!): Action! updateAction(id: ID!, action: ActionInput!): Action! deleteAction(id: ID!, actionType: String!): Boolean! - createInstrumentationRule( - instrumentationRule: InstrumentationRuleInput! - ): InstrumentationRule! - updateInstrumentationRule( - ruleId: ID! - instrumentationRule: InstrumentationRuleInput! - ): InstrumentationRule! + createInstrumentationRule(instrumentationRule: InstrumentationRuleInput!): InstrumentationRule! + updateInstrumentationRule(ruleId: ID!, instrumentationRule: InstrumentationRuleInput!): InstrumentationRule! deleteInstrumentationRule(ruleId: ID!): Boolean! } diff --git a/frontend/graph/schema.resolvers.go b/frontend/graph/schema.resolvers.go index edcf25b4d1..b3c4b18a96 100644 --- a/frontend/graph/schema.resolvers.go +++ b/frontend/graph/schema.resolvers.go @@ -280,7 +280,6 @@ func (r *destinationResolver) Type(ctx context.Context, obj *model.Destination) // Conditions is the resolver for the conditions field. func (r *destinationResolver) Conditions(ctx context.Context, obj *model.Destination) ([]*model.Condition, error) { - conditions := make([]*model.Condition, 0, len(obj.Conditions)) for _, c := range obj.Conditions { // Convert LastTransitionTime to a string pointer if it's not nil diff --git a/frontend/webapp/components/common/card-details/index.tsx b/frontend/webapp/components/common/card-details/index.tsx deleted file mode 100644 index 752e3bca08..0000000000 --- a/frontend/webapp/components/common/card-details/index.tsx +++ /dev/null @@ -1,38 +0,0 @@ -import React from 'react'; -import styled from 'styled-components'; -import { Text } from '@/reuseable-components'; -import { ConfiguredFields } from '@/components'; - -interface Props { - title?: string; - data: { - title: string; - tooltip?: string; - value: string; - }[]; -} - -const Container = styled.div` - display: flex; - flex-direction: column; - padding: 16px 24px 24px 24px; - flex-direction: column; - align-items: flex-start; - gap: 16px; - align-self: stretch; - border-radius: 24px; - border: 1px solid ${({ theme }) => theme.colors.border}; -`; - -const TitleWrapper = styled.div``; - -export const CardDetails: React.FC = ({ title = 'Details', data }) => { - return ( - - - {title} - - - - ); -}; diff --git a/frontend/webapp/components/common/configured-fields/index.tsx b/frontend/webapp/components/common/configured-fields/index.tsx deleted file mode 100644 index e268efc7c1..0000000000 --- a/frontend/webapp/components/common/configured-fields/index.tsx +++ /dev/null @@ -1,102 +0,0 @@ -import React, { Fragment } from 'react'; -import styled from 'styled-components'; -import { Text, Status, Tooltip } from '@/reuseable-components'; -import { MonitorsLegend } from '@/components/overview'; -import theme from '@/styles/theme'; - -interface Detail { - title: string; - tooltip?: string; - value: string; -} - -interface Props { - details: Detail[]; -} - -const ListContainer = styled.div` - display: flex; - flex-wrap: wrap; - gap: 24px 40px; -`; - -const ListItem = styled.div``; - -const ItemTitle = styled(Text)` - color: ${({ theme }) => theme.text.grey}; - font-size: 10px; - line-height: 16px; -`; - -const ItemValue = styled(Text)` - color: ${({ theme }) => theme.colors.text}; - font-size: 12px; - line-height: 18px; -`; - -export const ConfiguredFields: React.FC = ({ details }) => { - const parseValue = (value: string) => { - let str = ''; - - try { - const parsed = JSON.parse(value); - - // Handle arrays - if (Array.isArray(parsed)) { - str = parsed - .map((item) => { - if (typeof item === 'object' && item !== null) return `${item.key}: ${item.value}`; - else return item; - }) - .join(', '); - } - - // Handle objects (non-array JSON objects) - else if (typeof parsed === 'object' && parsed !== null) { - str = Object.entries(parsed) - .map(([key, val]) => `${key}: ${val}`) - .join(', '); - } - - // Should never reach this if it's a string (it will throw) - else { - str = value; - } - } catch (error) { - str = value; - } - - const strSplitted = str.split('\n'); - - return strSplitted.map((str, idx) => ( - - {str} - {idx < strSplitted.length - 1 ?
: null} -
- )); - }; - - const renderValue = (title: string, value: string) => { - switch (title) { - case 'Status': - return ; - case 'Monitors': - return ; - default: - return {parseValue(value)}; - } - }; - - return ( - - {details.map((detail, index) => ( - - - {detail.title} - - {renderValue(detail.title, detail.value)} - - ))} - - ); -}; diff --git a/frontend/webapp/components/common/index.ts b/frontend/webapp/components/common/index.ts index a9076a4d90..00da52a3ea 100644 --- a/frontend/webapp/components/common/index.ts +++ b/frontend/webapp/components/common/index.ts @@ -1,3 +1 @@ -export * from './card-details'; -export * from './configured-fields'; export * from './dropdowns'; diff --git a/frontend/webapp/components/main/header/cp-title/index.tsx b/frontend/webapp/components/main/header/cp-title/index.tsx index c7bf856d20..4ea8902b11 100644 --- a/frontend/webapp/components/main/header/cp-title/index.tsx +++ b/frontend/webapp/components/main/header/cp-title/index.tsx @@ -1,10 +1,11 @@ import React from 'react'; import Image from 'next/image'; import styled from 'styled-components'; +import { PlatformTypes } from '@/types'; import { Text } from '@/reuseable-components'; -interface PlatformProps { - type: 'k8s' | 'vm'; +interface Props { + type: PlatformTypes; } const PlatformWrapper = styled.div` @@ -28,21 +29,14 @@ const Title = styled(Text)` color: ${({ theme }) => theme.colors.white}; `; -const PlatformTitle: React.FC = ({ type }) => { +const PlatformTitle: React.FC = ({ type }) => { return ( - {type} + {type} - - {type === 'k8s' ? 'Kubernetes Cluster' : 'Virtual Machine'} - + {type === PlatformTypes.K8S ? 'Kubernetes Cluster' : 'Virtual Machine'} ); diff --git a/frontend/webapp/components/main/header/index.tsx b/frontend/webapp/components/main/header/index.tsx index f74f9aeb44..fbfb1be26b 100644 --- a/frontend/webapp/components/main/header/index.tsx +++ b/frontend/webapp/components/main/header/index.tsx @@ -1,32 +1,29 @@ import React from 'react'; import Image from 'next/image'; +import { FlexRow } from '@/styles'; import styled from 'styled-components'; +import { PlatformTypes } from '@/types'; import { PlatformTitle } from './cp-title'; import { useConnectionStore } from '@/store'; -import { Status } from '@/reuseable-components'; +import { ConnectionStatus } from '@/reuseable-components'; import { NotificationManager } from '@/components/notification'; interface MainHeaderProps {} -const Flex = styled.div` - display: flex; - align-items: center; -`; - -const HeaderContainer = styled(Flex)` +const HeaderContainer = styled(FlexRow)` width: 100%; padding: 12px 0; background-color: ${({ theme }) => theme.colors.darker_grey}; border-bottom: 1px solid rgba(249, 249, 249, 0.16); `; -const AlignLeft = styled(Flex)` +const AlignLeft = styled(FlexRow)` margin-right: auto; margin-left: 32px; gap: 16px; `; -const AlignRight = styled(Flex)` +const AlignRight = styled(FlexRow)` margin-left: auto; margin-right: 32px; gap: 16px; @@ -39,8 +36,8 @@ export const MainHeader: React.FC = () => { logo - - {!connecting && } + + {!connecting && } diff --git a/frontend/webapp/containers/main/actions/action-drawer/build-card.ts b/frontend/webapp/containers/main/actions/action-drawer/build-card.ts index 50953951c9..b53676ed25 100644 --- a/frontend/webapp/containers/main/actions/action-drawer/build-card.ts +++ b/frontend/webapp/containers/main/actions/action-drawer/build-card.ts @@ -1,4 +1,6 @@ +import { DISPLAY_TITLES } from '@/utils'; import type { ActionDataParsed } from '@/types'; +import { DataCardFieldTypes, type DataCardRow } from '@/reuseable-components'; const buildCard = (action: ActionDataParsed) => { const { @@ -19,19 +21,20 @@ const buildCard = (action: ActionDataParsed) => { }, } = action; - const arr = [ - { title: 'Type', value: type }, - { title: 'Status', value: String(!disabled) }, - { title: 'Monitors', value: signals.map((str) => str.toLowerCase()).join(', ') }, - { title: 'Name', value: actionName || 'N/A' }, - { title: 'Notes', value: notes || 'N/A' }, + const arr: DataCardRow[] = [ + { title: DISPLAY_TITLES.TYPE, value: type }, + { type: DataCardFieldTypes.ACTIVE_STATUS, title: DISPLAY_TITLES.STATUS, value: String(!disabled) }, + { title: DISPLAY_TITLES.NAME, value: actionName }, + { title: DISPLAY_TITLES.NOTES, value: notes }, + { type: DataCardFieldTypes.DIVIDER, width: '100%' }, + { type: DataCardFieldTypes.MONITORS, title: DISPLAY_TITLES.SIGNALS_FOR_PROCESSING, value: signals.map((str) => str.toLowerCase()).join(', ') }, ]; if (clusterAttributes) { let str = ''; clusterAttributes.forEach(({ attributeName, attributeStringValue }, idx) => { str += `${attributeName}: ${attributeStringValue}`; - if (idx < clusterAttributes.length - 1) str += '\n'; + if (idx < clusterAttributes.length - 1) str += ', '; }); arr.push({ title: 'Attributes', value: str }); diff --git a/frontend/webapp/containers/main/actions/action-drawer/index.tsx b/frontend/webapp/containers/main/actions/action-drawer/index.tsx index 9204193a28..d0b0936e7f 100644 --- a/frontend/webapp/containers/main/actions/action-drawer/index.tsx +++ b/frontend/webapp/containers/main/actions/action-drawer/index.tsx @@ -3,9 +3,9 @@ import buildCard from './build-card'; import { ActionFormBody } from '../'; import styled from 'styled-components'; import { useDrawerStore } from '@/store'; -import { CardDetails } from '@/components'; -import { ACTION, getActionIcon } from '@/utils'; +import { ACTION, DATA_CARDS, getActionIcon } from '@/utils'; import buildDrawerItem from './build-drawer-item'; +import { DataCard } from '@/reuseable-components'; import { useActionCRUD, useActionFormData } from '@/hooks'; import OverviewDrawer from '../../overview/overview-drawer'; import { ACTION_OPTIONS } from '../action-modal/action-options'; @@ -120,7 +120,7 @@ export const ActionDrawer: React.FC = () => { /> ) : ( - + )} ); diff --git a/frontend/webapp/containers/main/destinations/add-destination/configured-destinations-list/index.tsx b/frontend/webapp/containers/main/destinations/add-destination/configured-destinations-list/index.tsx index 517fe575f6..89360955a4 100644 --- a/frontend/webapp/containers/main/destinations/add-destination/configured-destinations-list/index.tsx +++ b/frontend/webapp/containers/main/destinations/add-destination/configured-destinations-list/index.tsx @@ -1,10 +1,10 @@ import React, { useState } from 'react'; import Image from 'next/image'; import styled from 'styled-components'; +import { DeleteWarning } from '@/components'; import { IAppState, useAppStore } from '@/store'; -import { ConfiguredFields, DeleteWarning } from '@/components'; -import { Button, Divider, ExtendIcon, Text } from '@/reuseable-components'; import { OVERVIEW_ENTITY_TYPES, type ConfiguredDestination } from '@/types'; +import { Button, DataCardFields, Divider, ExtendIcon, Text } from '@/reuseable-components'; const Container = styled.div` display: flex; @@ -12,10 +12,10 @@ const Container = styled.div` align-items: flex-start; gap: 12px; margin-top: 24px; - align-self: stretch; + max-height: calc(100vh - 400px); height: 100%; - max-height: 548px; - overflow-y: auto; + overflow-x: hidden; + overflow-y: scroll; `; const ListItem = styled.div` @@ -38,9 +38,9 @@ const ListItemHeader = styled.div` `; const ListItemContent = styled.div` - margin-left: 16px; display: flex; gap: 12px; + margin-left: 16px; `; const DestinationIconWrapper = styled.div` @@ -135,8 +135,8 @@ const ConfiguredDestinationsListItem: React.FC<{ item: ConfiguredDestination; is {expand && ( - - + + )} diff --git a/frontend/webapp/containers/main/destinations/destination-drawer/build-card.ts b/frontend/webapp/containers/main/destinations/destination-drawer/build-card.ts index c9868e1ea1..0cf3a2cd09 100644 --- a/frontend/webapp/containers/main/destinations/destination-drawer/build-card.ts +++ b/frontend/webapp/containers/main/destinations/destination-drawer/build-card.ts @@ -1,17 +1,19 @@ -import { ActualDestination, DestinationDetailsResponse, ExportedSignals } from '@/types'; -import { safeJsonParse } from '@/utils'; +import { DISPLAY_TITLES, safeJsonParse } from '@/utils'; +import { DataCardRow, DataCardFieldTypes } from '@/reuseable-components'; +import type { ActualDestination, DestinationDetailsResponse, ExportedSignals } from '@/types'; const buildMonitorsList = (exportedSignals: ExportedSignals): string => Object.keys(exportedSignals) .filter((key) => exportedSignals[key]) - .join(', ') || 'N/A'; + .join(', '); const buildCard = (destination: ActualDestination, destinationTypeDetails: DestinationDetailsResponse['destinationTypeDetails']) => { const { exportedSignals, destinationType, fields } = destination; - const arr = [ - { title: 'Destination', value: destinationType.displayName }, - { title: 'Monitors', value: buildMonitorsList(exportedSignals) }, + const arr: DataCardRow[] = [ + { title: DISPLAY_TITLES.DESTINATION, value: destinationType.displayName }, + { type: DataCardFieldTypes.MONITORS, title: DISPLAY_TITLES.MONITORS, value: buildMonitorsList(exportedSignals) }, + { type: DataCardFieldTypes.DIVIDER, width: '100%' }, ]; Object.entries(safeJsonParse>(fields, {})).map(([key, value]) => { @@ -22,7 +24,7 @@ const buildCard = (destination: ActualDestination, destinationTypeDetails: Desti arr.push({ title: found?.displayName || key, - value: secret || value || 'N/A', + value: secret || value, }); }); diff --git a/frontend/webapp/containers/main/destinations/destination-drawer/index.tsx b/frontend/webapp/containers/main/destinations/destination-drawer/index.tsx index fba80220de..77884b9cc1 100644 --- a/frontend/webapp/containers/main/destinations/destination-drawer/index.tsx +++ b/frontend/webapp/containers/main/destinations/destination-drawer/index.tsx @@ -1,13 +1,12 @@ import React, { useMemo, useState } from 'react'; -import { ACTION } from '@/utils'; +import { ACTION, DATA_CARDS } from '@/utils'; import buildCard from './build-card'; import styled from 'styled-components'; import { useDrawerStore } from '@/store'; -import { CardDetails } from '@/components'; import buildDrawerItem from './build-drawer-item'; -import { ConditionDetails } from '@/reuseable-components'; import OverviewDrawer from '../../overview/overview-drawer'; import { DestinationFormBody } from '../destination-form-body'; +import { ConditionDetails, DataCard } from '@/reuseable-components'; import { OVERVIEW_ENTITY_TYPES, type ActualDestination } from '@/types'; import { useDestinationCRUD, useDestinationFormData, useDestinationTypes } from '@/hooks'; @@ -138,7 +137,7 @@ export const DestinationDrawer: React.FC = () => { ) : ( - + )} diff --git a/frontend/webapp/containers/main/destinations/destination-form-body/index.tsx b/frontend/webapp/containers/main/destinations/destination-form-body/index.tsx index ab36f0ebb3..df8dbfbd71 100644 --- a/frontend/webapp/containers/main/destinations/destination-form-body/index.tsx +++ b/frontend/webapp/containers/main/destinations/destination-form-body/index.tsx @@ -12,7 +12,7 @@ interface Props { formData: DestinationInput; formErrors: Record; validateForm: () => boolean; - handleFormChange: (key: keyof DestinationInput | string, val: any) => void; + handleFormChange: (key: keyof DestinationInput, val: any) => void; dynamicFields: DynamicField[]; setDynamicFields: Dispatch>; } diff --git a/frontend/webapp/containers/main/instrumentation-rules/rule-drawer/build-card.ts b/frontend/webapp/containers/main/instrumentation-rules/rule-drawer/build-card.ts index b1f617598e..260a4ff6b3 100644 --- a/frontend/webapp/containers/main/instrumentation-rules/rule-drawer/build-card.ts +++ b/frontend/webapp/containers/main/instrumentation-rules/rule-drawer/build-card.ts @@ -1,13 +1,16 @@ +import { DISPLAY_TITLES } from '@/utils'; import type { InstrumentationRuleSpec } from '@/types'; +import { DataCardRow, DataCardFieldTypes } from '@/reuseable-components'; const buildCard = (rule: InstrumentationRuleSpec) => { const { type, ruleName, notes, disabled, payloadCollection } = rule; - const arr = [ - { title: 'Type', value: type || 'N/A' }, - { title: 'Status', value: String(!disabled) }, - { title: 'Name', value: ruleName || 'N/A' }, - { title: 'Notes', value: notes || 'N/A' }, + const arr: DataCardRow[] = [ + { title: DISPLAY_TITLES.TYPE, value: type }, + { type: DataCardFieldTypes.ACTIVE_STATUS, title: DISPLAY_TITLES.STATUS, value: String(!disabled) }, + { title: DISPLAY_TITLES.NAME, value: ruleName }, + { title: DISPLAY_TITLES.NOTES, value: notes }, + { type: DataCardFieldTypes.DIVIDER, width: '100%' }, ]; if (payloadCollection) { diff --git a/frontend/webapp/containers/main/instrumentation-rules/rule-drawer/index.tsx b/frontend/webapp/containers/main/instrumentation-rules/rule-drawer/index.tsx index af379dfceb..7a5921f9da 100644 --- a/frontend/webapp/containers/main/instrumentation-rules/rule-drawer/index.tsx +++ b/frontend/webapp/containers/main/instrumentation-rules/rule-drawer/index.tsx @@ -3,8 +3,8 @@ import buildCard from './build-card'; import { RuleFormBody } from '../'; import styled from 'styled-components'; import { useDrawerStore } from '@/store'; -import { CardDetails } from '@/components'; -import { ACTION, getRuleIcon } from '@/utils'; +import { ACTION, DATA_CARDS, getRuleIcon } from '@/utils'; +import { DataCard } from '@/reuseable-components'; import buildDrawerItem from './build-drawer-item'; import { RULE_OPTIONS } from '../rule-modal/rule-options'; import OverviewDrawer from '../../overview/overview-drawer'; @@ -117,7 +117,7 @@ export const RuleDrawer: React.FC = () => { /> ) : ( - + )} ); diff --git a/frontend/webapp/containers/main/overview/overview-drawer/drawer-header/index.tsx b/frontend/webapp/containers/main/overview/overview-drawer/drawer-header/index.tsx index d1a4cc9fe5..427002838e 100644 --- a/frontend/webapp/containers/main/overview/overview-drawer/drawer-header/index.tsx +++ b/frontend/webapp/containers/main/overview/overview-drawer/drawer-header/index.tsx @@ -26,6 +26,10 @@ const InputWrapper = styled(SectionItemsWrapper)` const Title = styled(Text)` font-size: 18px; line-height: 26px; + max-width: 400px; + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; `; const DrawerItemImageWrapper = styled.div` diff --git a/frontend/webapp/containers/main/sources/source-drawer-container/build-card.ts b/frontend/webapp/containers/main/sources/source-drawer-container/build-card.ts index 151c7c6155..0f27272eff 100644 --- a/frontend/webapp/containers/main/sources/source-drawer-container/build-card.ts +++ b/frontend/webapp/containers/main/sources/source-drawer-container/build-card.ts @@ -1,15 +1,17 @@ +import { DISPLAY_TITLES } from '@/utils'; import type { K8sActualSource } from '@/types'; +import { DataCardRow } from '@/reuseable-components'; const buildCard = (source: K8sActualSource) => { const { name, kind, namespace, instrumentedApplicationDetails } = source; const { containerName, language } = instrumentedApplicationDetails?.containers?.[0] || {}; - const arr = [ - { title: 'Namespace', value: namespace }, - { title: 'Kind', value: kind }, - { title: 'Name', value: name }, - { title: 'Container Name', value: containerName || 'N/A' }, - { title: 'Language', value: language || 'N/A' }, + const arr: DataCardRow[] = [ + { title: DISPLAY_TITLES.NAMESPACE, value: namespace }, + { title: DISPLAY_TITLES.KIND, value: kind }, + { title: DISPLAY_TITLES.CONTAINER_NAME, value: containerName }, + { title: DISPLAY_TITLES.NAME, value: name }, + { title: DISPLAY_TITLES.LANGUAGE, value: language }, ]; return arr; diff --git a/frontend/webapp/containers/main/sources/source-drawer-container/index.tsx b/frontend/webapp/containers/main/sources/source-drawer-container/index.tsx index 8a67b9dfb3..6f9381e936 100644 --- a/frontend/webapp/containers/main/sources/source-drawer-container/index.tsx +++ b/frontend/webapp/containers/main/sources/source-drawer-container/index.tsx @@ -3,13 +3,12 @@ import buildCard from './build-card'; import styled from 'styled-components'; import { useSourceCRUD } from '@/hooks'; import { useDrawerStore } from '@/store'; -import { CardDetails } from '@/components'; import buildDrawerItem from './build-drawer-item'; import { UpdateSourceBody } from '../update-source-body'; -import { ConditionDetails } from '@/reuseable-components'; import OverviewDrawer from '../../overview/overview-drawer'; -import { ACTION, getMainContainerLanguageLogo } from '@/utils'; -import { OVERVIEW_ENTITY_TYPES, WorkloadId, type K8sActualSource } from '@/types'; +import { OVERVIEW_ENTITY_TYPES, type WorkloadId, type K8sActualSource } from '@/types'; +import { ACTION, DATA_CARDS, getMainContainerLanguage, getProgrammingLanguageIcon } from '@/utils'; +import { ConditionDetails, DataCard, DataCardRow, DataCardFieldTypes } from '@/reuseable-components'; interface Props {} @@ -75,6 +74,23 @@ export const SourceDrawer: React.FC = () => { return arr; }, [selectedItem]); + const containersData = useMemo(() => { + if (!selectedItem) return []; + + const { item } = selectedItem as { item: K8sActualSource }; + + return ( + item.instrumentedApplicationDetails.containers.map( + (container) => + ({ + type: DataCardFieldTypes.SOURCE_CONTAINER, + width: '100%', + value: JSON.stringify(container), + } as DataCardRow), + ) || [] + ); + }, [selectedItem]); + if (!selectedItem?.item) return null; const { id, item } = selectedItem as { id: WorkloadId; item: K8sActualSource }; @@ -102,7 +118,7 @@ export const SourceDrawer: React.FC = () => { = () => { ) : ( - + + )} diff --git a/frontend/webapp/graphql/queries/compute-platform.ts b/frontend/webapp/graphql/queries/compute-platform.ts index 2e61d9a727..a62a0629e3 100644 --- a/frontend/webapp/graphql/queries/compute-platform.ts +++ b/frontend/webapp/graphql/queries/compute-platform.ts @@ -13,6 +13,8 @@ export const GET_COMPUTE_PLATFORM = gql` containers { containerName language + runtimeVersion + otherAgent } conditions { type diff --git a/frontend/webapp/hooks/actions/useActionFormData.ts b/frontend/webapp/hooks/actions/useActionFormData.ts index fef65a4fc4..e6f7214627 100644 --- a/frontend/webapp/hooks/actions/useActionFormData.ts +++ b/frontend/webapp/hooks/actions/useActionFormData.ts @@ -1,7 +1,6 @@ -import { useState } from 'react'; -import { useNotify } from '../notification/useNotify'; import { DrawerBaseItem } from '@/store'; -import { ACTION, FORM_ALERTS, NOTIFICATION } from '@/utils'; +import { useGenericForm, useNotify } from '@/hooks'; +import { FORM_ALERTS, NOTIFICATION } from '@/utils'; import type { ActionDataParsed, ActionInput } from '@/types'; const INITIAL: ActionInput = { @@ -16,21 +15,7 @@ const INITIAL: ActionInput = { export function useActionFormData() { const notify = useNotify(); - - const [formData, setFormData] = useState({ ...INITIAL }); - const [formErrors, setFormErrors] = useState>({}); - - const handleFormChange = (key: keyof typeof INITIAL, val: any) => { - setFormData((prev) => ({ - ...prev, - [key]: val, - })); - }; - - const resetFormData = () => { - setFormData({ ...INITIAL }); - setFormErrors({}); - }; + const { formData, formErrors, handleFormChange, handleErrorChange, resetFormData } = useGenericForm(INITIAL); const validateForm = (params?: { withAlert?: boolean; alertTitle?: string }) => { const errors = {}; @@ -60,7 +45,7 @@ export function useActionFormData() { }); } - setFormErrors(errors); + handleErrorChange(undefined, undefined, errors); return ok; }; @@ -98,7 +83,7 @@ export function useActionFormData() { } }); - setFormData(updatedData); + handleFormChange(undefined, undefined, updatedData); }; return { diff --git a/frontend/webapp/hooks/common/index.ts b/frontend/webapp/hooks/common/index.ts index 71b90efaa6..3707609309 100644 --- a/frontend/webapp/hooks/common/index.ts +++ b/frontend/webapp/hooks/common/index.ts @@ -1,4 +1,5 @@ export * from './useContainerSize'; +export * from './useGenericForm'; export * from './useOnClickOutside'; export * from './useKeyDown'; export * from './useTimeAgo'; diff --git a/frontend/webapp/hooks/common/useGenericForm.ts b/frontend/webapp/hooks/common/useGenericForm.ts new file mode 100644 index 0000000000..4b60a5e6a7 --- /dev/null +++ b/frontend/webapp/hooks/common/useGenericForm.ts @@ -0,0 +1,51 @@ +import { useState } from 'react'; + +export const useGenericForm =
>(initialFormData: Form) => { + function copyInitial() { + // this is to avoid reference issues with the initial form data, + // when an object has arrays or objects as part of it's values, a simple spread operator won't work, the children would act as references, + // so we use JSON.parse(JSON.stringify()) to create a deep copy of the object without affecting the original + return JSON.parse(JSON.stringify(initialFormData)); + } + + const [formData, setFormData] = useState(copyInitial()); + const [formErrors, setFormErrors] = useState>>({}); + + const handleFormChange = (key?: keyof typeof formData, val?: any, obj?: typeof formData) => { + if (!!key) { + // this is for cases where the form contains objects such as "exportedSignals", + // the object's child is targeted with a ".dot" for example: "exportedSignals.logs" + + const [parentKey, childKey] = (key as string).split('.'); + + if (!!childKey) { + setFormData((prev) => ({ ...prev, [parentKey]: { ...prev[parentKey], [childKey]: val } })); + } else { + setFormData((prev) => ({ ...prev, [parentKey]: val })); + } + } else if (!!obj) { + setFormData({ ...obj }); + } + }; + + const handleErrorChange = (key?: keyof typeof formErrors, val?: string, obj?: typeof formErrors) => { + if (!!key) { + setFormErrors((prev) => ({ ...prev, [key]: val })); + } else if (!!obj) { + setFormErrors({ ...obj }); + } + }; + + const resetFormData = () => { + setFormData(copyInitial()); + setFormErrors({}); + }; + + return { + formData, + formErrors, + handleFormChange, + handleErrorChange, + resetFormData, + }; +}; diff --git a/frontend/webapp/hooks/destinations/useDestinationFormData.ts b/frontend/webapp/hooks/destinations/useDestinationFormData.ts index 28ca622e88..3f4edefd34 100644 --- a/frontend/webapp/hooks/destinations/useDestinationFormData.ts +++ b/frontend/webapp/hooks/destinations/useDestinationFormData.ts @@ -2,7 +2,7 @@ import { useState, useEffect } from 'react'; import { DrawerBaseItem } from '@/store'; import { useQuery } from '@apollo/client'; import { GET_DESTINATION_TYPE_DETAILS } from '@/graphql'; -import { useConnectDestinationForm, useNotify } from '@/hooks'; +import { useConnectDestinationForm, useGenericForm, useNotify } from '@/hooks'; import { ACTION, FORM_ALERTS, NOTIFICATION, safeJsonParse } from '@/utils'; import { type DynamicField, @@ -29,10 +29,9 @@ export function useDestinationFormData(params?: { destinationType?: string; supp const { destinationType, supportedSignals, preLoadedFields } = params || {}; const notify = useNotify(); - const { buildFormDynamicFields } = useConnectDestinationForm(); + const { formData, formErrors, handleFormChange, handleErrorChange, resetFormData } = useGenericForm(INITIAL); - const [formData, setFormData] = useState({ ...INITIAL }); - const [formErrors, setFormErrors] = useState>({}); + const { buildFormDynamicFields } = useConnectDestinationForm(); const [dynamicFields, setDynamicFields] = useState([]); const t = destinationType || formData.type; @@ -87,31 +86,6 @@ export function useDestinationFormData(params?: { destinationType?: string; supp }); }, [supportedSignals]); - function handleFormChange(key: keyof typeof INITIAL | string, val: any) { - // this is for a case where "exportedSignals" have been changed, it's an object so they children are targeted as: "exportedSignals.logs" - const [parentKey, childKey] = key.split('.'); - - if (!!childKey) { - setFormData((prev) => ({ - ...prev, - [parentKey]: { - ...prev[parentKey], - [childKey]: val, - }, - })); - } else { - setFormData((prev) => ({ - ...prev, - [parentKey]: val, - })); - } - } - - const resetFormData = () => { - setFormData({ ...INITIAL }); - setFormErrors({}); - }; - const validateForm = (params?: { withAlert?: boolean; alertTitle?: string }) => { const errors = {}; let ok = true; @@ -131,7 +105,7 @@ export function useDestinationFormData(params?: { destinationType?: string; supp }); } - setFormErrors(errors); + handleErrorChange(undefined, undefined, errors); return ok; }; @@ -152,7 +126,7 @@ export function useDestinationFormData(params?: { destinationType?: string; supp fields: Object.entries(safeJsonParse(fields, {})).map(([key, value]: [string, string]) => ({ key, value })), }; - setFormData(updatedData); + handleFormChange(undefined, undefined, updatedData); }; return { diff --git a/frontend/webapp/hooks/instrumentation-rules/useInstrumentationRuleFormData.ts b/frontend/webapp/hooks/instrumentation-rules/useInstrumentationRuleFormData.ts index 0c39e9c66c..c1718e65fb 100644 --- a/frontend/webapp/hooks/instrumentation-rules/useInstrumentationRuleFormData.ts +++ b/frontend/webapp/hooks/instrumentation-rules/useInstrumentationRuleFormData.ts @@ -1,7 +1,6 @@ -import { useState } from 'react'; -import { useNotify } from '../notification/useNotify'; import type { DrawerBaseItem } from '@/store'; -import { ACTION, FORM_ALERTS, NOTIFICATION } from '@/utils'; +import { useGenericForm, useNotify } from '@/hooks'; +import { FORM_ALERTS, NOTIFICATION } from '@/utils'; import { PayloadCollectionType, type InstrumentationRuleInput, type InstrumentationRuleSpec } from '@/types'; const INITIAL: InstrumentationRuleInput = { @@ -20,21 +19,7 @@ const INITIAL: InstrumentationRuleInput = { export function useInstrumentationRuleFormData() { const notify = useNotify(); - - const [formData, setFormData] = useState({ ...INITIAL }); - const [formErrors, setFormErrors] = useState>({}); - - const handleFormChange = (key: keyof typeof INITIAL, val: any) => { - setFormData((prev) => ({ - ...prev, - [key]: val, - })); - }; - - const resetFormData = () => { - setFormData({ ...INITIAL }); - setFormErrors({}); - }; + const { formData, formErrors, handleFormChange, handleErrorChange, resetFormData } = useGenericForm(INITIAL); const validateForm = (params?: { withAlert?: boolean; alertTitle?: string }) => { const errors = {}; @@ -63,7 +48,7 @@ export function useInstrumentationRuleFormData() { }); } - setFormErrors(errors); + handleErrorChange(undefined, undefined, errors); return ok; }; @@ -87,7 +72,7 @@ export function useInstrumentationRuleFormData() { }; } - setFormData(updatedData); + handleFormChange(undefined, undefined, updatedData); }; return { diff --git a/frontend/webapp/reuseable-components/data-card/data-card-fields/index.tsx b/frontend/webapp/reuseable-components/data-card/data-card-fields/index.tsx new file mode 100644 index 0000000000..4a227d264c --- /dev/null +++ b/frontend/webapp/reuseable-components/data-card/data-card-fields/index.tsx @@ -0,0 +1,100 @@ +import React, { useId } from 'react'; +import styled from 'styled-components'; +import { ActiveStatus, DataTab, Divider, InstrumentStatus, MonitorsIcons, Text, Tooltip } from '@/reuseable-components'; +import { capitalizeFirstLetter, getProgrammingLanguageIcon, parseJsonStringToPrettyString, safeJsonParse, WORKLOAD_PROGRAMMING_LANGUAGES } from '@/utils'; + +export enum DataCardFieldTypes { + DIVIDER = 'divider', + MONITORS = 'monitors', + ACTIVE_STATUS = 'active-status', + SOURCE_CONTAINER = 'source-container', +} + +export interface DataCardRow { + type?: DataCardFieldTypes; + title?: string; + tooltip?: string; + value?: string; + width?: string; +} + +interface Props { + data: DataCardRow[]; +} + +const ListContainer = styled.div` + display: flex; + flex-wrap: wrap; + gap: 16px 32px; + width: 100%; +`; + +const ListItem = styled.div<{ $width: string }>` + display: flex; + flex-direction: column; + gap: 2px; + width: ${({ $width }) => $width}; +`; + +const ItemTitle = styled(Text)` + color: ${({ theme }) => theme.text.grey}; + font-size: 10px; + line-height: 16px; +`; + +export const DataCardFields: React.FC = ({ data }) => { + return ( + + {data.map(({ type, title, tooltip, value, width = 'unset' }) => { + const id = useId(); + + return ( + + + {!!title && {title}} + + {renderValue(type, value)} + + ); + })} + + ); +}; + +const PreWrap = styled(Text)` + font-size: 12px; + white-space: pre-wrap; +`; + +const renderValue = (type: DataCardRow['type'], value: DataCardRow['value']) => { + // We need to maintain this with new components every time we add a new type to "DataCardFieldTypes" + + switch (type) { + case DataCardFieldTypes.DIVIDER: + return ; + + case DataCardFieldTypes.MONITORS: + return ; + + case DataCardFieldTypes.ACTIVE_STATUS: + return ; + + case DataCardFieldTypes.SOURCE_CONTAINER: { + const { containerName, language, runtimeVersion } = safeJsonParse(value, { + containerName: '-', + language: WORKLOAD_PROGRAMMING_LANGUAGES.UNKNOWN, + runtimeVersion: '-', + }); + + return ( + + + + ); + } + + default: { + return {parseJsonStringToPrettyString(value || '-')}; + } + } +}; diff --git a/frontend/webapp/reuseable-components/data-card/index.tsx b/frontend/webapp/reuseable-components/data-card/index.tsx new file mode 100644 index 0000000000..7105834e48 --- /dev/null +++ b/frontend/webapp/reuseable-components/data-card/index.tsx @@ -0,0 +1,58 @@ +import React from 'react'; +import styled from 'styled-components'; +import { Badge, Text } from '@/reuseable-components'; +import { DataCardFields, type DataCardRow, DataCardFieldTypes } from './data-card-fields'; +export { DataCardFields, type DataCardRow, DataCardFieldTypes }; + +interface Props { + title?: string; + titleBadge?: string | number; + description?: string; + data: DataCardRow[]; +} + +const CardContainer = styled.div` + display: flex; + flex-direction: column; + align-items: flex-start; + align-self: stretch; + gap: 16px; + padding: 24px; + border-radius: 24px; + border: 1px solid ${({ theme }) => theme.colors.border}; +`; + +const Header = styled.div` + display: flex; + flex-direction: column; + gap: 4px; +`; + +const Title = styled(Text)` + display: flex; + align-items: center; + gap: 8px; + font-size: 16px; +`; + +const Description = styled(Text)` + font-size: 12px; + color: ${({ theme }) => theme.text.grey}; +`; + +export const DataCard: React.FC = ({ title = 'Details', titleBadge, description, data }) => { + return ( + +
+ + {title} + {/* NOT undefined, because we should allow zero (0) values */} + {titleBadge !== undefined && <Badge label={titleBadge} />} + + {!!description && {description}} +
+ + +
+ ); +}; diff --git a/frontend/webapp/reuseable-components/data-tab/index.tsx b/frontend/webapp/reuseable-components/data-tab/index.tsx new file mode 100644 index 0000000000..71575cc7ed --- /dev/null +++ b/frontend/webapp/reuseable-components/data-tab/index.tsx @@ -0,0 +1,115 @@ +import React, { PropsWithChildren, useCallback } from 'react'; +import Image from 'next/image'; +import { FlexColumn } from '@/styles'; +import styled, { css } from 'styled-components'; +import { ActiveStatus, MonitorsIcons, Text } from '@/reuseable-components'; + +interface Props extends PropsWithChildren { + title: string; + subTitle: string; + logo: string; + monitors?: string[]; + isActive?: boolean; + isError?: boolean; + onClick?: () => void; +} + +const Container = styled.div<{ $withClick: boolean; $isError: Props['isError'] }>` + display: flex; + align-items: center; + align-self: stretch; + gap: 8px; + padding: 16px; + width: calc(100% - 32px); + border-radius: 16px; + background-color: ${({ $isError, theme }) => ($isError ? '#281515' : theme.colors.white_opacity['004'])}; + + ${({ $withClick, $isError, theme }) => + $withClick && + css` + &:hover { + cursor: pointer; + background-color: ${$isError ? '#351515' : theme.colors.white_opacity['008']}; + } + `} +`; + +const IconWrapper = styled.div<{ $isError: Props['isError'] }>` + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + width: 36px; + height: 36px; + border-radius: 8px; + background: ${({ $isError }) => + `linear-gradient(180deg, ${$isError ? 'rgba(237, 124, 124, 0.08)' : 'rgba(249, 249, 249, 0.06)'} 0%, ${$isError ? 'rgba(237, 124, 124, 0.02)' : 'rgba(249, 249, 249, 0.02)'} 100%)`}; +`; + +const Title = styled(Text)` + max-width: 150px; + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; +`; + +const SubTitleWrapper = styled.div` + display: flex; + align-items: center; + gap: 8px; +`; + +const SubTitle = styled(Text)` + color: ${({ theme }) => theme.text.grey}; + font-size: 10px; +`; + +const ActionsWrapper = styled.div` + display: flex; + align-items: center; + gap: 8px; + margin-left: auto; +`; + +export const DataTab: React.FC = ({ title, subTitle, logo, monitors, isActive, isError, onClick, children }) => { + const renderMonitors = useCallback(() => { + if (!monitors) return null; + + return ( + <> + {'β€’'} + + + ); + }, [monitors]); + + const renderActiveStatus = useCallback(() => { + if (typeof isActive !== 'boolean') return null; + + return ( + <> + {'β€’'} + + + ); + }, [isActive]); + + return ( + + + + + + + {title} + + {subTitle} + {renderMonitors()} + {renderActiveStatus()} + + + + {children} + + ); +}; diff --git a/frontend/webapp/reuseable-components/divider/index.tsx b/frontend/webapp/reuseable-components/divider/index.tsx index c2b81abf0d..35294176b7 100644 --- a/frontend/webapp/reuseable-components/divider/index.tsx +++ b/frontend/webapp/reuseable-components/divider/index.tsx @@ -1,29 +1,29 @@ import React from 'react'; import styled from 'styled-components'; +import { hexPercentValues } from '@/styles'; +import type { NotificationType } from '@/types'; interface Props { orientation?: 'horizontal' | 'vertical'; + type?: NotificationType; // this is to apply coloring to the divider thickness?: number; length?: string; - color?: string; margin?: string; } const StyledDivider = styled.div<{ $orientation?: Props['orientation']; + $type?: Props['type']; $thickness?: Props['thickness']; $length?: Props['length']; - $color?: Props['color']; $margin?: Props['margin']; }>` width: ${({ $orientation, $thickness, $length }) => ($orientation === 'vertical' ? `${$thickness}px` : $length || '100%')}; height: ${({ $orientation, $thickness, $length }) => ($orientation === 'horizontal' ? `${$thickness}px` : $length || '100%')}; margin: ${({ $orientation, $margin }) => $margin || ($orientation === 'horizontal' ? '8px 0' : '0 8px')}; - background-color: ${({ $color, theme }) => $color || theme.colors.border}; + background-color: ${({ $type, theme }) => (!!$type ? theme.text[$type] : theme.colors.border) + hexPercentValues['050']}; `; -const Divider: React.FC = ({ orientation = 'horizontal', thickness = 1, length, color, margin }) => { - return ; +export const Divider: React.FC = ({ orientation = 'horizontal', type, thickness = 1, length, margin }) => { + return ; }; - -export { Divider }; diff --git a/frontend/webapp/reuseable-components/index.ts b/frontend/webapp/reuseable-components/index.ts index 234a4d8d2c..6735fe776a 100644 --- a/frontend/webapp/reuseable-components/index.ts +++ b/frontend/webapp/reuseable-components/index.ts @@ -1,3 +1,4 @@ +export * from './monitors-icons'; export * from './text'; export * from './badge'; export * from './button'; @@ -33,3 +34,5 @@ export * from './field-label'; export * from './field-error'; export * from './extend-icon'; export * from './condition-details'; +export * from './data-card'; +export * from './data-tab'; diff --git a/frontend/webapp/reuseable-components/monitors-icons/index.tsx b/frontend/webapp/reuseable-components/monitors-icons/index.tsx new file mode 100644 index 0000000000..9c580d4514 --- /dev/null +++ b/frontend/webapp/reuseable-components/monitors-icons/index.tsx @@ -0,0 +1,32 @@ +import React from 'react'; +import Image from 'next/image'; +import { FlexRow } from '@/styles'; +import { capitalizeFirstLetter } from '@/utils'; +import { Text, Tooltip } from '@/reuseable-components'; + +interface Props { + monitors: string[]; + withTooltips?: boolean; + withLabels?: boolean; + size?: number; +} + +export const MonitorsIcons: React.FC = ({ monitors, withTooltips, withLabels, size = 12 }) => { + return ( + + {monitors.map((str) => { + const signal = str.toLocaleLowerCase(); + const signalDisplayName = capitalizeFirstLetter(signal); + + return ( + + + {signal} + {withLabels && {signalDisplayName}} + + + ); + })} + + ); +}; diff --git a/frontend/webapp/reuseable-components/nodes-data-flow/builder.ts b/frontend/webapp/reuseable-components/nodes-data-flow/builder.ts index 34d6b11288..dcb96ffd82 100644 --- a/frontend/webapp/reuseable-components/nodes-data-flow/builder.ts +++ b/frontend/webapp/reuseable-components/nodes-data-flow/builder.ts @@ -1,7 +1,7 @@ import theme from '@/styles/theme'; import { type Edge, type Node } from '@xyflow/react'; -import { getMainContainerLanguageLogo } from '@/utils/constants/programming-languages'; -import { extractMonitors, formatBytes, getActionIcon, getEntityIcon, getEntityLabel, getHealthStatus, getRuleIcon, getValueForRange } from '@/utils'; +import { getMainContainerLanguage } from '@/utils/constants/programming-languages'; +import { extractMonitors, formatBytes, getActionIcon, getEntityIcon, getEntityLabel, getHealthStatus, getProgrammingLanguageIcon, getRuleIcon, getValueForRange } from '@/utils'; import { OVERVIEW_ENTITY_TYPES, OVERVIEW_NODE_TYPES, STATUSES, type OverviewMetricsResponse, type SingleDestinationMetricsResponse, type ComputePlatformMapped } from '@/types'; const createNode = (nodeId: string, nodeType: string, x: number, y: number, data: Record, style?: React.CSSProperties): Node => { @@ -168,7 +168,7 @@ export const buildNodesAndEdges = ({ computePlatform, computePlatformFiltered, m status: getHealthStatus(source), title: getEntityLabel(source, OVERVIEW_ENTITY_TYPES.SOURCE, { extended: true }), subTitle: source.kind, - imageUri: getMainContainerLanguageLogo(source), + imageUri: getProgrammingLanguageIcon(getMainContainerLanguage(source)), metric, raw: source, }), diff --git a/frontend/webapp/reuseable-components/nodes-data-flow/nodes/base-node.tsx b/frontend/webapp/reuseable-components/nodes-data-flow/nodes/base-node.tsx index 6cc839db44..df7d39e992 100644 --- a/frontend/webapp/reuseable-components/nodes-data-flow/nodes/base-node.tsx +++ b/frontend/webapp/reuseable-components/nodes-data-flow/nodes/base-node.tsx @@ -3,7 +3,7 @@ import Image from 'next/image'; import { useAppStore } from '@/store'; import styled from 'styled-components'; import { getStatusIcon } from '@/utils'; -import { Checkbox, Status, Text } from '@/reuseable-components'; +import { Checkbox, DataTab } from '@/reuseable-components'; import { Handle, type Node, type NodeProps, Position } from '@xyflow/react'; import { type ActionDataParsed, type ActualDestination, type InstrumentationRuleSpec, type K8sActualSource, STATUSES } from '@/types'; @@ -27,63 +27,8 @@ interface Props nodeWidth: number; } -const Container = styled.div<{ $nodeWidth: Props['nodeWidth']; $isError?: boolean }>` - display: flex; - align-items: center; - align-self: stretch; - gap: 8px; - padding: 16px 24px 16px 16px; - width: ${({ $nodeWidth }) => `${$nodeWidth}px`}; - border-radius: 16px; - cursor: pointer; - background-color: ${({ $isError, theme }) => ($isError ? '#281515' : theme.colors.white_opacity['004'])}; - &:hover { - background-color: ${({ $isError, theme }) => ($isError ? '#351515' : theme.colors.white_opacity['008'])}; - } -`; - -const IconWrapper = styled.div<{ $isError?: boolean }>` - display: flex; - align-items: center; - justify-content: center; - gap: 8px; - width: 36px; - height: 36px; - border-radius: 8px; - background: ${({ $isError }) => - `linear-gradient(180deg, ${$isError ? 'rgba(237, 124, 124, 0.08)' : 'rgba(249, 249, 249, 0.06)'} 0%, ${$isError ? 'rgba(237, 124, 124, 0.02)' : 'rgba(249, 249, 249, 0.02)'} 100%)`}; -`; - -const BodyWrapper = styled.div` - display: flex; - flex-direction: column; - justify-content: space-between; - height: 36px; -`; - -const Title = styled(Text)<{ $nodeWidth: number }>` - max-width: ${({ $nodeWidth }) => `${Math.floor($nodeWidth * 0.5)}px`}; - overflow: hidden; - white-space: nowrap; - text-overflow: ellipsis; -`; - -const FooterWrapper = styled.div` - display: flex; - align-items: center; - gap: 8px; -`; - -const FooterText = styled(Text)` - color: ${({ theme }) => theme.text.grey}; - font-size: 10px; -`; - -const ActionsWrapper = styled.div` - display: flex; - align-items: center; - gap: 8px; - margin-left: auto; +const Container = styled.div<{ $nodeWidth: Props['nodeWidth'] }>` + width: ${({ $nodeWidth }) => `${$nodeWidth + 40}px`}; `; const BaseNode: React.FC = ({ nodeWidth, data, isConnectable }) => { @@ -92,57 +37,13 @@ const BaseNode: React.FC = ({ nodeWidth, data, isConnectable }) => { const { configuredSources, setConfiguredSources } = useAppStore((state) => state); - const renderHandles = () => { - switch (type) { - case 'source': - return ; - case 'action': - return ( - <> - - - - ); - case 'destination': - return ; - default: - return null; - } - }; - - const renderMonitors = () => { - if (!monitors) return null; - - return ( - - {'Β·'} - {monitors.map((monitor, index) => ( - {monitor} - ))} - - ); - }; - - const renderStatus = () => { - if (typeof isActive !== 'boolean') return null; - - return ( - - {'Β·'} - - - ); - }; - const renderActions = () => { const getSourceLocation = () => { const { namespace, name, kind } = raw as K8sActualSource; const selected = { ...configuredSources }; - if (!selected[namespace]) selected[namespace] = []; const index = selected[namespace].findIndex((x) => x.name === name && x.kind === kind); - return { index, namespace, selected }; }; @@ -171,24 +72,30 @@ const BaseNode: React.FC = ({ nodeWidth, data, isConnectable }) => { ); }; - return ( - - - source - - - - {title} - - {subTitle} - {renderMonitors()} - {renderStatus()} - - - - {renderActions()} + const renderHandles = () => { + switch (type) { + case 'source': + return ; + case 'action': + return ( + <> + + + + ); + case 'destination': + return ; + default: + return null; + } + }; - {renderHandles()} + return ( + + {}}> + {renderActions()} + {renderHandles()} + ); }; diff --git a/frontend/webapp/reuseable-components/nodes-data-flow/nodes/header-node.tsx b/frontend/webapp/reuseable-components/nodes-data-flow/nodes/header-node.tsx index 802b701bbe..61b6578e7b 100644 --- a/frontend/webapp/reuseable-components/nodes-data-flow/nodes/header-node.tsx +++ b/frontend/webapp/reuseable-components/nodes-data-flow/nodes/header-node.tsx @@ -35,7 +35,7 @@ const Title = styled(Text)` const ActionsWrapper = styled.div` margin-left: auto; - margin-right: 24px; + margin-right: 16px; `; const HeaderNode: React.FC = ({ nodeWidth, data }) => { diff --git a/frontend/webapp/reuseable-components/notification-note/index.tsx b/frontend/webapp/reuseable-components/notification-note/index.tsx index 755eb87a9f..0976363cb7 100644 --- a/frontend/webapp/reuseable-components/notification-note/index.tsx +++ b/frontend/webapp/reuseable-components/notification-note/index.tsx @@ -1,7 +1,6 @@ import React, { useCallback, useEffect, useRef, useState } from 'react'; import Image from 'next/image'; import { Text } from '../text'; -import theme from '@/styles/theme'; import { Divider } from '../divider'; import styled from 'styled-components'; import { getStatusIcon } from '@/utils'; @@ -106,7 +105,7 @@ const CloseButton = styled(Image)` } `; -const NotificationNote: React.FC = ({ type, title, message, action, onClose, style }) => { +export const NotificationNote: React.FC = ({ type, title, message, action, onClose, style }) => { // These are for handling transitions: // isEntering - to stop the progress bar from rendering before the toast is fully slide-in // isLeaving - to trigger the slide-out animation @@ -166,7 +165,7 @@ const NotificationNote: React.FC = ({ type, title, message, a {title && {title}} - {title && message && } + {title && message && } {message && {message}} @@ -182,5 +181,3 @@ const NotificationNote: React.FC = ({ type, title, message, a ); }; - -export { NotificationNote }; diff --git a/frontend/webapp/reuseable-components/status/active-status/index.tsx b/frontend/webapp/reuseable-components/status/active-status/index.tsx new file mode 100644 index 0000000000..866503b6fa --- /dev/null +++ b/frontend/webapp/reuseable-components/status/active-status/index.tsx @@ -0,0 +1,8 @@ +import React from 'react'; +import { Status, type StatusProps } from '@/reuseable-components'; + +interface Props extends StatusProps {} + +export const ActiveStatus: React.FC = ({ isActive, ...props }) => { + return ; +}; diff --git a/frontend/webapp/reuseable-components/status/connection-status/index.tsx b/frontend/webapp/reuseable-components/status/connection-status/index.tsx new file mode 100644 index 0000000000..6b9fe2bbd7 --- /dev/null +++ b/frontend/webapp/reuseable-components/status/connection-status/index.tsx @@ -0,0 +1,8 @@ +import React from 'react'; +import { Status, type StatusProps } from '@/reuseable-components'; + +interface Props extends StatusProps {} + +export const ConnectionStatus: React.FC = ({ ...props }) => { + return ; +}; diff --git a/frontend/webapp/reuseable-components/status/index.tsx b/frontend/webapp/reuseable-components/status/index.tsx index e1d7d0d4ae..c9c8919877 100644 --- a/frontend/webapp/reuseable-components/status/index.tsx +++ b/frontend/webapp/reuseable-components/status/index.tsx @@ -1,45 +1,54 @@ import React from 'react'; import Image from 'next/image'; -import { Text } from '../text'; -import styled from 'styled-components'; +import styled, { css } from 'styled-components'; import { getStatusIcon } from '@/utils'; +import { Divider, Text } from '@/reuseable-components'; +import theme, { hexPercentValues } from '@/styles/theme'; -interface Props { +export * from './active-status'; +export * from './connection-status'; +export * from './instrument-status'; + +export interface StatusProps { title?: string; subtitle?: string; + size?: number; + family?: 'primary' | 'secondary'; + isPale?: boolean; isActive?: boolean; - withBackground?: boolean; - withBorder?: boolean; - withSmaller?: boolean; - withSpecialFont?: boolean; withIcon?: boolean; + withBorder?: boolean; + withBackground?: boolean; } const StatusWrapper = styled.div<{ - $isActive?: Props['isActive']; - $withIcon?: Props['withIcon']; - $withBorder?: Props['withBorder']; - $withBackground?: Props['withBackground']; - $withSmaller?: Props['withSmaller']; + $size: number; + $isPale: StatusProps['isPale']; + $isActive: StatusProps['isActive']; + $withIcon?: StatusProps['withIcon']; + $withBorder?: StatusProps['withBorder']; + $withBackground?: StatusProps['withBackground']; }>` display: flex; align-items: center; + gap: ${({ $size }) => $size / 3}px; + padding: ${({ $size, $withBorder, $withBackground }) => ($withBorder || $withBackground ? `${$size / ($withBorder ? 3 : 2)}px ${$size / ($withBorder ? 1.5 : 1)}px` : '0')}; width: fit-content; - padding: ${({ $withIcon, $withBorder, $withSmaller }) => ($withIcon || $withBorder ? ($withSmaller ? '4px 8px' : '8px 24px') : '0')}; - border-radius: 32px; - border: 1px solid ${({ $withBorder, $isActive, theme }) => ($withBorder ? ($isActive ? theme.colors.dark_green : theme.colors.dark_red) : 'transparent')}; - background: ${({ $withBackground, $isActive }) => + border-radius: 360px; + border: ${({ $withBorder, $isPale, $isActive, theme }) => ($withBorder ? `1px solid ${$isPale ? theme.colors.border : $isActive ? theme.colors.dark_green : theme.colors.dark_red}` : 'none')}; + background: ${({ $withBackground, $isPale, $isActive, theme }) => $withBackground - ? $isActive - ? `linear-gradient(90deg, rgba(23, 32, 19, 0) 0%, rgba(23, 32, 19, 0.8) 50%, #172013 100%)` - : `linear-gradient(90deg, rgba(51, 21, 21, 0.00) 0%, rgba(51, 21, 21, 0.80) 50%, #331515 100%)` + ? $isPale + ? `linear-gradient(90deg, transparent 0%, ${theme.colors.info + hexPercentValues['080']} 50%, ${theme.colors.info} 100%)` + : $isActive + ? `linear-gradient(90deg, transparent 0%, ${theme.colors.success + hexPercentValues['080']} 50%, ${theme.colors.success} 100%)` + : `linear-gradient(90deg, transparent 0%, ${theme.colors.error + hexPercentValues['080']} 50%, ${theme.colors.error} 100%)` : 'transparent'}; `; -const IconWrapper = styled.div<{ $withSmaller?: Props['withSmaller'] }>` +const IconWrapper = styled.div` display: flex; align-items: center; - margin-right: ${({ $withSmaller }) => ($withSmaller ? '6px' : '8px')}; `; const TextWrapper = styled.div` @@ -47,54 +56,48 @@ const TextWrapper = styled.div` align-items: center; `; -const Title = styled(Text)<{ $isActive?: Props['isActive']; $withSpecialFont?: Props['withSpecialFont']; $withSmaller?: Props['withSmaller'] }>` - font-weight: 400; - font-size: ${({ $withSmaller }) => ($withSmaller ? '12px' : '14px')}; - font-family: ${({ $withSpecialFont, theme }) => ($withSpecialFont ? theme.font_family.secondary : theme.font_family.primary)}; - color: ${({ $isActive, theme }) => ($isActive ? theme.text.success : theme.text.error)}; - text-transform: ${({ $withSpecialFont }) => ($withSpecialFont ? 'uppercase' : 'unset')}; -`; - -const SubTitle = styled(Text)<{ $isActive?: Props['isActive']; $withSpecialFont?: Props['withSpecialFont']; $withSmaller?: Props['withSmaller'] }>` - font-weight: 400; - font-size: ${({ $withSmaller }) => ($withSmaller ? '10px' : '12px')}; - font-family: ${({ $withSpecialFont, theme }) => ($withSpecialFont ? theme.font_family.secondary : theme.font_family.primary)}; - color: ${({ $isActive }) => ($isActive ? '#51DB51' : '#DB5151')}; - text-transform: ${({ $withSpecialFont }) => ($withSpecialFont ? 'uppercase' : 'unset')}; +const Title = styled(Text)<{ + $isPale: StatusProps['isPale']; + $isActive: StatusProps['isActive']; +}>` + color: ${({ $isPale, $isActive, theme }) => ($isPale ? theme.text.secondary : $isActive ? theme.text.success : theme.text.error)}; `; -const TextDivider = styled.div<{ $isActive?: Props['isActive'] }>` - width: 1px; - height: 12px; - background: ${({ $isActive }) => ($isActive ? 'rgba(124, 237, 124, 0.16)' : 'rgba(237, 124, 124, 0.16)')}; - margin: 0 8px; +const SubTitle = styled(Text)<{ + $isPale: StatusProps['isPale']; + $isActive: StatusProps['isActive']; +}>` + color: ${({ $isPale, $isActive }) => ($isPale ? theme.text.grey : $isActive ? '#51DB51' : '#DB5151')}; `; -const Status: React.FC = ({ title, subtitle, isActive, withIcon, withBorder, withBackground, withSpecialFont, withSmaller }) => { +export const Status: React.FC = ({ title, subtitle, size = 12, family = 'secondary', isPale, isActive, withIcon, withBorder, withBackground }) => { return ( - + {withIcon && ( - - status + + {/* TODO: SVG to JSX */} + status )} - - - {title || (isActive ? 'Active' : 'Inactive')} - + {(!!title || !!subtitle) && ( + + {!!title && ( + + {title} + + )} - {subtitle && ( - - - - {subtitle} - - - )} - + {!!subtitle && ( + + + + {subtitle} + + + )} + + )} ); }; - -export { Status }; diff --git a/frontend/webapp/reuseable-components/status/instrument-status/index.tsx b/frontend/webapp/reuseable-components/status/instrument-status/index.tsx new file mode 100644 index 0000000000..8c965255b3 --- /dev/null +++ b/frontend/webapp/reuseable-components/status/instrument-status/index.tsx @@ -0,0 +1,19 @@ +import React from 'react'; +import { Status, type StatusProps } from '@/reuseable-components'; +import { INSTUMENTATION_STATUS, WORKLOAD_PROGRAMMING_LANGUAGES } from '@/utils'; + +interface Props extends StatusProps { + language: WORKLOAD_PROGRAMMING_LANGUAGES; +} + +export const InstrumentStatus: React.FC = ({ language, ...props }) => { + const isActive = ![ + WORKLOAD_PROGRAMMING_LANGUAGES.IGNORED, + WORKLOAD_PROGRAMMING_LANGUAGES.UNKNOWN, + WORKLOAD_PROGRAMMING_LANGUAGES.PROCESSING, + WORKLOAD_PROGRAMMING_LANGUAGES.NO_CONTAINERS, + WORKLOAD_PROGRAMMING_LANGUAGES.NO_RUNNING_PODS, + ].includes(language); + + return ; +}; diff --git a/frontend/webapp/styles/styled.tsx b/frontend/webapp/styles/styled.tsx index b2759dc89f..88e5eb37a6 100644 --- a/frontend/webapp/styles/styled.tsx +++ b/frontend/webapp/styles/styled.tsx @@ -27,3 +27,17 @@ export const ModalBody = styled.div` margin: 64px 7vw 32px 7vw; overflow-y: scroll; `; + +export const FlexRow = styled.div<{ $gap?: number }>` + display: flex; + flex-direction: row; + align-items: center; + gap: ${({ $gap = 2 }) => $gap}px; +`; + +export const FlexColumn = styled.div<{ $gap?: number }>` + display: flex; + flex-direction: column; + align-items: flex-start; + gap: ${({ $gap = 2 }) => $gap}px; +`; diff --git a/frontend/webapp/types/actions.ts b/frontend/webapp/types/actions.ts index ea395fd56a..9785df777d 100644 --- a/frontend/webapp/types/actions.ts +++ b/frontend/webapp/types/actions.ts @@ -1,5 +1,10 @@ import { type SignalUppercase } from '@/utils'; +export enum PlatformTypes { + K8S = 'k8s', + VM = 'vm', +} + export enum ActionsType { ADD_CLUSTER_INFO = 'AddClusterInfo', DELETE_ATTRIBUTES = 'DeleteAttribute', diff --git a/frontend/webapp/types/sources.ts b/frontend/webapp/types/sources.ts index c274bd0231..f2ce900154 100644 --- a/frontend/webapp/types/sources.ts +++ b/frontend/webapp/types/sources.ts @@ -1,8 +1,11 @@ -import { Condition } from './common'; +import { type Condition } from './common'; +import { WORKLOAD_PROGRAMMING_LANGUAGES } from '@/utils'; export type SourceContainer = { containerName: string; - language: string; + language: WORKLOAD_PROGRAMMING_LANGUAGES; + runtimeVersion: string; + otherAgent: string | null; }; export type K8sActualSource = { diff --git a/frontend/webapp/utils/constants/programming-languages.ts b/frontend/webapp/utils/constants/programming-languages.ts index 3e709db778..994a90879f 100644 --- a/frontend/webapp/utils/constants/programming-languages.ts +++ b/frontend/webapp/utils/constants/programming-languages.ts @@ -1,7 +1,5 @@ import { K8sActualSource } from '@/types'; -const BASE_URL = 'https://d1n7d4xz7fr8b4.cloudfront.net/'; - // while odigos lists language per container, we want to aggregate one single language for the workload. // the process is mostly heuristic, we iterate over the containers and return the first valid language we find. // there are additional cases for when the workload programming language is not available. @@ -12,45 +10,15 @@ export enum WORKLOAD_PROGRAMMING_LANGUAGES { PYTHON = 'python', DOTNET = 'dotnet', MYSQL = 'mysql', + NGINX = 'nginx', + IGNORED = 'ignored', UNKNOWN = 'unknown', // language detection completed but could not find a supported language PROCESSING = 'processing', // language detection is not yet complotted, data is not available NO_CONTAINERS = 'no containers', // language detection completed but no containers found or they are ignored NO_RUNNING_PODS = 'no running pods', // no running pods are available for language detection - NGINX = 'nginx', } -export const LANGUAGES_LOGOS: Record = { - [WORKLOAD_PROGRAMMING_LANGUAGES.JAVA]: `${BASE_URL}java.svg`, - [WORKLOAD_PROGRAMMING_LANGUAGES.GO]: `${BASE_URL}go.svg`, - [WORKLOAD_PROGRAMMING_LANGUAGES.JAVASCRIPT]: `${BASE_URL}nodejs.svg`, - [WORKLOAD_PROGRAMMING_LANGUAGES.PYTHON]: `${BASE_URL}python.svg`, - [WORKLOAD_PROGRAMMING_LANGUAGES.DOTNET]: `${BASE_URL}dotnet.svg`, - [WORKLOAD_PROGRAMMING_LANGUAGES.MYSQL]: `${BASE_URL}mysql.svg`, - [WORKLOAD_PROGRAMMING_LANGUAGES.UNKNOWN]: `${BASE_URL}default.svg`, // TODO: good icon - [WORKLOAD_PROGRAMMING_LANGUAGES.PROCESSING]: `${BASE_URL}default.svg`, // TODO: good icon - [WORKLOAD_PROGRAMMING_LANGUAGES.NO_CONTAINERS]: `${BASE_URL}default.svg`, // TODO: good icon - [WORKLOAD_PROGRAMMING_LANGUAGES.NO_RUNNING_PODS]: `${BASE_URL}default.svg`, // TODO: good icon - [WORKLOAD_PROGRAMMING_LANGUAGES.NGINX]: `${BASE_URL}nginx.svg`, -}; - -export const LANGUAGES_COLORS: Record = - { - [WORKLOAD_PROGRAMMING_LANGUAGES.JAVA]: '#B07219', - [WORKLOAD_PROGRAMMING_LANGUAGES.GO]: '#00ADD8', - [WORKLOAD_PROGRAMMING_LANGUAGES.JAVASCRIPT]: '#F7DF1E', - [WORKLOAD_PROGRAMMING_LANGUAGES.PYTHON]: '#306998', - [WORKLOAD_PROGRAMMING_LANGUAGES.DOTNET]: '#512BD4', - [WORKLOAD_PROGRAMMING_LANGUAGES.MYSQL]: '#00758F', - [WORKLOAD_PROGRAMMING_LANGUAGES.UNKNOWN]: '#8b92a6', - [WORKLOAD_PROGRAMMING_LANGUAGES.PROCESSING]: '#3367d9', - [WORKLOAD_PROGRAMMING_LANGUAGES.NO_CONTAINERS]: '#111111', - [WORKLOAD_PROGRAMMING_LANGUAGES.NO_RUNNING_PODS]: '#666666', - [WORKLOAD_PROGRAMMING_LANGUAGES.NGINX]: '#009237', - }; - -export const getMainContainerLanguage = ( - source: K8sActualSource -): WORKLOAD_PROGRAMMING_LANGUAGES => { +export const getMainContainerLanguage = (source: K8sActualSource): WORKLOAD_PROGRAMMING_LANGUAGES => { const ia = source?.instrumentedApplicationDetails; if (!ia) { @@ -67,26 +35,15 @@ export const getMainContainerLanguage = ( } // we will filter out the ignored languages as we don't want to account them in the main language - const noneIgnoredLanguages = containers.filter( - (container) => container.language !== 'ignored' - ); + const noneIgnoredLanguages = containers.filter((container) => container.language !== 'ignored'); if (noneIgnoredLanguages.length === 0) { return WORKLOAD_PROGRAMMING_LANGUAGES.NO_CONTAINERS; } // find the first container with valid language - const mainContainer = noneIgnoredLanguages.find( - (container) => container.language !== 'unknown' - ); + const mainContainer = noneIgnoredLanguages.find((container) => container.language !== 'unknown'); if (!mainContainer) { return WORKLOAD_PROGRAMMING_LANGUAGES.UNKNOWN; // no valid language found, return the first one } return mainContainer.language as WORKLOAD_PROGRAMMING_LANGUAGES; }; - -export const getMainContainerLanguageLogo = ( - source: K8sActualSource -): string => { - const language = getMainContainerLanguage(source); - return LANGUAGES_LOGOS[language]; -}; diff --git a/frontend/webapp/utils/constants/string.tsx b/frontend/webapp/utils/constants/string.tsx index 7c2f3e5a1e..b74a352430 100644 --- a/frontend/webapp/utils/constants/string.tsx +++ b/frontend/webapp/utils/constants/string.tsx @@ -51,3 +51,40 @@ export const BACKEND_BOOLEAN = { FALSE: 'False', TRUE: 'True', }; + +export const INSTUMENTATION_STATUS = { + INSTRUMENTED: 'Instrumented', + UNINSTRUMENTED: 'Uninstrumented', +}; + +export const DATA_CARDS = { + ACTION_DETAILS: 'Action Details', + RULE_DETAILS: 'Instrumentation Rule Details', + DESTINATION_DETAILS: 'Destination Details', + SOURCE_DETAILS: 'Source Details', + + DETECTED_CONTAINERS: 'Detected Containers', + DETECTED_CONTAINERS_DESCRIPTION: 'The system automatically instruments the containers it detects with a supported programming language.', +}; + +export const DISPLAY_TITLES = { + ACTION: 'Action', + ACTIONS: 'Actions', + INSTRUMENTATION_RULE: 'Instrumentation Rule', + INSTRUMENTATION_RULES: 'Instrumentation Rules', + DESTINATION: 'Destination', + DESTINATIONS: 'Destinations', + SOURCE: 'Source', + SOURCES: 'Sources', + + NAMESPACE: 'Namespace', + CONTAINER_NAME: 'Container Name', + KIND: 'Kind', + TYPE: 'Type', + NAME: 'Name', + NOTES: 'Notes', + STATUS: 'Status', + LANGUAGE: 'Language', + MONITORS: 'Monitors', + SIGNALS_FOR_PROCESSING: 'Signals for Processing', +}; diff --git a/frontend/webapp/utils/functions/icons.ts b/frontend/webapp/utils/functions/icons.ts index 9f747bd772..baf2ab0ec9 100644 --- a/frontend/webapp/utils/functions/icons.ts +++ b/frontend/webapp/utils/functions/icons.ts @@ -1,4 +1,5 @@ -import { type ActionsType, type InstrumentationRuleType, type NotificationType, OVERVIEW_ENTITY_TYPES } from '@/types'; +import { WORKLOAD_PROGRAMMING_LANGUAGES } from '../constants'; +import { type ActionsType, type InstrumentationRuleType, type NotificationType, OVERVIEW_ENTITY_TYPES, SourceContainer } from '@/types'; const BRAND_ICON = '/brand/odigos-icon.svg'; @@ -44,3 +45,25 @@ export const getActionIcon = (type?: ActionsType | 'sampler' | 'attributes') => return `/icons/actions/${iconName}.svg`; }; + +export const getProgrammingLanguageIcon = (language?: SourceContainer['language']) => { + if (!language) return BRAND_ICON; + + const BASE_URL = 'https://d1n7d4xz7fr8b4.cloudfront.net/'; + const LANGUAGES_LOGOS: Record = { + [WORKLOAD_PROGRAMMING_LANGUAGES.JAVA]: `${BASE_URL}java.svg`, + [WORKLOAD_PROGRAMMING_LANGUAGES.GO]: `${BASE_URL}go.svg`, + [WORKLOAD_PROGRAMMING_LANGUAGES.JAVASCRIPT]: `${BASE_URL}nodejs.svg`, + [WORKLOAD_PROGRAMMING_LANGUAGES.PYTHON]: `${BASE_URL}python.svg`, + [WORKLOAD_PROGRAMMING_LANGUAGES.DOTNET]: `${BASE_URL}dotnet.svg`, + [WORKLOAD_PROGRAMMING_LANGUAGES.MYSQL]: `${BASE_URL}mysql.svg`, + [WORKLOAD_PROGRAMMING_LANGUAGES.NGINX]: `${BASE_URL}nginx.svg`, + [WORKLOAD_PROGRAMMING_LANGUAGES.IGNORED]: BRAND_ICON, // TODO: good icon + [WORKLOAD_PROGRAMMING_LANGUAGES.UNKNOWN]: BRAND_ICON, // TODO: good icon + [WORKLOAD_PROGRAMMING_LANGUAGES.PROCESSING]: BRAND_ICON, // TODO: good icon + [WORKLOAD_PROGRAMMING_LANGUAGES.NO_CONTAINERS]: BRAND_ICON, // TODO: good icon + [WORKLOAD_PROGRAMMING_LANGUAGES.NO_RUNNING_PODS]: BRAND_ICON, // TODO: good icon + }; + + return LANGUAGES_LOGOS[language] || BRAND_ICON; +}; diff --git a/frontend/webapp/utils/functions/strings.tsx b/frontend/webapp/utils/functions/strings.tsx index 7d6b3ea23b..27c402a87e 100644 --- a/frontend/webapp/utils/functions/strings.tsx +++ b/frontend/webapp/utils/functions/strings.tsx @@ -13,9 +13,7 @@ export function safeJsonParse(str: string | undefined, fallback: T): T { } } -export function cleanObjectEmptyStringsValues( - obj: Record -): Record { +export function cleanObjectEmptyStringsValues(obj: Record): Record { const cleanArray = (arr: any[]): any[] => arr.filter((item) => { if (typeof item === 'object' && item !== null) { @@ -30,10 +28,9 @@ export function cleanObjectEmptyStringsValues( .filter(([key, value]) => key !== '' && value !== '') .map(([key, value]) => { if (Array.isArray(value)) return [key, cleanArray(value)]; - else if (typeof value === 'object' && value !== null) - return [key, cleanObject(value)]; + else if (typeof value === 'object' && value !== null) return [key, cleanObject(value)]; return [key, value]; - }) + }), ); return Object.entries(obj).reduce((acc, [key, value]) => { @@ -59,9 +56,8 @@ export function cleanObjectEmptyStringsValues( return acc; }, {} as Record); } -export function stringifyNonStringValues( - obj: Record -): Record { + +export function stringifyNonStringValues(obj: Record): Record { return Object.entries(obj).reduce((acc, [key, value]) => { // Check if the value is already a string if (typeof value === 'string') { @@ -74,6 +70,40 @@ export function stringifyNonStringValues( }, {} as Record); } +export const parseJsonStringToPrettyString = (value: string) => { + let str = ''; + + try { + const parsed = JSON.parse(value); + + // Handle arrays + if (Array.isArray(parsed)) { + str = parsed + .map((item) => { + if (typeof item === 'object' && item !== null) return `${item.key}: ${item.value}`; + else return item; + }) + .join(', '); + } + + // Handle objects (non-array JSON objects) + else if (typeof parsed === 'object' && parsed !== null) { + str = Object.entries(parsed) + .map(([key, val]) => `${key}: ${val}`) + .join(', '); + } + + // Should never reach this if it's a string (it will throw) + else { + str = value; + } + } catch (error) { + str = value; + } + + return str; +}; + export const timeAgo = (timestamp: string) => { const now = new Date(); const notificationTime = new Date(timestamp); @@ -115,27 +145,10 @@ export function formatDate(dateString: string) { const seconds = date.getUTCSeconds(); // Define month names - const monthNames = [ - 'January', - 'February', - 'March', - 'April', - 'May', - 'June', - 'July', - 'August', - 'September', - 'October', - 'November', - 'December', - ]; + const monthNames = ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December']; // Format the components into a readable string - const formattedDate = `${monthNames[month]} ${day}, ${year} ${hours - .toString() - .padStart(2, '0')}:${minutes.toString().padStart(2, '0')}:${seconds - .toString() - .padStart(2, '0')}`; + const formattedDate = `${monthNames[month]} ${day}, ${year} ${hours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`; return formattedDate; } diff --git a/helm/odigos/templates/odigos-config-cm.yaml b/helm/odigos/templates/odigos-config-cm.yaml index 1ea0ec8803..3628ce5d9e 100644 --- a/helm/odigos/templates/odigos-config-cm.yaml +++ b/helm/odigos/templates/odigos-config-cm.yaml @@ -17,9 +17,21 @@ data: {{- end }} {{- if .Values.collectorGateway }} collectorGateway: + {{- with .Values.collectorGateway.minReplicas }} + MinReplicas: {{ . }} + {{- end }} + {{- with .Values.collectorGateway.maxReplicas }} + MaxReplicas: {{ . }} + {{- end }} {{- with .Values.collectorGateway.requestMemoryMiB }} requestMemoryMiB: {{ . }} {{- end }} + {{- with .Values.collectorGateway.requestCPUm }} + requestCPUm: {{ . }} + {{- end }} + {{- with .Values.collectorGateway.limitCPUm }} + limitCPUm: {{ . }} + {{- end }} {{- with .Values.collectorGateway.memoryLimiterLimitMiB }} memoryLimiterLimitMiB: {{ . }} {{- end }} diff --git a/helm/odigos/values.yaml b/helm/odigos/values.yaml index e407e5dcb5..ce5fbd2a18 100644 --- a/helm/odigos/values.yaml +++ b/helm/odigos/values.yaml @@ -25,6 +25,25 @@ collectorGateway: # of the form "memory: Mi". # default value is 500Mi requestMemoryMiB: 500 + + # the CPU request for the cluster gateway collector deployment. + # it will be embedded in the deployment as a resource request + # of the form "cpu: m". + # default value is 500m + requestCPUm: 500 + # the CPU limit for the cluster gateway collector deployment. + # it will be embedded in the deployment as a resource limit + # of the form "cpu: m". + # default value is 1000m + limitCPUm: 1000 + + # The number of replicas for the cluster gateway collector deployment. + # Also uses in MinReplicas the HPA config. + minReplicas: 1 + # The maxReplicas in the HPA config. + maxReplicas: 10 + + # sets the "limit_mib" parameter in the memory limiter configuration for the collector gateway. # it is the hard limit after which a force garbage collection will be performed. # if not set, it will be 50Mi below the memory request. diff --git a/scheduler/controllers/clustercollectorsgroup/common.go b/scheduler/controllers/clustercollectorsgroup/common.go index a88c0424b6..514862ad46 100644 --- a/scheduler/controllers/clustercollectorsgroup/common.go +++ b/scheduler/controllers/clustercollectorsgroup/common.go @@ -11,7 +11,7 @@ import ( "sigs.k8s.io/controller-runtime/pkg/client" ) -func newClusterCollectorGroup(namespace string, memorySettings *odigosv1.CollectorsGroupMemorySettings) *odigosv1.CollectorsGroup { +func newClusterCollectorGroup(namespace string, resourcesSettings *odigosv1.CollectorsGroupResourcesSettings) *odigosv1.CollectorsGroup { return &odigosv1.CollectorsGroup{ TypeMeta: metav1.TypeMeta{ Kind: "CollectorsGroup", @@ -24,7 +24,7 @@ func newClusterCollectorGroup(namespace string, memorySettings *odigosv1.Collect Spec: odigosv1.CollectorsGroupSpec{ Role: odigosv1.CollectorsGroupRoleClusterGateway, CollectorOwnMetricsPort: consts.OdigosClusterCollectorOwnTelemetryPortDefault, - MemorySettings: *memorySettings, + ResourcesSettings: *resourcesSettings, }, } } @@ -44,10 +44,10 @@ func sync(ctx context.Context, c client.Client) error { return err } - memorySettings := getMemorySettings(&odigosConfig) + resourceSettings := getGatewayResourceSettings(&odigosConfig) if len(dests.Items) > 0 { - err := utils.ApplyCollectorGroup(ctx, c, newClusterCollectorGroup(namespace, memorySettings)) + err := utils.ApplyCollectorGroup(ctx, c, newClusterCollectorGroup(namespace, resourceSettings)) if err != nil { return err } diff --git a/scheduler/controllers/clustercollectorsgroup/memory.go b/scheduler/controllers/clustercollectorsgroup/resource_config.go similarity index 61% rename from scheduler/controllers/clustercollectorsgroup/memory.go rename to scheduler/controllers/clustercollectorsgroup/resource_config.go index 7a8244a1a2..94515db5d3 100644 --- a/scheduler/controllers/clustercollectorsgroup/memory.go +++ b/scheduler/controllers/clustercollectorsgroup/resource_config.go @@ -6,8 +6,19 @@ import ( ) const ( + // the default memory request in MiB defaultRequestMemoryMiB = 500 + // the default CPU request in millicores + defaultRequestCPUm = 500 + // the default CPU limit in millicores + defaultLimitCPUm = 1000 + + // MinReplicasDefault is the default number of replicas for the collector + defaultMinReplicas = 1 + // MaxReplicasDefault is the default maximum number of replicas for the collector hpa + defaultMaxReplicas = 10 + // this configures the processor limit_mib, which is the hard limit in MiB, afterwhich garbage collection will be forced. // as recommended by the processor docs, if not set, this is set to 50MiB less than the memory limit of the collector defaultMemoryLimiterLimitDiffMib = 50 @@ -27,35 +38,57 @@ const ( memoryLimitAboveRequestFactor = 1.25 ) -// process the memory settings from odigos config and return the memory settings for the collectors group. +// process the resources settings from odigos config and return the resources settings for the collectors group. // apply any defaulting and calculations here. -func getMemorySettings(odigosConfig *common.OdigosConfiguration) *odigosv1.CollectorsGroupMemorySettings { +func getGatewayResourceSettings(odigosConfig *common.OdigosConfiguration) *odigosv1.CollectorsGroupResourcesSettings { + gatewayConfig := odigosConfig.CollectorGateway + + gatewayMinReplicas := defaultMinReplicas + if gatewayConfig != nil && gatewayConfig.MinReplicas > 0 { + gatewayMinReplicas = gatewayConfig.MinReplicas + } + gatewayMaxReplicas := defaultMaxReplicas + if gatewayConfig != nil && gatewayConfig.MaxReplicas > 0 { + gatewayMaxReplicas = gatewayConfig.MaxReplicas + } memoryRequestMiB := defaultRequestMemoryMiB - if odigosConfig.CollectorGateway != nil && odigosConfig.CollectorGateway.RequestMemoryMiB > 0 { - memoryRequestMiB = odigosConfig.CollectorGateway.RequestMemoryMiB + if gatewayConfig != nil && gatewayConfig.RequestMemoryMiB > 0 { + memoryRequestMiB = gatewayConfig.RequestMemoryMiB + } + cpuRequestm := defaultRequestCPUm + if gatewayConfig != nil && gatewayConfig.RequestCPUm > 0 { + cpuRequestm = gatewayConfig.RequestCPUm + } + cpuLimitm := defaultLimitCPUm + if gatewayConfig != nil && gatewayConfig.LimitCPUm > 0 { + cpuLimitm = gatewayConfig.LimitCPUm } - - memoryLimitMiB := int(float64(memoryRequestMiB) * memoryLimitAboveRequestFactor) // the memory limiter hard limit is set as 50 MiB less than the memory request + memoryLimiterLimitMiB := memoryRequestMiB - defaultMemoryLimiterLimitDiffMib if odigosConfig.CollectorGateway != nil && odigosConfig.CollectorGateway.MemoryLimiterLimitMiB > 0 { memoryLimiterLimitMiB = odigosConfig.CollectorGateway.MemoryLimiterLimitMiB } - - memoryLimiterSpikeLimitMiB := memoryLimiterLimitMiB * defaultMemoryLimiterSpikePercentage / 100.0 + memoryLimiterSpikeLimitMiB := memoryLimiterLimitMiB * defaultMemoryLimiterSpikePercentage / 100 if odigosConfig.CollectorGateway != nil && odigosConfig.CollectorGateway.MemoryLimiterSpikeLimitMiB > 0 { memoryLimiterSpikeLimitMiB = odigosConfig.CollectorGateway.MemoryLimiterSpikeLimitMiB } + memoryLimitMiB := int(float64(memoryRequestMiB) * memoryLimitAboveRequestFactor) + gomemlimitMiB := int(memoryLimiterLimitMiB * defaultGoMemLimitPercentage / 100.0) if odigosConfig.CollectorGateway != nil && odigosConfig.CollectorGateway.GoMemLimitMib != 0 { gomemlimitMiB = odigosConfig.CollectorGateway.GoMemLimitMib } - return &odigosv1.CollectorsGroupMemorySettings{ + return &odigosv1.CollectorsGroupResourcesSettings{ + MinReplicas: &gatewayMinReplicas, + MaxReplicas: &gatewayMaxReplicas, MemoryRequestMiB: memoryRequestMiB, MemoryLimitMiB: memoryLimitMiB, + CpuRequestMillicores: cpuRequestm, + CpuLimitMillicores: cpuLimitm, MemoryLimiterLimitMiB: memoryLimiterLimitMiB, MemoryLimiterSpikeLimitMiB: memoryLimiterSpikeLimitMiB, GomemlimitMiB: gomemlimitMiB, diff --git a/tests/e2e/cli-upgrade/chainsaw-test.yaml b/tests/e2e/cli-upgrade/chainsaw-test.yaml index 9ced3e4635..b9907593f9 100644 --- a/tests/e2e/cli-upgrade/chainsaw-test.yaml +++ b/tests/e2e/cli-upgrade/chainsaw-test.yaml @@ -118,6 +118,11 @@ spec: timeout: 60s - assert: file: assert-odigos-upgraded.yaml + timeout: 60s + catch: + - get: + apiVersion: v1 + kind: Pod - name: Odigos pipeline ready after upgrade try: - assert: