mirror of
https://github.com/SigNoz/signoz.git
synced 2026-03-01 03:22:55 +00:00
Compare commits
13 Commits
refactor/c
...
SIG-1709-c
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a3dcdca45f | ||
|
|
870df96302 | ||
|
|
95fc905448 | ||
|
|
9f5afaf36f | ||
|
|
3f69d5cdc2 | ||
|
|
98617b5120 | ||
|
|
4e7530b359 | ||
|
|
b439413c21 | ||
|
|
e5c7f0b636 | ||
|
|
07f0332900 | ||
|
|
728557fbff | ||
|
|
21c0192536 | ||
|
|
a238614738 |
10
.github/workflows/jsci.yaml
vendored
10
.github/workflows/jsci.yaml
vendored
@@ -61,3 +61,13 @@ jobs:
|
||||
with:
|
||||
PRIMUS_REF: main
|
||||
JS_SRC: frontend
|
||||
md-languages:
|
||||
if: |
|
||||
(github.event_name == 'pull_request' && ! github.event.pull_request.head.repo.fork && github.event.pull_request.user.login != 'dependabot[bot]' && ! contains(github.event.pull_request.labels.*.name, 'safe-to-test')) ||
|
||||
(github.event_name == 'pull_request_target' && contains(github.event.pull_request.labels.*.name, 'safe-to-test'))
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: checkout
|
||||
uses: actions/checkout@v4
|
||||
- name: validate md languages
|
||||
run: bash frontend/scripts/validate-md-languages.sh
|
||||
|
||||
127
docs/contributing/go/abstractions.md
Normal file
127
docs/contributing/go/abstractions.md
Normal file
@@ -0,0 +1,127 @@
|
||||
# Abstractions
|
||||
|
||||
This document provides rules for deciding when a new type, interface, or intermediate representation is warranted in Go code. The goal is to keep the codebase navigable by ensuring every abstraction earns its place.
|
||||
|
||||
## The cost of a new abstraction
|
||||
|
||||
Every exported type, interface, or wrapper is a permanent commitment. It must be named, documented, tested, and understood by every future contributor. It creates a new concept in the codebase vocabulary. Before introducing one, verify that the cost is justified by a concrete benefit that cannot be achieved with existing mechanisms.
|
||||
|
||||
## Before you introduce anything new
|
||||
|
||||
Answer these four questions. If writing a PR, include the answers in the description.
|
||||
|
||||
1. **What already exists?** Name the specific type, function, interface, or library that covers this ground today.
|
||||
2. **What does the new abstraction add?** Name the concrete operation, guarantee, or capability. "Cleaner" or "more reusable" are not sufficient; name what the caller can do that it could not do before.
|
||||
3. **What does the new abstraction drop?** If it wraps or mirrors an existing structure, list what it cannot represent. Every gap must be either justified or handled with an explicit error.
|
||||
4. **Who consumes it?** List the call sites. If there is only one producer and one consumer in the same call chain, you likely need a function, not a type.
|
||||
|
||||
## Rules
|
||||
|
||||
### 1. Prefer functions over types
|
||||
|
||||
If a piece of logic has one input and one output, write a function. Do not create a struct to hold intermediate state that is built in one place and read in one place. A function is easier to test, easier to inline, and does not expand the vocabulary of the codebase.
|
||||
|
||||
```go
|
||||
// Prefer this:
|
||||
func ConvertConfig(src ExternalConfig) (InternalConfig, error)
|
||||
|
||||
// Over this:
|
||||
type ConfigAdapter struct { ... }
|
||||
func NewConfigAdapter(src ExternalConfig) *ConfigAdapter
|
||||
func (a *ConfigAdapter) ToInternal() (InternalConfig, error)
|
||||
```
|
||||
|
||||
The two-step version is only justified when `ConfigAdapter` has multiple distinct consumers that use it in different ways.
|
||||
|
||||
### 2. Do not duplicate structures you do not own
|
||||
|
||||
When a library or external package produces a structured output, operate on that output directly. Do not create a parallel type that mirrors a subset of its fields.
|
||||
|
||||
A partial copy will:
|
||||
- **Silently lose data** when the source has fields or variants the copy does not account for.
|
||||
- **Drift** when the source evolves and the copy is not updated in lockstep.
|
||||
- **Add a conversion step** that doubles the code surface and the opportunity for bugs.
|
||||
|
||||
If you need to shield consumers from a dependency, define a narrow interface over the dependency's type rather than copying its shape into a new struct.
|
||||
|
||||
### 3. Never silently discard input
|
||||
|
||||
If your code receives structured input and cannot handle part of it, return an error. Do not silently return nil, skip the element, or produce a partial result. Silent data loss is the hardest class of bug to detect because the code appears to work, it just produces wrong results.
|
||||
|
||||
```go
|
||||
// Wrong: silently ignores the unrecognized case.
|
||||
default:
|
||||
return nil
|
||||
|
||||
// Right: makes the gap visible.
|
||||
default:
|
||||
return nil, fmt.Errorf("unsupported %T value: %v", v, v)
|
||||
```
|
||||
|
||||
This applies broadly: type switches, format conversions, data migrations, enum mappings, configuration parsing. Anywhere a `default` or `else` branch can swallow input, it should surface an error instead.
|
||||
|
||||
### 4. Do not expose methods that lose information
|
||||
|
||||
A method on a structured type should not strip meaning from the structure it belongs to. If a caller needs to iterate over elements for a specific purpose (validation, aggregation, logging), write that logic as a standalone function that operates on the structure with full context, rather than adding a method that returns a reduced view.
|
||||
|
||||
```go
|
||||
// Problematic: callers cannot distinguish how items were related.
|
||||
func (o *Order) AllLineItems() []LineItem { ... }
|
||||
|
||||
// Better: the validation logic operates on the full structure.
|
||||
func ValidateOrder(o *Order) error { ... }
|
||||
```
|
||||
|
||||
Public methods shape how a type is used. Once a lossy accessor exists, callers will depend on it, and the lost information becomes unrecoverable at those call sites.
|
||||
|
||||
### 5. Interfaces should be discovered, not predicted
|
||||
|
||||
Do not define an interface before you have at least two concrete implementations that need it. An interface with one implementation is not abstraction; it is indirection that makes it harder to navigate from call site to implementation.
|
||||
|
||||
The exception is interfaces required for testing (e.g., for mocking an external dependency). In that case, define the interface in the **consuming** package, not the providing package, following the Go convention of [accepting interfaces and returning structs](https://go.dev/wiki/CodeReviewComments#interfaces).
|
||||
|
||||
### 6. Wrappers must add semantics, not just rename
|
||||
|
||||
A wrapper type is justified when it adds meaning, validation, or invariants that the underlying type does not carry. It is not justified when it merely renames fields or reorganizes the same data into a different shape.
|
||||
|
||||
```go
|
||||
// Justified: adds validation that the underlying string does not carry.
|
||||
type OrgID struct{ value string }
|
||||
func NewOrgID(s string) (OrgID, error) { /* validates format */ }
|
||||
|
||||
// Not justified: renames fields with no new invariant or behavior.
|
||||
type UserInfo struct {
|
||||
Name string // same as source.Name
|
||||
Email string // same as source.Email
|
||||
}
|
||||
```
|
||||
|
||||
Ask: what does the wrapper guarantee that the underlying type does not? If the answer is nothing, use the underlying type directly.
|
||||
|
||||
## When a new type IS warranted
|
||||
|
||||
A new type earns its place when it meets **at least one** of these criteria:
|
||||
|
||||
- **Serialization boundary**: It must be persisted, sent over the wire, or written to config. The source type is unsuitable (unexported fields, function pointers, cycles).
|
||||
- **Invariant enforcement**: The constructor or methods enforce constraints that raw data does not carry (e.g., non-empty, validated format, bounded range).
|
||||
- **Multiple distinct consumers**: Three or more call sites use the type in meaningfully different ways. The type is the shared vocabulary between them.
|
||||
- **Dependency firewall**: The type lives in a lightweight package so that consumers avoid importing a heavy dependency.
|
||||
|
||||
## What should I remember?
|
||||
|
||||
- A function is almost always simpler than a type. Start with a function; promote to a type only when you have evidence of need.
|
||||
- Never silently drop data. If you cannot handle it, error.
|
||||
- If your new type mirrors an existing one, you need a strong reason beyond "nicer to work with".
|
||||
- If your type has one producer and one consumer, it is indirection, not abstraction.
|
||||
- Interfaces come from need (multiple implementations), not from prediction.
|
||||
- When in doubt, do not add it. It is easier to add an abstraction later when the need is clear than to remove one after it has spread through the codebase.
|
||||
|
||||
## Further reading
|
||||
|
||||
These works and our own lessions shaped the above guidelines
|
||||
|
||||
- [The Wrong Abstraction](https://sandimetz.com/blog/2016/1/20/the-wrong-abstraction) - Sandi Metz. The wrong abstraction is worse than duplication. If you find yourself passing parameters and adding conditional paths through shared code, inline it back into every caller and let the duplication show you what the right abstraction is.
|
||||
- [Write code that is easy to delete, not easy to extend](https://programmingisterrible.com/post/139222674273/write-code-that-is-easy-to-delete-not-easy-to) - tef. Every abstraction is a bet on the future. Optimize for how cheaply you can remove code when the bet is wrong, not for how easily you can extend it when the bet is right.
|
||||
- [Goodbye, Clean Code](https://overreacted.io/goodbye-clean-code/) - Dan Abramov. A refactoring that removes duplication can look cleaner while making the code harder to change. Clean-looking and easy-to-change are not the same thing.
|
||||
- [A Philosophy of Software Design](https://www.amazon.com/Philosophy-Software-Design-John-Ousterhout/dp/1732102201) - John Ousterhout. Good abstractions are deep: simple interface, complex implementation. A "false abstraction" omits important details while appearing simple, and is worse than no abstraction at all. ([Summary by Pragmatic Engineer](https://blog.pragmaticengineer.com/a-philosophy-of-software-design-review/))
|
||||
- [Simplicity is Complicated](https://go.dev/talks/2015/simplicity-is-complicated.slide) - Rob Pike. Go-specific. Fewer orthogonal concepts that compose predictably beat many overlapping ones. Features were left out of Go deliberately; the same discipline applies to your own code.
|
||||
@@ -10,11 +10,13 @@ We adhere to three primary style guides as our foundation:
|
||||
|
||||
We **recommend** (almost enforce) reviewing these guides before contributing to the codebase. They provide valuable insights into writing idiomatic Go code and will help you understand our approach to backend development. In addition, we have a few additional rules that make certain areas stricter than the above which can be found in area-specific files in this package:
|
||||
|
||||
- [Packages](packages.md) — Naming, layout, and conventions for `pkg/` packages
|
||||
- [Errors](errors.md) — Structured error handling
|
||||
- [Handler](handler.md) — Writing HTTP handlers and OpenAPI integration
|
||||
- [Endpoint](endpoint.md) — Endpoint conventions
|
||||
- [SQL](sql.md) — Database query patterns
|
||||
- [Provider](provider.md) — Provider pattern
|
||||
- [Integration](integration.md) — Integration conventions
|
||||
- [Flagger](flagger.md) — Feature flag conventions
|
||||
- [Abstractions](abstractions.md) - When to introduce new types and intermediate representations
|
||||
- [Errors](errors.md) - Structured error handling
|
||||
- [Endpoint](endpoint.md) - HTTP endpoint patterns
|
||||
- [Flagger](flagger.md) - Feature flag patterns
|
||||
- [Handler](handler.md) - HTTP handler patterns
|
||||
- [Integration](integration.md) - Integration testing
|
||||
- [Provider](provider.md) - Dependency injection and provider patterns
|
||||
- [Packages](packages.md) - Naming, layout, and conventions for `pkg/` packages
|
||||
- [Service](service.md) - Managed service lifecycle with `factory.Service`
|
||||
- [SQL](sql.md) - Database and SQL patterns
|
||||
|
||||
269
docs/contributing/go/service.md
Normal file
269
docs/contributing/go/service.md
Normal file
@@ -0,0 +1,269 @@
|
||||
# Service
|
||||
|
||||
A service is a component with a managed lifecycle: it starts, runs for the lifetime of the application, and stops gracefully.
|
||||
|
||||
Services are distinct from [providers](provider.md). A provider adapts an external dependency behind an interface. A service has a managed lifecycle that is tied to the lifetime of the application.
|
||||
|
||||
## When do you need a service?
|
||||
|
||||
You need a service when your component needs to do work that outlives a single method call:
|
||||
|
||||
- **Periodic work**: polling an external system, garbage-collecting expired data, syncing state on an interval.
|
||||
- **Graceful shutdown**: holding resources (connections, caches, buffers) that must be flushed or closed before the process exits.
|
||||
- **Blocking on readiness**: waiting for an external dependency to become available before the application can proceed.
|
||||
|
||||
If your component only responds to calls and holds no state that requires cleanup, it is a provider, not a service. If it does both (responds to calls *and* needs a lifecycle), embed `factory.Service` in the provider interface; see [How to create a service](#how-to-create-a-service).
|
||||
|
||||
## The interface
|
||||
|
||||
The `factory.Service` interface in `pkg/factory/service.go` defines two methods:
|
||||
|
||||
```go
|
||||
type Service interface {
|
||||
// Starts a service. It should block and should not return until the service is stopped or it fails.
|
||||
Start(context.Context) error
|
||||
// Stops a service.
|
||||
Stop(context.Context) error
|
||||
}
|
||||
```
|
||||
|
||||
`Start` **must block**. It should not return until the service is stopped (returning `nil`) or something goes wrong (returning an error). If `Start` returns an error, the entire application shuts down.
|
||||
|
||||
`Stop` should cause `Start` to unblock and return. It must be safe to call from a different goroutine than the one running `Start`.
|
||||
|
||||
## Shutdown coordination
|
||||
|
||||
Every service uses a `stopC chan struct{}` to coordinate shutdown:
|
||||
|
||||
- **Constructor**: `stopC: make(chan struct{})`
|
||||
- **Start**: blocks on `<-stopC` (or uses it in a `select` loop)
|
||||
- **Stop**: `close(stopC)` to unblock `Start`
|
||||
|
||||
This is the standard pattern. Do not use `context.WithCancel` or other mechanisms for service-level shutdown coordination. See the examples in the next section.
|
||||
|
||||
## Service shapes
|
||||
|
||||
Two shapes recur across the codebase (these are not exhaustive, if a new shape is needed, bring it up for discussion before going ahead with the implementation), implemented by convention rather than base classes.
|
||||
|
||||
### Idle service
|
||||
|
||||
The service does work during startup or shutdown but has nothing to do while running. `Start` blocks on `<-stopC`. `Stop` closes `stopC` and optionally does cleanup.
|
||||
|
||||
The JWT tokenizer (`pkg/tokenizer/jwttokenizer/provider.go`) is a good example. It validates and creates tokens on demand via method calls, but has no periodic work to do. It still needs the service lifecycle so the registry can manage its lifetime:
|
||||
|
||||
```go
|
||||
// pkg/tokenizer/jwttokenizer/provider.go
|
||||
|
||||
func (provider *provider) Start(ctx context.Context) error {
|
||||
<-provider.stopC
|
||||
return nil
|
||||
}
|
||||
|
||||
func (provider *provider) Stop(ctx context.Context) error {
|
||||
close(provider.stopC)
|
||||
return nil
|
||||
}
|
||||
```
|
||||
|
||||
The instrumentation SDK (`pkg/instrumentation/sdk.go`) is idle while running but does real cleanup in `Stop` shutting down its OpenTelemetry tracer and meter providers:
|
||||
|
||||
```go
|
||||
// pkg/instrumentation/sdk.go
|
||||
|
||||
func (i *SDK) Start(ctx context.Context) error {
|
||||
<-i.startCh
|
||||
return nil
|
||||
}
|
||||
|
||||
func (i *SDK) Stop(ctx context.Context) error {
|
||||
close(i.startCh)
|
||||
return errors.Join(
|
||||
i.sdk.Shutdown(ctx),
|
||||
i.meterProviderShutdownFunc(ctx),
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
### Scheduled service
|
||||
|
||||
The service runs an operation repeatedly on a fixed interval. `Start` runs a ticker loop with a `select` on `stopC` and the ticker channel.
|
||||
|
||||
The opaque tokenizer (`pkg/tokenizer/opaquetokenizer/provider.go`) garbage-collects expired tokens and flushes cached last-observed-at timestamps to the database on a configurable interval:
|
||||
|
||||
```go
|
||||
// pkg/tokenizer/opaquetokenizer/provider.go
|
||||
|
||||
func (provider *provider) Start(ctx context.Context) error {
|
||||
ticker := time.NewTicker(provider.config.Opaque.GC.Interval)
|
||||
defer ticker.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-provider.stopC:
|
||||
return nil
|
||||
case <-ticker.C:
|
||||
orgs, err := provider.orgGetter.ListByOwnedKeyRange(ctx)
|
||||
if err != nil {
|
||||
provider.settings.Logger().ErrorContext(ctx, "failed to get orgs data", "error", err)
|
||||
continue
|
||||
}
|
||||
|
||||
for _, org := range orgs {
|
||||
if err := provider.gc(ctx, org); err != nil {
|
||||
provider.settings.Logger().ErrorContext(ctx, "failed to garbage collect tokens", "error", err, "org_id", org.ID)
|
||||
}
|
||||
|
||||
if err := provider.flushLastObservedAt(ctx, org); err != nil {
|
||||
provider.settings.Logger().ErrorContext(ctx, "failed to flush tokens", "error", err, "org_id", org.ID)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Its `Stop` does a final gc and flush before returning, so no data is lost on shutdown:
|
||||
|
||||
```go
|
||||
// pkg/tokenizer/opaquetokenizer/provider.go
|
||||
|
||||
func (provider *provider) Stop(ctx context.Context) error {
|
||||
close(provider.stopC)
|
||||
|
||||
orgs, err := provider.orgGetter.ListByOwnedKeyRange(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, org := range orgs {
|
||||
if err := provider.gc(ctx, org); err != nil {
|
||||
provider.settings.Logger().ErrorContext(ctx, "failed to garbage collect tokens", "error", err, "org_id", org.ID)
|
||||
}
|
||||
|
||||
if err := provider.flushLastObservedAt(ctx, org); err != nil {
|
||||
provider.settings.Logger().ErrorContext(ctx, "failed to flush tokens", "error", err, "org_id", org.ID)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
```
|
||||
|
||||
The key points:
|
||||
|
||||
- In the loop, `select` on `stopC` and the ticker. Errors in iterations are logged but do not cause the service to return (which would shut down the application).
|
||||
- Only return an error from `Start` if the failure is unrecoverable.
|
||||
- Use `Stop` to flush or drain any in-memory state before the process exits.
|
||||
|
||||
## How to create a service
|
||||
|
||||
There are two cases: a standalone service and a provider that is also a service.
|
||||
|
||||
### Standalone service
|
||||
|
||||
A standalone service only has the `factory.Service` lifecycle i.e it does not serve as a dependency for other packages. The user reconciliation service is an example.
|
||||
|
||||
1. Define the service interface in your package. Embed `factory.Service`:
|
||||
|
||||
```go
|
||||
// pkg/modules/user/service.go
|
||||
package user
|
||||
|
||||
type Service interface {
|
||||
factory.Service
|
||||
}
|
||||
```
|
||||
|
||||
2. Create the implementation in an `impl` sub-package. Use an unexported struct with an exported constructor that returns the interface:
|
||||
|
||||
```go
|
||||
// pkg/modules/user/impluser/service.go
|
||||
package impluser
|
||||
|
||||
type service struct {
|
||||
settings factory.ScopedProviderSettings
|
||||
// ... dependencies ...
|
||||
stopC chan struct{}
|
||||
}
|
||||
|
||||
func NewService(
|
||||
providerSettings factory.ProviderSettings,
|
||||
// ... dependencies ...
|
||||
) user.Service {
|
||||
return &service{
|
||||
settings: factory.NewScopedProviderSettings(providerSettings, "go.signoz.io/pkg/modules/user"),
|
||||
// ... dependencies ...
|
||||
stopC: make(chan struct{}),
|
||||
}
|
||||
}
|
||||
|
||||
func (s *service) Start(ctx context.Context) error { ... }
|
||||
func (s *service) Stop(ctx context.Context) error { ... }
|
||||
```
|
||||
|
||||
### Provider that is also a service
|
||||
|
||||
Many providers need a managed lifecycle: they poll, sync, or garbage-collect in the background. In this case, embed `factory.Service` in the provider interface. The implementation satisfies both the provider methods and `Start`/`Stop`.
|
||||
|
||||
```go
|
||||
// pkg/tokenizer/tokenizer.go
|
||||
package tokenizer
|
||||
|
||||
type Tokenizer interface {
|
||||
factory.Service
|
||||
CreateToken(context.Context, *authtypes.Identity, map[string]string) (*authtypes.Token, error)
|
||||
GetIdentity(context.Context, string) (*authtypes.Identity, error)
|
||||
// ... other methods ...
|
||||
}
|
||||
```
|
||||
|
||||
The implementation (e.g. `pkg/tokenizer/opaquetokenizer/provider.go`) implements `Start`, `Stop`, and all the provider methods on the same struct. See the [provider guide](provider.md) for how to set up the factory, config, and constructor. The `stopC` channel and `Start`/`Stop` methods follow the same patterns described above.
|
||||
|
||||
## How to wire it up
|
||||
|
||||
Wiring happens in `pkg/signoz/signoz.go`.
|
||||
|
||||
### 1. Instantiate the service
|
||||
|
||||
For a standalone service, call the constructor directly:
|
||||
|
||||
```go
|
||||
userService := impluser.NewService(providerSettings, store, module, orgGetter, authz, config.User.Root)
|
||||
```
|
||||
|
||||
For a provider that is also a service, use `factory.NewProviderFromNamedMap` as described in the [provider guide](provider.md). The returned value already implements `factory.Service`.
|
||||
|
||||
### 2. Register in the registry
|
||||
|
||||
Wrap the service with `factory.NewNamedService` and pass it to `factory.NewRegistry`:
|
||||
|
||||
```go
|
||||
registry, err := factory.NewRegistry(
|
||||
instrumentation.Logger(),
|
||||
// ... other services ...
|
||||
factory.NewNamedService(factory.MustNewName("user"), userService),
|
||||
)
|
||||
```
|
||||
|
||||
The name must be unique across all services. The registry handles the rest:
|
||||
|
||||
- **Start**: launches all services concurrently in goroutines.
|
||||
- **Wait**: blocks until a service returns an error, the context is cancelled, or a SIGINT/SIGTERM is received. Any service error triggers application shutdown.
|
||||
- **Stop**: stops all services concurrently, collects errors via `errors.Join`.
|
||||
|
||||
You do not call `Start` or `Stop` on individual services. The registry does it.
|
||||
|
||||
## What should I remember?
|
||||
|
||||
- A service has a managed lifecycle: `Start` blocks, `Stop` unblocks it.
|
||||
- Use `stopC chan struct{}` for shutdown coordination. `close(stopC)` in `Stop`, `<-stopC` in `Start`.
|
||||
- Service shapes: idle (block on `stopC`) and scheduled (ticker loop with `select`).
|
||||
- Unexported struct, exported `NewService` constructor returning the interface.
|
||||
- First constructor parameter is `factory.ProviderSettings`. Create scoped settings with `factory.NewScopedProviderSettings`.
|
||||
- Register in `factory.Registry` with `factory.NewNamedService`. The registry starts and stops everything.
|
||||
- Only return an error from `Start` if the failure is unrecoverable. Log and continue for transient errors in polling loops.
|
||||
|
||||
## Further reading
|
||||
|
||||
- [Google Guava - ServiceExplained](https://github.com/google/guava/wiki/ServiceExplained) - the service lifecycle pattern takes inspiration from this
|
||||
- [OpenTelemetry Collector](https://github.com/open-telemetry/opentelemetry-collector) - Worth studying for its approach to building composable components
|
||||
@@ -19,6 +19,8 @@ const config: Config.InitialOptions = {
|
||||
'^.*/useSafeNavigate$': USE_SAFE_NAVIGATE_MOCK_PATH,
|
||||
'^@signozhq/icons$':
|
||||
'<rootDir>/node_modules/@signozhq/icons/dist/index.esm.js',
|
||||
'^react-syntax-highlighter/dist/esm/(.*)$':
|
||||
'<rootDir>/node_modules/react-syntax-highlighter/dist/cjs/$1',
|
||||
},
|
||||
globals: {
|
||||
extensionsToTreatAsEsm: ['.ts'],
|
||||
|
||||
@@ -52,6 +52,7 @@
|
||||
"@signozhq/combobox": "0.0.2",
|
||||
"@signozhq/command": "0.0.0",
|
||||
"@signozhq/design-tokens": "2.1.1",
|
||||
"@signozhq/dialog": "0.0.2",
|
||||
"@signozhq/icons": "0.1.0",
|
||||
"@signozhq/input": "0.0.2",
|
||||
"@signozhq/popover": "0.0.0",
|
||||
@@ -214,7 +215,7 @@
|
||||
"@types/react-redux": "^7.1.11",
|
||||
"@types/react-resizable": "3.0.3",
|
||||
"@types/react-router-dom": "^5.1.6",
|
||||
"@types/react-syntax-highlighter": "15.5.7",
|
||||
"@types/react-syntax-highlighter": "15.5.13",
|
||||
"@types/redux-mock-store": "1.0.4",
|
||||
"@types/styled-components": "^5.1.4",
|
||||
"@types/uuid": "^8.3.1",
|
||||
@@ -289,4 +290,4 @@
|
||||
"on-headers": "^1.1.0",
|
||||
"tmp": "0.2.4"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
13
frontend/scripts/extract-md-languages.sh
Executable file
13
frontend/scripts/extract-md-languages.sh
Executable file
@@ -0,0 +1,13 @@
|
||||
#!/usr/bin/env bash
|
||||
# Extracts unique fenced code block language identifiers from all .md files under frontend/src/
|
||||
# Usage: bash frontend/scripts/extract-md-languages.sh
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
SRC_DIR="$SCRIPT_DIR/../src"
|
||||
|
||||
grep -roh '```[a-zA-Z0-9_+-]*' "$SRC_DIR" --include='*.md' \
|
||||
| sed 's/^```//' \
|
||||
| grep -v '^$' \
|
||||
| sort -u
|
||||
35
frontend/scripts/validate-md-languages.sh
Executable file
35
frontend/scripts/validate-md-languages.sh
Executable file
@@ -0,0 +1,35 @@
|
||||
#!/usr/bin/env bash
|
||||
# Validates that all fenced code block languages used in .md files are registered
|
||||
# in the syntax highlighter.
|
||||
# Usage: bash frontend/scripts/validate-md-languages.sh
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
SYNTAX_HIGHLIGHTER="$SCRIPT_DIR/../src/components/MarkdownRenderer/syntaxHighlighter.ts"
|
||||
|
||||
# Get all languages used in .md files
|
||||
md_languages=$("$SCRIPT_DIR/extract-md-languages.sh")
|
||||
|
||||
# Get all registered languages from syntaxHighlighter.ts
|
||||
registered_languages=$(grep -oP "registerLanguage\('\K[^']+" "$SYNTAX_HIGHLIGHTER" | sort -u)
|
||||
|
||||
missing_languages=()
|
||||
|
||||
for lang in $md_languages; do
|
||||
if ! echo "$registered_languages" | grep -qx "$lang"; then
|
||||
missing_languages+=("$lang")
|
||||
fi
|
||||
done
|
||||
|
||||
if [ ${#missing_languages[@]} -gt 0 ]; then
|
||||
echo "Error: The following languages are used in .md files but not registered in syntaxHighlighter.ts:"
|
||||
for lang in "${missing_languages[@]}"; do
|
||||
echo " - $lang"
|
||||
done
|
||||
echo ""
|
||||
echo "Please add them to: frontend/src/components/MarkdownRenderer/syntaxHighlighter.ts"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "All markdown code block languages are registered in syntaxHighlighter.ts"
|
||||
@@ -165,11 +165,6 @@ export const MySettings = Loadable(
|
||||
() => import(/* webpackChunkName: "All MySettings" */ 'pages/Settings'),
|
||||
);
|
||||
|
||||
export const CustomDomainSettings = Loadable(
|
||||
() =>
|
||||
import(/* webpackChunkName: "Custom Domain Settings" */ 'pages/Settings'),
|
||||
);
|
||||
|
||||
export const Logs = Loadable(
|
||||
() => import(/* webpackChunkName: "Logs" */ 'pages/LogsModulePage'),
|
||||
);
|
||||
|
||||
1
frontend/src/auto-import-registry.d.ts
vendored
1
frontend/src/auto-import-registry.d.ts
vendored
@@ -18,6 +18,7 @@ import '@signozhq/checkbox';
|
||||
import '@signozhq/combobox';
|
||||
import '@signozhq/command';
|
||||
import '@signozhq/design-tokens';
|
||||
import '@signozhq/dialog';
|
||||
import '@signozhq/icons';
|
||||
import '@signozhq/input';
|
||||
import '@signozhq/popover';
|
||||
|
||||
@@ -2,13 +2,12 @@
|
||||
|
||||
import ReactMarkdown from 'react-markdown';
|
||||
import { CodeProps } from 'react-markdown/lib/ast-to-react';
|
||||
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter';
|
||||
import { a11yDark } from 'react-syntax-highlighter/dist/cjs/styles/prism';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import { isEmpty } from 'lodash-es';
|
||||
import rehypeRaw from 'rehype-raw';
|
||||
|
||||
import CodeCopyBtn from './CodeCopyBtn/CodeCopyBtn';
|
||||
import SyntaxHighlighter, { a11yDark } from './syntaxHighlighter';
|
||||
|
||||
interface LinkProps {
|
||||
href: string;
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import { PrismLight as SyntaxHighlighter } from 'react-syntax-highlighter';
|
||||
import bash from 'react-syntax-highlighter/dist/esm/languages/prism/bash';
|
||||
import docker from 'react-syntax-highlighter/dist/esm/languages/prism/docker';
|
||||
import elixir from 'react-syntax-highlighter/dist/esm/languages/prism/elixir';
|
||||
import go from 'react-syntax-highlighter/dist/esm/languages/prism/go';
|
||||
import javascript from 'react-syntax-highlighter/dist/esm/languages/prism/javascript';
|
||||
import json from 'react-syntax-highlighter/dist/esm/languages/prism/json';
|
||||
import jsx from 'react-syntax-highlighter/dist/esm/languages/prism/jsx';
|
||||
import rust from 'react-syntax-highlighter/dist/esm/languages/prism/rust';
|
||||
import swift from 'react-syntax-highlighter/dist/esm/languages/prism/swift';
|
||||
import tsx from 'react-syntax-highlighter/dist/esm/languages/prism/tsx';
|
||||
import typescript from 'react-syntax-highlighter/dist/esm/languages/prism/typescript';
|
||||
import yaml from 'react-syntax-highlighter/dist/esm/languages/prism/yaml';
|
||||
import a11yDark from 'react-syntax-highlighter/dist/esm/styles/prism/a11y-dark';
|
||||
|
||||
SyntaxHighlighter.registerLanguage('bash', bash);
|
||||
SyntaxHighlighter.registerLanguage('docker', docker);
|
||||
SyntaxHighlighter.registerLanguage('dockerfile', docker);
|
||||
SyntaxHighlighter.registerLanguage('elixir', elixir);
|
||||
SyntaxHighlighter.registerLanguage('go', go);
|
||||
SyntaxHighlighter.registerLanguage('javascript', javascript);
|
||||
SyntaxHighlighter.registerLanguage('js', javascript);
|
||||
SyntaxHighlighter.registerLanguage('json', json);
|
||||
SyntaxHighlighter.registerLanguage('jsx', jsx);
|
||||
SyntaxHighlighter.registerLanguage('rust', rust);
|
||||
SyntaxHighlighter.registerLanguage('swift', swift);
|
||||
SyntaxHighlighter.registerLanguage('ts', typescript);
|
||||
SyntaxHighlighter.registerLanguage('tsx', tsx);
|
||||
SyntaxHighlighter.registerLanguage('typescript', typescript);
|
||||
SyntaxHighlighter.registerLanguage('yaml', yaml);
|
||||
SyntaxHighlighter.registerLanguage('yml', yaml);
|
||||
|
||||
export default SyntaxHighlighter;
|
||||
export { a11yDark };
|
||||
@@ -38,7 +38,6 @@ const ROUTES = {
|
||||
SETTINGS: '/settings',
|
||||
MY_SETTINGS: '/settings/my-settings',
|
||||
ORG_SETTINGS: '/settings/org-settings',
|
||||
CUSTOM_DOMAIN_SETTINGS: '/settings/custom-domain-settings',
|
||||
API_KEYS: '/settings/api-keys',
|
||||
INGESTION_SETTINGS: '/settings/ingestion-settings',
|
||||
SOMETHING_WENT_WRONG: '/something-went-wrong',
|
||||
|
||||
@@ -0,0 +1,200 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Button } from '@signozhq/button';
|
||||
import { Color } from '@signozhq/design-tokens';
|
||||
import { DialogWrapper } from '@signozhq/dialog';
|
||||
import { Input } from '@signozhq/input';
|
||||
import { RenderErrorResponseDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { AxiosError } from 'axios';
|
||||
import LaunchChatSupport from 'components/LaunchChatSupport/LaunchChatSupport';
|
||||
import { AlertCircle, CheckCircle2, Loader2 } from 'lucide-react';
|
||||
|
||||
interface CustomDomainEditModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
customDomainSubdomain?: string;
|
||||
dnsSuffix: string;
|
||||
isLoading: boolean;
|
||||
updateDomainError: AxiosError<RenderErrorResponseDTO> | null;
|
||||
onClearError: () => void;
|
||||
onSubmit: (subdomain: string) => void;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line sonarjs/cognitive-complexity
|
||||
export default function CustomDomainEditModal({
|
||||
isOpen,
|
||||
onClose,
|
||||
customDomainSubdomain,
|
||||
dnsSuffix,
|
||||
isLoading,
|
||||
updateDomainError,
|
||||
onClearError,
|
||||
onSubmit,
|
||||
}: CustomDomainEditModalProps): JSX.Element {
|
||||
const [value, setValue] = useState(customDomainSubdomain ?? '');
|
||||
const [validationError, setValidationError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
setValue(customDomainSubdomain ?? '');
|
||||
}
|
||||
}, [isOpen, customDomainSubdomain]);
|
||||
|
||||
const handleClose = (): void => {
|
||||
setValidationError(null);
|
||||
onClearError();
|
||||
onClose();
|
||||
};
|
||||
|
||||
const handleChange = (e: React.ChangeEvent<HTMLInputElement>): void => {
|
||||
setValue(e.target.value);
|
||||
setValidationError(null);
|
||||
onClearError();
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>): void => {
|
||||
if (e.key === 'Enter') {
|
||||
handleSubmit();
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = (): void => {
|
||||
if (!value) {
|
||||
setValidationError('This field is required');
|
||||
return;
|
||||
}
|
||||
if (value.length < 3) {
|
||||
setValidationError('Minimum 3 characters required');
|
||||
return;
|
||||
}
|
||||
onSubmit(value);
|
||||
};
|
||||
|
||||
const is409 = updateDomainError?.status === 409;
|
||||
|
||||
const apiErrorMessage =
|
||||
(updateDomainError?.response?.data as RenderErrorResponseDTO)?.error
|
||||
?.message ?? null;
|
||||
|
||||
const errorMessage =
|
||||
validationError ??
|
||||
(is409
|
||||
? apiErrorMessage ??
|
||||
"You've already updated the custom domain once today. Please contact support."
|
||||
: apiErrorMessage);
|
||||
|
||||
const hasError = Boolean(errorMessage);
|
||||
|
||||
const statusIcon = ((): JSX.Element => {
|
||||
if (isLoading) {
|
||||
return <Loader2 size={16} className="animate-spin edit-modal-status-icon" />;
|
||||
}
|
||||
|
||||
if (hasError) {
|
||||
return <AlertCircle size={16} color={Color.BG_CHERRY_500} />;
|
||||
}
|
||||
|
||||
return <CheckCircle2 size={16} color={Color.BG_FOREST_500} />;
|
||||
})();
|
||||
|
||||
return (
|
||||
<DialogWrapper
|
||||
className="edit-workspace-modal"
|
||||
title="Edit Workspace Link"
|
||||
open={isOpen}
|
||||
onOpenChange={(open: boolean): void => {
|
||||
if (!open) {
|
||||
handleClose();
|
||||
}
|
||||
}}
|
||||
width="base"
|
||||
>
|
||||
<div className="edit-workspace-modal-content">
|
||||
<p className="edit-modal-description">
|
||||
Enter your preferred subdomain to create a unique URL for your team. Need
|
||||
help?{' '}
|
||||
<a
|
||||
href="https://signoz.io/support"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="edit-modal-link"
|
||||
>
|
||||
Contact support.
|
||||
</a>
|
||||
</p>
|
||||
|
||||
<div className="edit-modal-field">
|
||||
<label
|
||||
htmlFor="workspace-url-input"
|
||||
className={`edit-modal-label${
|
||||
hasError ? ' edit-modal-label--error' : ''
|
||||
}`}
|
||||
>
|
||||
Workspace URL
|
||||
</label>
|
||||
|
||||
<div
|
||||
className={`edit-modal-input-wrapper${
|
||||
hasError ? ' edit-modal-input-wrapper--error' : ''
|
||||
}`}
|
||||
>
|
||||
<div className="edit-modal-input-field">
|
||||
{statusIcon}
|
||||
<Input
|
||||
id="workspace-url-input"
|
||||
aria-describedby="workspace-url-helper"
|
||||
aria-invalid={hasError}
|
||||
value={value}
|
||||
onChange={handleChange}
|
||||
onKeyDown={handleKeyDown}
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
<div className="edit-modal-input-suffix">{dnsSuffix}</div>
|
||||
</div>
|
||||
|
||||
<span
|
||||
id="workspace-url-helper"
|
||||
className={`edit-modal-helper${
|
||||
hasError ? ' edit-modal-helper--error' : ''
|
||||
}`}
|
||||
>
|
||||
{hasError
|
||||
? errorMessage
|
||||
: "To help you easily explore SigNoz, we've selected a tenant sub domain name for you."}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="edit-modal-note">
|
||||
<span className="edit-modal-note-emoji">🚧</span>
|
||||
<span className="edit-modal-note-text">
|
||||
Note that your previous URL still remains accessible. Your access
|
||||
credentials for the new URL remain the same.
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="edit-modal-footer">
|
||||
{is409 ? (
|
||||
<LaunchChatSupport
|
||||
attributes={{ screen: 'Custom Domain Settings' }}
|
||||
eventName="Custom Domain Settings: Facing Issues Updating Custom Domain"
|
||||
message="Hi Team, I need help with updating custom domain"
|
||||
buttonText="Contact Support"
|
||||
/>
|
||||
) : (
|
||||
<Button
|
||||
variant="solid"
|
||||
size="md"
|
||||
color="primary"
|
||||
className="edit-modal-apply-btn"
|
||||
onClick={handleSubmit}
|
||||
disabled={isLoading}
|
||||
loading={isLoading}
|
||||
>
|
||||
Apply Changes
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</DialogWrapper>
|
||||
);
|
||||
}
|
||||
@@ -1,262 +1,460 @@
|
||||
.custom-domain-settings-container {
|
||||
margin-top: 24px;
|
||||
.beacon {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
flex-shrink: 0;
|
||||
|
||||
&::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 1px;
|
||||
border-radius: 50%;
|
||||
background: rgba(78, 116, 248, 0.2);
|
||||
}
|
||||
|
||||
&::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 5px;
|
||||
top: 5px;
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background: var(--primary);
|
||||
}
|
||||
}
|
||||
|
||||
.custom-domain-card {
|
||||
width: 100%;
|
||||
max-width: 768px;
|
||||
border-radius: 4px;
|
||||
border: 1px solid var(--l2-border);
|
||||
background: var(--l2-background);
|
||||
overflow: hidden;
|
||||
|
||||
&--loading {
|
||||
padding: var(--padding-3);
|
||||
}
|
||||
|
||||
.custom-domain-card-top {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
padding: var(--padding-3);
|
||||
gap: var(--spacing-6);
|
||||
|
||||
.custom-domain-edit-button {
|
||||
border: 1px solid var(--l3-border);
|
||||
background: var(--l3-background);
|
||||
|
||||
&:hover {
|
||||
background: var(--l3-background-hover);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.custom-domain-card-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-6);
|
||||
}
|
||||
|
||||
.custom-domain-card-name-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-5);
|
||||
}
|
||||
|
||||
.custom-domain-card-org-name {
|
||||
color: var(--l1-foreground);
|
||||
font-size: var(--paragraph-base-500-font-size);
|
||||
font-weight: var(--paragraph-base-500-font-weight);
|
||||
line-height: var(--paragraph-base-500-line-height);
|
||||
letter-spacing: var(--paragraph-base-500-letter-spacing);
|
||||
}
|
||||
|
||||
.custom-domain-card-meta-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-10);
|
||||
padding-left: 26px;
|
||||
}
|
||||
|
||||
.custom-domain-card-meta-timezone {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-3);
|
||||
color: var(--l1-foreground);
|
||||
font-variant-numeric: lining-nums tabular-nums slashed-zero;
|
||||
font-feature-settings: 'dlig' on, 'salt' on;
|
||||
font-family: Inter;
|
||||
font-size: var(--paragraph-small-400-font-size);
|
||||
font-style: normal;
|
||||
font-weight: var(--paragraph-small-400-font-weight);
|
||||
line-height: var(--paragraph-small-400-line-height);
|
||||
letter-spacing: var(--paragraph-small-400-letter-spacing);
|
||||
text-transform: uppercase;
|
||||
|
||||
svg {
|
||||
flex-shrink: 0;
|
||||
color: var(--l1-foreground);
|
||||
}
|
||||
}
|
||||
|
||||
.custom-domain-callout {
|
||||
margin: 0 var(--margin-3) var(--margin-3);
|
||||
font-size: 13px;
|
||||
max-width: 742px;
|
||||
--callout-background: var(--primary);
|
||||
--callout-border-color: var(--callout-primary-border);
|
||||
--callout-icon-color: var(--primary);
|
||||
--callout-title-color: var(--callout-primary-title);
|
||||
}
|
||||
|
||||
.custom-domain-card-divider {
|
||||
height: 1px;
|
||||
background: var(--l2-border);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.custom-domain-card-bottom {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-5);
|
||||
padding: var(--padding-3);
|
||||
}
|
||||
|
||||
.custom-domain-card-license {
|
||||
color: var(--l1-foreground);
|
||||
font-size: 13px;
|
||||
line-height: var(--line-height-20);
|
||||
letter-spacing: -0.07px;
|
||||
}
|
||||
|
||||
.custom-domain-plan-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 0 2px;
|
||||
border-radius: 2px;
|
||||
background: var(--l2-background);
|
||||
color: var(--l2-foreground);
|
||||
font-family: 'SF Mono', 'Fira Code', monospace;
|
||||
font-size: 13px;
|
||||
line-height: var(--line-height-20);
|
||||
}
|
||||
}
|
||||
|
||||
.workspace-url-trigger {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-3);
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0;
|
||||
cursor: pointer;
|
||||
color: var(--l1-foreground);
|
||||
font-size: var(--font-size-xs);
|
||||
line-height: var(--line-height-18);
|
||||
letter-spacing: -0.06px;
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
svg {
|
||||
flex-shrink: 0;
|
||||
color: var(--l2-foreground);
|
||||
}
|
||||
}
|
||||
|
||||
.workspace-url-dropdown {
|
||||
border-radius: 4px;
|
||||
border: 1px solid var(--l1-border);
|
||||
background: var(--l1-background);
|
||||
box-shadow: 4px 10px 16px 2px rgba(0, 0, 0, 0.2);
|
||||
padding: var(--padding-2) 0;
|
||||
min-width: 200px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.workspace-url-dropdown-header {
|
||||
color: var(--l2-foreground);
|
||||
font-size: 13px;
|
||||
line-height: var(--line-height-20);
|
||||
letter-spacing: -0.07px;
|
||||
padding: 0 var(--padding-3) var(--padding-2);
|
||||
}
|
||||
|
||||
.workspace-url-dropdown-divider {
|
||||
height: 1px;
|
||||
background: var(--l1-border);
|
||||
margin-bottom: var(--margin-1);
|
||||
}
|
||||
|
||||
.workspace-url-dropdown-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 24px;
|
||||
justify-content: space-between;
|
||||
gap: var(--spacing-4);
|
||||
padding: 5px var(--padding-3);
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
transition: background 0.15s ease;
|
||||
|
||||
&:hover {
|
||||
background: var(--l1-background-hover);
|
||||
|
||||
.workspace-url-dropdown-item-label {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.workspace-url-dropdown-item-external {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
&--active {
|
||||
background: var(--l1-background-hover);
|
||||
}
|
||||
}
|
||||
|
||||
.workspace-url-dropdown-item-external {
|
||||
color: var(--l2-foreground);
|
||||
flex-shrink: 0;
|
||||
opacity: 0.5;
|
||||
transition: opacity 0.15s ease;
|
||||
}
|
||||
|
||||
.workspace-url-dropdown-item-label {
|
||||
font-size: 13px;
|
||||
line-height: var(--line-height-20);
|
||||
letter-spacing: -0.07px;
|
||||
color: var(--l2-foreground);
|
||||
|
||||
.workspace-url-dropdown-item--active & {
|
||||
color: var(--l1-foreground);
|
||||
}
|
||||
}
|
||||
|
||||
.workspace-url-dropdown-item-check {
|
||||
color: var(--l1-foreground);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.edit-workspace-modal-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-12);
|
||||
}
|
||||
|
||||
// Description
|
||||
.edit-modal-description {
|
||||
margin: 0;
|
||||
color: var(--l1-foreground);
|
||||
font-size: 13px;
|
||||
line-height: var(--line-height-20);
|
||||
letter-spacing: -0.07px;
|
||||
}
|
||||
|
||||
.edit-modal-link {
|
||||
color: var(--primary);
|
||||
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
|
||||
// Input field group
|
||||
.edit-modal-field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-4);
|
||||
}
|
||||
|
||||
.edit-modal-label {
|
||||
color: var(--l2-foreground);
|
||||
font-size: var(--paragraph-base-500-font-size);
|
||||
font-weight: var(--paragraph-base-500-font-weight);
|
||||
line-height: var(--paragraph-base-500-line-height);
|
||||
|
||||
&--error {
|
||||
color: var(--destructive);
|
||||
}
|
||||
}
|
||||
|
||||
.edit-modal-input-wrapper {
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
|
||||
.edit-modal-input-field {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-3);
|
||||
height: 44px;
|
||||
padding: 6px var(--padding-3);
|
||||
background: var(--l1-background);
|
||||
border: 1px solid var(--l1-border);
|
||||
border-right: none;
|
||||
border-radius: 2px 0 0 2px;
|
||||
|
||||
svg {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
input {
|
||||
flex: 1;
|
||||
width: 100%;
|
||||
height: auto;
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-radius: 0;
|
||||
outline: none;
|
||||
box-shadow: none;
|
||||
color: var(--l1-foreground);
|
||||
font-size: 13px;
|
||||
line-height: var(--line-height-20);
|
||||
padding: 0;
|
||||
|
||||
&:focus,
|
||||
&:focus-visible {
|
||||
outline: none;
|
||||
box-shadow: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.edit-modal-input-suffix {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 6px var(--padding-3);
|
||||
background: var(--l2-background);
|
||||
border: 1px solid var(--l1-border);
|
||||
border-left: none;
|
||||
border-radius: 0 2px 2px 0;
|
||||
color: var(--l2-foreground);
|
||||
font-size: 13px;
|
||||
line-height: var(--line-height-20);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.edit-modal-helper {
|
||||
color: var(--l2-foreground);
|
||||
font-size: var(--font-size-xs);
|
||||
line-height: var(--line-height-20);
|
||||
|
||||
&--error {
|
||||
color: var(--destructive);
|
||||
}
|
||||
}
|
||||
|
||||
.edit-modal-status-icon {
|
||||
color: var(--l2-foreground);
|
||||
}
|
||||
|
||||
.edit-modal-note {
|
||||
display: flex;
|
||||
gap: var(--spacing-6);
|
||||
align-items: flex-start;
|
||||
padding: var(--padding-3);
|
||||
border-radius: 4px;
|
||||
background: var(--l2-background);
|
||||
}
|
||||
|
||||
.edit-modal-note-emoji {
|
||||
font-size: 16px;
|
||||
line-height: var(--line-height-20);
|
||||
flex-shrink: 0;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.edit-modal-note-text {
|
||||
color: var(--l2-foreground);
|
||||
font-size: 13px;
|
||||
line-height: var(--line-height-20);
|
||||
letter-spacing: -0.07px;
|
||||
}
|
||||
|
||||
.edit-modal-footer {
|
||||
.facing-issue-button {
|
||||
width: 100%;
|
||||
|
||||
.periscope-btn {
|
||||
width: 100%;
|
||||
border-radius: 2px;
|
||||
background: var(--primary);
|
||||
border: none;
|
||||
color: var(--primary-foreground);
|
||||
font-size: var(--paragraph-base-500-font-size);
|
||||
font-weight: var(--paragraph-base-500-font-weight);
|
||||
line-height: var(--paragraph-base-500-line-height);
|
||||
height: 36px;
|
||||
|
||||
.ant-btn-icon {
|
||||
display: none;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background: var(--primary) !important;
|
||||
border: none !important;
|
||||
color: var(--primary-foreground) !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.edit-modal-apply-btn {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.custom-domain-settings-content {
|
||||
width: calc(100% - 30px);
|
||||
max-width: 736px;
|
||||
.custom-domain-toast {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
padding: 0 16px;
|
||||
height: 40px;
|
||||
width: min(942px, calc(100vw - 32px));
|
||||
border-radius: 4px;
|
||||
background: var(--primary-background);
|
||||
color: var(--bg-base-white);
|
||||
|
||||
.title {
|
||||
color: var(--bg-vanilla-100);
|
||||
font-size: var(--font-size-lg);
|
||||
font-style: normal;
|
||||
font-weight: var(--font-weight-normal);
|
||||
line-height: 28px; /* 155.556% */
|
||||
letter-spacing: -0.09px;
|
||||
}
|
||||
.custom-domain-toast-message {
|
||||
font-size: 13px;
|
||||
line-height: var(--line-height-20);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
color: var(--bg-vanilla-400);
|
||||
font-size: var(--font-size-sm);
|
||||
font-style: normal;
|
||||
font-weight: var(--font-weight-normal);
|
||||
line-height: 20px; /* 142.857% */
|
||||
letter-spacing: -0.07px;
|
||||
.custom-domain-toast-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-4);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.custom-domain-toast-visit-btn {
|
||||
text-decoration: none;
|
||||
background: var(--bg-robin-600);
|
||||
|
||||
&:hover {
|
||||
background: var(--primary-background-hover);
|
||||
}
|
||||
}
|
||||
|
||||
.custom-domain-settings-card {
|
||||
border-radius: 4px;
|
||||
border: 1px solid var(--bg-slate-400);
|
||||
background: var(--bg-ink-400);
|
||||
.custom-domain-toast-dismiss-btn {
|
||||
color: var(--callout-primary-icon);
|
||||
height: 24px;
|
||||
width: 24px;
|
||||
|
||||
.ant-card-body {
|
||||
padding: 12px;
|
||||
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
.custom-domain-settings-content-header {
|
||||
color: var(--bg-vanilla-100);
|
||||
font-size: var(--font-size-sm);
|
||||
font-style: normal;
|
||||
font-weight: var(--font-weight-medium);
|
||||
line-height: 20px; /* 142.857% */
|
||||
letter-spacing: -0.07px;
|
||||
}
|
||||
|
||||
.custom-domain-settings-content-body {
|
||||
margin-top: 12px;
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
|
||||
align-items: flex-end;
|
||||
justify-content: space-between;
|
||||
|
||||
.custom-domain-url-edit-btn {
|
||||
.periscope-btn {
|
||||
border-radius: 2px;
|
||||
border: 1px solid var(--Slate-200, #2c3140);
|
||||
background: var(--Ink-200, #23262e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.custom-domain-urls {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.custom-domain-url {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
cursor: pointer;
|
||||
|
||||
line-height: 24px;
|
||||
padding: 4px 0;
|
||||
}
|
||||
|
||||
.custom-domain-update-status {
|
||||
margin-top: 12px;
|
||||
|
||||
color: var(--bg-robin-400);
|
||||
font-size: 13px;
|
||||
font-style: normal;
|
||||
font-weight: var(--font-weight-medium);
|
||||
line-height: 20px;
|
||||
letter-spacing: -0.07px;
|
||||
border-radius: 4px;
|
||||
border: 1px solid rgba(78, 116, 248, 0.1);
|
||||
background: rgba(78, 116, 248, 0.1);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.custom-domain-settings-modal {
|
||||
.ant-modal-content {
|
||||
border-radius: 4px;
|
||||
border: 1px solid var(--bg-slate-500);
|
||||
background: var(--bg-ink-400);
|
||||
box-shadow: 0px -4px 16px 2px rgba(0, 0, 0, 0.2);
|
||||
padding: 0;
|
||||
|
||||
.ant-modal-header {
|
||||
background: none;
|
||||
border-bottom: 1px solid var(--bg-slate-500);
|
||||
padding: 16px;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.ant-modal-close-x {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.ant-modal-body {
|
||||
padding: 12px 16px;
|
||||
|
||||
.custom-domain-settings-modal-body {
|
||||
margin-bottom: 48px;
|
||||
|
||||
font-size: 13px;
|
||||
font-style: normal;
|
||||
font-weight: var(--font-weight-normal);
|
||||
line-height: 20px; /* 142.857% */
|
||||
letter-spacing: -0.07px;
|
||||
}
|
||||
}
|
||||
|
||||
.custom-domain-settings-modal-error {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 24px;
|
||||
|
||||
.update-limit-reached-error {
|
||||
display: flex;
|
||||
padding: 20px 24px 24px 24px;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 24px;
|
||||
align-self: stretch;
|
||||
|
||||
border-radius: 4px;
|
||||
border: 1px solid rgba(255, 205, 86, 0.2);
|
||||
background: rgba(255, 205, 86, 0.1);
|
||||
|
||||
color: var(--bg-amber-400);
|
||||
font-size: 13px;
|
||||
font-style: normal;
|
||||
line-height: 20px; /* 142.857% */
|
||||
}
|
||||
|
||||
.ant-alert-message::first-letter {
|
||||
text-transform: capitalize;
|
||||
}
|
||||
}
|
||||
|
||||
.custom-domain-settings-modal-footer {
|
||||
padding: 16px 0;
|
||||
margin-top: 0;
|
||||
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
|
||||
.apply-changes-btn {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.facing-issue-button {
|
||||
width: 100%;
|
||||
|
||||
.periscope-btn {
|
||||
width: 100%;
|
||||
|
||||
border-radius: 2px;
|
||||
background: var(--bg-robin-500);
|
||||
border: none;
|
||||
|
||||
color: var(--bg-vanilla-100);
|
||||
line-height: 20px;
|
||||
|
||||
.ant-btn-icon {
|
||||
display: none;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background: var(--bg-robin-500) !important;
|
||||
border: none !important;
|
||||
color: var(--bg-vanilla-100) !important;
|
||||
line-height: 20px !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.lightMode {
|
||||
.custom-domain-settings-container {
|
||||
.custom-domain-settings-content {
|
||||
.title {
|
||||
color: var(--bg-ink-400);
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
color: var(--bg-vanilla-400);
|
||||
}
|
||||
}
|
||||
|
||||
.custom-domain-settings-card {
|
||||
border: 1px solid var(--bg-vanilla-300);
|
||||
background: var(--bg-vanilla-100);
|
||||
|
||||
.ant-card-body {
|
||||
.custom-domain-settings-content-header {
|
||||
color: var(--bg-ink-100);
|
||||
}
|
||||
|
||||
.custom-domain-update-status {
|
||||
color: var(--bg-robin-400);
|
||||
border: 1px solid rgba(78, 116, 248, 0.1);
|
||||
background: rgba(78, 116, 248, 0.1);
|
||||
}
|
||||
|
||||
.custom-domain-url-edit-btn {
|
||||
.periscope-btn {
|
||||
border: 1px solid var(--bg-vanilla-300);
|
||||
background: var(--bg-vanilla-100);
|
||||
box-shadow: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.custom-domain-settings-modal {
|
||||
.ant-modal-content {
|
||||
border: 1px solid var(--bg-vanilla-300);
|
||||
background: var(--bg-vanilla-100);
|
||||
box-shadow: 0px -4px 16px 2px rgba(0, 0, 0, 0.2);
|
||||
|
||||
.ant-modal-header {
|
||||
border-bottom: 1px solid var(--bg-vanilla-300);
|
||||
}
|
||||
|
||||
.custom-domain-settings-modal-error {
|
||||
.update-limit-reached-error {
|
||||
border: 1px solid rgba(255, 205, 86, 0.2);
|
||||
background: rgba(255, 205, 86, 0.1);
|
||||
|
||||
color: var(--bg-amber-500);
|
||||
}
|
||||
}
|
||||
&:hover {
|
||||
background: var(--primary-background-hover);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,59 +1,88 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useCopyToClipboard } from 'react-use';
|
||||
import { Color } from '@signozhq/design-tokens';
|
||||
import { Button } from '@signozhq/button';
|
||||
import { Callout } from '@signozhq/callout';
|
||||
import {
|
||||
Alert,
|
||||
Button,
|
||||
Card,
|
||||
Form,
|
||||
Input,
|
||||
Modal,
|
||||
Skeleton,
|
||||
Tag,
|
||||
Typography,
|
||||
} from 'antd';
|
||||
Check,
|
||||
ChevronDown,
|
||||
Clock,
|
||||
ExternalLink,
|
||||
FilePenLine,
|
||||
Link2,
|
||||
SolidAlertCircle,
|
||||
X,
|
||||
} from '@signozhq/icons';
|
||||
import { toast } from '@signozhq/sonner';
|
||||
import { Dropdown, Skeleton } from 'antd';
|
||||
import {
|
||||
RenderErrorResponseDTO,
|
||||
ZeustypesHostDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { useGetHosts, usePutHost } from 'api/generated/services/zeus';
|
||||
import { AxiosError } from 'axios';
|
||||
import LaunchChatSupport from 'components/LaunchChatSupport/LaunchChatSupport';
|
||||
import { useNotifications } from 'hooks/useNotifications';
|
||||
import { InfoIcon, Link2, Pencil } from 'lucide-react';
|
||||
import { useAppContext } from 'providers/App/App';
|
||||
import { useTimezone } from 'providers/Timezone';
|
||||
|
||||
import CustomDomainEditModal from './CustomDomainEditModal';
|
||||
|
||||
import './CustomDomainSettings.styles.scss';
|
||||
|
||||
interface CustomDomainSettingsProps {
|
||||
subdomain: string;
|
||||
function DomainUpdateToast({
|
||||
toastId,
|
||||
url,
|
||||
}: {
|
||||
toastId: string | number;
|
||||
url: string;
|
||||
}): JSX.Element {
|
||||
const displayUrl = url?.split('://')[1] ?? url;
|
||||
|
||||
return (
|
||||
<div className="custom-domain-toast">
|
||||
<span className="custom-domain-toast-message">
|
||||
Your workspace URL is being updated to <strong>{displayUrl}</strong>. This
|
||||
may take a few minutes.
|
||||
</span>
|
||||
<div className="custom-domain-toast-actions">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
className="custom-domain-toast-visit-btn"
|
||||
suffixIcon={<ExternalLink size={12} />}
|
||||
onClick={(): void => {
|
||||
window.open(url, '_blank', 'noopener,noreferrer');
|
||||
}}
|
||||
>
|
||||
Visit new URL
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="custom-domain-toast-dismiss-btn"
|
||||
onClick={(): void => {
|
||||
toast.dismiss(toastId);
|
||||
}}
|
||||
aria-label="Dismiss"
|
||||
prefixIcon={<X size={14} />}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function CustomDomainSettings(): JSX.Element {
|
||||
const { org } = useAppContext();
|
||||
const { notifications } = useNotifications();
|
||||
const { org, activeLicense } = useAppContext();
|
||||
const { timezone } = useTimezone();
|
||||
const [isEditModalOpen, setIsEditModalOpen] = useState(false);
|
||||
const [isPollingEnabled, setIsPollingEnabled] = useState(false);
|
||||
const [hosts, setHosts] = useState<ZeustypesHostDTO[] | null>(null);
|
||||
|
||||
const [updateDomainError, setUpdateDomainError] = useState<AxiosError | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
const [, setCopyUrl] = useCopyToClipboard();
|
||||
|
||||
const [
|
||||
customDomainDetails,
|
||||
setCustomDomainDetails,
|
||||
] = useState<CustomDomainSettingsProps | null>();
|
||||
updateDomainError,
|
||||
setUpdateDomainError,
|
||||
] = useState<AxiosError<RenderErrorResponseDTO> | null>(null);
|
||||
|
||||
const [editForm] = Form.useForm();
|
||||
|
||||
const handleModalClose = (): void => {
|
||||
setIsEditModalOpen(false);
|
||||
editForm.resetFields();
|
||||
setUpdateDomainError(null);
|
||||
};
|
||||
const [customDomainSubdomain, setCustomDomainSubdomain] = useState<
|
||||
string | undefined
|
||||
>();
|
||||
|
||||
const {
|
||||
data: hostsData,
|
||||
@@ -67,9 +96,7 @@ export default function CustomDomainSettings(): JSX.Element {
|
||||
isLoading: isLoadingUpdateCustomDomain,
|
||||
} = usePutHost<AxiosError<RenderErrorResponseDTO>>();
|
||||
|
||||
const stripProtocol = (url: string): string => {
|
||||
return url?.split('://')[1] ?? url;
|
||||
};
|
||||
const stripProtocol = (url: string): string => url?.split('://')[1] ?? url;
|
||||
|
||||
const dnsSuffix = useMemo(() => {
|
||||
const defaultHost = hosts?.find((h) => h.is_default);
|
||||
@@ -78,6 +105,11 @@ export default function CustomDomainSettings(): JSX.Element {
|
||||
: '';
|
||||
}, [hosts]);
|
||||
|
||||
const activeHost = useMemo(
|
||||
() => hosts?.find((h) => !h.is_default) ?? hosts?.find((h) => h.is_default),
|
||||
[hosts],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (isFetchingHosts || !hostsData) {
|
||||
return;
|
||||
@@ -85,22 +117,14 @@ export default function CustomDomainSettings(): JSX.Element {
|
||||
|
||||
if (hostsData.status === 'success') {
|
||||
setHosts(hostsData.data.hosts ?? null);
|
||||
|
||||
const activeCustomDomain = hostsData.data.hosts?.find(
|
||||
(host) => !host.is_default,
|
||||
);
|
||||
|
||||
if (activeCustomDomain) {
|
||||
setCustomDomainDetails({
|
||||
subdomain: activeCustomDomain?.name || '',
|
||||
});
|
||||
const customHost = hostsData.data.hosts?.find((h) => !h.is_default);
|
||||
if (customHost) {
|
||||
setCustomDomainSubdomain(customHost.name || '');
|
||||
}
|
||||
}
|
||||
|
||||
if (hostsData.data.state !== 'HEALTHY' && isPollingEnabled) {
|
||||
setTimeout(() => {
|
||||
refetchHosts();
|
||||
}, 3000);
|
||||
setTimeout(() => refetchHosts(), 3000);
|
||||
}
|
||||
|
||||
if (hostsData.data.state === 'HEALTHY') {
|
||||
@@ -108,206 +132,175 @@ export default function CustomDomainSettings(): JSX.Element {
|
||||
}
|
||||
}, [hostsData, refetchHosts, isPollingEnabled, isFetchingHosts]);
|
||||
|
||||
const onUpdateCustomDomainSettings = (): void => {
|
||||
editForm
|
||||
.validateFields()
|
||||
.then((values) => {
|
||||
if (values.subdomain) {
|
||||
updateSubDomain(
|
||||
{ data: { name: values.subdomain } },
|
||||
{
|
||||
onSuccess: () => {
|
||||
setIsPollingEnabled(true);
|
||||
refetchHosts();
|
||||
setIsEditModalOpen(false);
|
||||
},
|
||||
onError: (error: AxiosError<RenderErrorResponseDTO>) => {
|
||||
setUpdateDomainError(error as AxiosError);
|
||||
setIsPollingEnabled(false);
|
||||
},
|
||||
},
|
||||
const handleSubmit = (subdomain: string): void => {
|
||||
updateSubDomain(
|
||||
{ data: { name: subdomain } },
|
||||
{
|
||||
onSuccess: () => {
|
||||
setIsPollingEnabled(true);
|
||||
refetchHosts();
|
||||
setIsEditModalOpen(false);
|
||||
setCustomDomainSubdomain(subdomain);
|
||||
const newUrl = `https://${subdomain}.${dnsSuffix}`;
|
||||
toast.custom(
|
||||
(toastId) => <DomainUpdateToast toastId={toastId} url={newUrl} />,
|
||||
{ duration: 5000, position: 'bottom-right' }, // this 5 sec is as per design
|
||||
);
|
||||
},
|
||||
onError: (error: AxiosError<RenderErrorResponseDTO>) => {
|
||||
setUpdateDomainError(error as AxiosError<RenderErrorResponseDTO>);
|
||||
setIsPollingEnabled(false);
|
||||
},
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
setCustomDomainDetails({
|
||||
subdomain: values.subdomain,
|
||||
});
|
||||
const sortedHosts = useMemo(
|
||||
() =>
|
||||
[...(hosts ?? [])].sort((a, b) => {
|
||||
if (a.name === activeHost?.name) {
|
||||
return -1;
|
||||
}
|
||||
})
|
||||
.catch((errorInfo) => {
|
||||
console.error('error info', errorInfo);
|
||||
});
|
||||
};
|
||||
if (b.name === activeHost?.name) {
|
||||
return 1;
|
||||
}
|
||||
if (a.is_default && !b.is_default) {
|
||||
return 1;
|
||||
}
|
||||
if (!a.is_default && b.is_default) {
|
||||
return -1;
|
||||
}
|
||||
return 0;
|
||||
}),
|
||||
[hosts, activeHost],
|
||||
);
|
||||
|
||||
const onCopyUrlHandler = (url: string): void => {
|
||||
setCopyUrl(stripProtocol(url));
|
||||
notifications.success({
|
||||
message: 'Copied to clipboard',
|
||||
});
|
||||
};
|
||||
const planName = activeLicense?.plan?.name;
|
||||
|
||||
if (isLoadingHosts) {
|
||||
return (
|
||||
<div className="custom-domain-card custom-domain-card--loading">
|
||||
<Skeleton
|
||||
active
|
||||
title={{ width: '40%' }}
|
||||
paragraph={{ rows: 1, width: '60%' }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="custom-domain-settings-container">
|
||||
<div className="custom-domain-settings-content">
|
||||
<header>
|
||||
<Typography.Title className="title">
|
||||
Custom Domain Settings
|
||||
</Typography.Title>
|
||||
<Typography.Text className="subtitle">
|
||||
Personalize your workspace domain effortlessly.
|
||||
</Typography.Text>
|
||||
</header>
|
||||
</div>
|
||||
|
||||
<div className="custom-domain-settings-content">
|
||||
{!isLoadingHosts && (
|
||||
<Card className="custom-domain-settings-card">
|
||||
<div className="custom-domain-settings-content-header">
|
||||
Team {org?.[0]?.displayName} Information
|
||||
<>
|
||||
<div className="custom-domain-card">
|
||||
<div className="custom-domain-card-top">
|
||||
<div className="custom-domain-card-info">
|
||||
<div className="custom-domain-card-name-row">
|
||||
<span className="beacon" />
|
||||
<span className="custom-domain-card-org-name">
|
||||
{org?.[0]?.displayName ? org?.[0]?.displayName : customDomainSubdomain}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="custom-domain-settings-content-body">
|
||||
<div className="custom-domain-urls">
|
||||
{hosts?.map((host) => (
|
||||
<div
|
||||
className="custom-domain-url"
|
||||
key={host.name}
|
||||
onClick={(): void => onCopyUrlHandler(host.url || '')}
|
||||
>
|
||||
<Link2 size={12} /> {stripProtocol(host.url || '')}
|
||||
{host.is_default && <Tag color={Color.BG_ROBIN_500}>Default</Tag>}
|
||||
<div className="custom-domain-card-meta-row">
|
||||
<Dropdown
|
||||
trigger={['click']}
|
||||
dropdownRender={(): JSX.Element => (
|
||||
<div className="workspace-url-dropdown">
|
||||
<span className="workspace-url-dropdown-header">
|
||||
All Workspace URLs
|
||||
</span>
|
||||
<div className="workspace-url-dropdown-divider" />
|
||||
{sortedHosts.map((host) => {
|
||||
const isActive = host.name === activeHost?.name;
|
||||
return (
|
||||
<a
|
||||
key={host.name}
|
||||
href={host.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className={`workspace-url-dropdown-item${
|
||||
isActive ? ' workspace-url-dropdown-item--active' : ''
|
||||
}`}
|
||||
>
|
||||
<span className="workspace-url-dropdown-item-label">
|
||||
{stripProtocol(host.url ?? '')}
|
||||
</span>
|
||||
{isActive ? (
|
||||
<Check size={14} className="workspace-url-dropdown-item-check" />
|
||||
) : (
|
||||
<ExternalLink
|
||||
size={12}
|
||||
className="workspace-url-dropdown-item-external"
|
||||
/>
|
||||
)}
|
||||
</a>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="custom-domain-url-edit-btn">
|
||||
)}
|
||||
>
|
||||
<Button
|
||||
className="periscope-btn"
|
||||
disabled={isLoadingHosts || isFetchingHosts || isPollingEnabled}
|
||||
type="default"
|
||||
icon={<Pencil size={10} />}
|
||||
onClick={(): void => setIsEditModalOpen(true)}
|
||||
type="button"
|
||||
size="xs"
|
||||
className="workspace-url-trigger"
|
||||
disabled={isFetchingHosts}
|
||||
>
|
||||
Customize team’s URL
|
||||
<Link2 size={12} />
|
||||
<span>{stripProtocol(activeHost?.url ?? '')}</span>
|
||||
<ChevronDown size={12} />
|
||||
</Button>
|
||||
</div>
|
||||
</Dropdown>
|
||||
<span className="custom-domain-card-meta-timezone">
|
||||
<Clock size={11} />
|
||||
{timezone.offset}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isPollingEnabled && (
|
||||
<Alert
|
||||
className="custom-domain-update-status"
|
||||
message={`Updating your URL to ⎯ ${customDomainDetails?.subdomain}.${dnsSuffix}. This may take a few mins.`}
|
||||
type="info"
|
||||
icon={<InfoIcon size={12} />}
|
||||
/>
|
||||
)}
|
||||
</Card>
|
||||
<Button
|
||||
variant="solid"
|
||||
size="sm"
|
||||
className="custom-domain-edit-button"
|
||||
prefixIcon={<FilePenLine size={12} />}
|
||||
disabled={isFetchingHosts || isPollingEnabled}
|
||||
onClick={(): void => setIsEditModalOpen(true)}
|
||||
>
|
||||
Edit workspace link
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{isPollingEnabled && (
|
||||
<Callout
|
||||
type="info"
|
||||
showIcon
|
||||
className="custom-domain-callout"
|
||||
size="small"
|
||||
icon={<SolidAlertCircle size={13} color="primary" />}
|
||||
message={`Updating your URL to ⎯ ${customDomainSubdomain}.${dnsSuffix}. This may take a few mins.`}
|
||||
/>
|
||||
)}
|
||||
|
||||
{isLoadingHosts && (
|
||||
<Card className="custom-domain-settings-card">
|
||||
<Skeleton
|
||||
className="custom-domain-settings-skeleton"
|
||||
active
|
||||
paragraph={{ rows: 2 }}
|
||||
/>
|
||||
</Card>
|
||||
)}
|
||||
<div className="custom-domain-card-divider" />
|
||||
|
||||
<div className="custom-domain-card-bottom">
|
||||
<span className="beacon" />
|
||||
<span className="custom-domain-card-license">
|
||||
{planName && <code className="custom-domain-plan-badge">{planName}</code>}{' '}
|
||||
license is currently active
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Update Custom Domain Modal */}
|
||||
<Modal
|
||||
className="custom-domain-settings-modal"
|
||||
title="Customize your team’s URL"
|
||||
open={isEditModalOpen}
|
||||
key="edit-custom-domain-settings-modal"
|
||||
afterClose={handleModalClose}
|
||||
// closable
|
||||
onCancel={handleModalClose}
|
||||
destroyOnClose
|
||||
footer={null}
|
||||
>
|
||||
<Form
|
||||
name="edit-custom-domain-settings-form"
|
||||
key={customDomainDetails?.subdomain}
|
||||
form={editForm}
|
||||
layout="vertical"
|
||||
autoComplete="off"
|
||||
initialValues={{
|
||||
subdomain: customDomainDetails?.subdomain,
|
||||
}}
|
||||
>
|
||||
{updateDomainError?.status !== 409 && (
|
||||
<>
|
||||
<div className="custom-domain-settings-modal-body">
|
||||
Enter your preferred subdomain to create a unique URL for your team.
|
||||
Need help? Contact support.
|
||||
</div>
|
||||
|
||||
<Form.Item
|
||||
name="subdomain"
|
||||
label="Team’s URL subdomain"
|
||||
rules={[{ required: true }, { type: 'string', min: 3 }]}
|
||||
>
|
||||
<Input
|
||||
addonBefore={updateDomainError && <InfoIcon size={12} color="red" />}
|
||||
placeholder="Enter Domain"
|
||||
onChange={(): void => setUpdateDomainError(null)}
|
||||
addonAfter={dnsSuffix}
|
||||
autoFocus
|
||||
/>
|
||||
</Form.Item>
|
||||
</>
|
||||
)}
|
||||
|
||||
{updateDomainError && (
|
||||
<div className="custom-domain-settings-modal-error">
|
||||
{updateDomainError.status === 409 ? (
|
||||
<Alert
|
||||
message={
|
||||
(updateDomainError?.response?.data as RenderErrorResponseDTO)?.error
|
||||
?.message ||
|
||||
'You’ve already updated the custom domain once today. To make further changes, please contact our support team for assistance.'
|
||||
}
|
||||
type="warning"
|
||||
className="update-limit-reached-error"
|
||||
/>
|
||||
) : (
|
||||
<Typography.Text type="danger">
|
||||
{
|
||||
(updateDomainError?.response?.data as RenderErrorResponseDTO)?.error
|
||||
?.message
|
||||
}
|
||||
</Typography.Text>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{updateDomainError?.status !== 409 && (
|
||||
<div className="custom-domain-settings-modal-footer">
|
||||
<Button
|
||||
className="periscope-btn primary apply-changes-btn"
|
||||
onClick={onUpdateCustomDomainSettings}
|
||||
loading={isLoadingUpdateCustomDomain}
|
||||
>
|
||||
Apply Changes
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{updateDomainError?.status === 409 && (
|
||||
<div className="custom-domain-settings-modal-footer">
|
||||
<LaunchChatSupport
|
||||
attributes={{
|
||||
screen: 'Custom Domain Settings',
|
||||
}}
|
||||
eventName="Custom Domain Settings: Facing Issues Updating Custom Domain"
|
||||
message="Hi Team, I need help with updating custom domain"
|
||||
buttonText="Contact Support"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</Form>
|
||||
</Modal>
|
||||
</div>
|
||||
<CustomDomainEditModal
|
||||
isOpen={isEditModalOpen}
|
||||
onClose={(): void => setIsEditModalOpen(false)}
|
||||
customDomainSubdomain={customDomainSubdomain}
|
||||
dnsSuffix={dnsSuffix}
|
||||
isLoading={isLoadingUpdateCustomDomain}
|
||||
updateDomainError={updateDomainError}
|
||||
onClearError={(): void => setUpdateDomainError(null)}
|
||||
onSubmit={handleSubmit}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,6 +4,14 @@ import { render, screen, userEvent, waitFor } from 'tests/test-utils';
|
||||
|
||||
import CustomDomainSettings from '../CustomDomainSettings';
|
||||
|
||||
const mockToastCustom = jest.fn();
|
||||
jest.mock('@signozhq/sonner', () => ({
|
||||
toast: {
|
||||
custom: (...args: unknown[]): unknown => mockToastCustom(...args),
|
||||
dismiss: jest.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
const ZEUS_HOSTS_ENDPOINT = '*/api/v2/zeus/hosts';
|
||||
|
||||
const mockHostsResponse: GetHosts200 = {
|
||||
@@ -28,9 +36,12 @@ const mockHostsResponse: GetHosts200 = {
|
||||
};
|
||||
|
||||
describe('CustomDomainSettings', () => {
|
||||
afterEach(() => server.resetHandlers());
|
||||
afterEach(() => {
|
||||
server.resetHandlers();
|
||||
mockToastCustom.mockClear();
|
||||
});
|
||||
|
||||
it('renders host URLs with protocol stripped and marks the default host', async () => {
|
||||
it('renders active host URL in the trigger button', async () => {
|
||||
server.use(
|
||||
rest.get(ZEUS_HOSTS_ENDPOINT, (_, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json(mockHostsResponse)),
|
||||
@@ -39,12 +50,11 @@ describe('CustomDomainSettings', () => {
|
||||
|
||||
render(<CustomDomainSettings />);
|
||||
|
||||
await screen.findByText(/accepted-starfish\.test\.cloud/i);
|
||||
// The active host is the non-default one (custom-host)
|
||||
await screen.findByText(/custom-host\.test\.cloud/i);
|
||||
expect(screen.getByText('Default')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('opens edit modal with DNS suffix derived from the default host', async () => {
|
||||
it('opens edit modal when clicking the edit button', async () => {
|
||||
server.use(
|
||||
rest.get(ZEUS_HOSTS_ENDPOINT, (_, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json(mockHostsResponse)),
|
||||
@@ -54,14 +64,14 @@ describe('CustomDomainSettings', () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
render(<CustomDomainSettings />);
|
||||
|
||||
await screen.findByText(/accepted-starfish\.test\.cloud/i);
|
||||
await screen.findByText(/custom-host\.test\.cloud/i);
|
||||
|
||||
await user.click(
|
||||
screen.getByRole('button', { name: /customize team['’]s url/i }),
|
||||
screen.getByRole('button', { name: /edit workspace link/i }),
|
||||
);
|
||||
|
||||
expect(
|
||||
screen.getByRole('dialog', { name: /customize your team['’]s url/i }),
|
||||
screen.getByRole('dialog', { name: /edit workspace link/i }),
|
||||
).toBeInTheDocument();
|
||||
// DNS suffix is the part of the default host URL after the name prefix
|
||||
expect(screen.getByText('test.cloud')).toBeInTheDocument();
|
||||
@@ -83,12 +93,13 @@ describe('CustomDomainSettings', () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
render(<CustomDomainSettings />);
|
||||
|
||||
await screen.findByText(/accepted-starfish\.test\.cloud/i);
|
||||
await screen.findByText(/custom-host\.test\.cloud/i);
|
||||
await user.click(
|
||||
screen.getByRole('button', { name: /customize team['’]s url/i }),
|
||||
screen.getByRole('button', { name: /edit workspace link/i }),
|
||||
);
|
||||
|
||||
const input = screen.getByPlaceholderText(/enter domain/i);
|
||||
// The input is inside the modal — find it by its role
|
||||
const input = screen.getByRole('textbox');
|
||||
await user.clear(input);
|
||||
await user.type(input, 'myteam');
|
||||
await user.click(screen.getByRole('button', { name: /apply changes/i }));
|
||||
@@ -114,15 +125,111 @@ describe('CustomDomainSettings', () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
render(<CustomDomainSettings />);
|
||||
|
||||
await screen.findByText(/accepted-starfish\.test\.cloud/i);
|
||||
await screen.findByText(/custom-host\.test\.cloud/i);
|
||||
await user.click(
|
||||
screen.getByRole('button', { name: /customize team['’]s url/i }),
|
||||
screen.getByRole('button', { name: /edit workspace link/i }),
|
||||
);
|
||||
await user.type(screen.getByPlaceholderText(/enter domain/i), 'myteam');
|
||||
|
||||
const input = screen.getByRole('textbox');
|
||||
await user.clear(input);
|
||||
await user.type(input, 'myteam');
|
||||
await user.click(screen.getByRole('button', { name: /apply changes/i }));
|
||||
|
||||
expect(
|
||||
await screen.findByRole('button', { name: /contact support/i }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows validation error when subdomain is less than 3 characters', async () => {
|
||||
server.use(
|
||||
rest.get(ZEUS_HOSTS_ENDPOINT, (_, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json(mockHostsResponse)),
|
||||
),
|
||||
);
|
||||
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
render(<CustomDomainSettings />);
|
||||
|
||||
await screen.findByText(/custom-host\.test\.cloud/i);
|
||||
await user.click(
|
||||
screen.getByRole('button', { name: /edit workspace link/i }),
|
||||
);
|
||||
|
||||
const input = screen.getByRole('textbox');
|
||||
await user.clear(input);
|
||||
await user.type(input, 'ab');
|
||||
await user.click(screen.getByRole('button', { name: /apply changes/i }));
|
||||
|
||||
expect(
|
||||
screen.getByText(/minimum 3 characters required/i),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows all workspace URLs as links in the dropdown', async () => {
|
||||
server.use(
|
||||
rest.get(ZEUS_HOSTS_ENDPOINT, (_, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json(mockHostsResponse)),
|
||||
),
|
||||
);
|
||||
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
render(<CustomDomainSettings />);
|
||||
|
||||
await screen.findByText(/custom-host\.test\.cloud/i);
|
||||
|
||||
// Open the URL dropdown
|
||||
await user.click(
|
||||
screen.getByRole('button', { name: /custom-host\.test\.cloud/i }),
|
||||
);
|
||||
|
||||
// Both host URLs should appear as links in the dropdown
|
||||
const links = await screen.findAllByRole('link');
|
||||
const hostLinks = links.filter(
|
||||
(link) =>
|
||||
link.getAttribute('href')?.includes('test.cloud') &&
|
||||
link.getAttribute('target') === '_blank',
|
||||
);
|
||||
expect(hostLinks).toHaveLength(2);
|
||||
|
||||
// Verify the URLs
|
||||
const hrefs = hostLinks.map((link) => link.getAttribute('href'));
|
||||
expect(hrefs).toContain('https://accepted-starfish.test.cloud');
|
||||
expect(hrefs).toContain('https://custom-host.test.cloud');
|
||||
});
|
||||
|
||||
it('calls toast.custom with new URL after successful domain update', async () => {
|
||||
server.use(
|
||||
rest.get(ZEUS_HOSTS_ENDPOINT, (_, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json(mockHostsResponse)),
|
||||
),
|
||||
rest.put(ZEUS_HOSTS_ENDPOINT, (_, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json({})),
|
||||
),
|
||||
);
|
||||
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
render(<CustomDomainSettings />);
|
||||
|
||||
await screen.findByText(/custom-host\.test\.cloud/i);
|
||||
await user.click(
|
||||
screen.getByRole('button', { name: /edit workspace link/i }),
|
||||
);
|
||||
|
||||
const input = screen.getByRole('textbox');
|
||||
await user.clear(input);
|
||||
await user.type(input, 'myteam');
|
||||
await user.click(screen.getByRole('button', { name: /apply changes/i }));
|
||||
|
||||
// Verify toast.custom was called
|
||||
await waitFor(() => {
|
||||
expect(mockToastCustom).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
// Render the toast element to verify its content
|
||||
const toastRenderer = mockToastCustom.mock.calls[0][0] as (
|
||||
id: string,
|
||||
) => JSX.Element;
|
||||
const { container } = render(toastRenderer('test-id'));
|
||||
expect(container).toHaveTextContent(/myteam\.test\.cloud/i);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
.general-settings-page {
|
||||
max-width: 768px;
|
||||
margin: 0 auto;
|
||||
padding: var(--padding-8) 0 var(--padding-16);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-12);
|
||||
}
|
||||
|
||||
.general-settings-header {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-2);
|
||||
}
|
||||
|
||||
.general-settings-title {
|
||||
font-size: var(--paragraph-medium-500-font-size);
|
||||
font-weight: var(--paragraph-medium-500-font-weight);
|
||||
line-height: 32px;
|
||||
letter-spacing: -0.08px;
|
||||
color: var(--l1-foreground);
|
||||
}
|
||||
|
||||
.general-settings-subtitle {
|
||||
font-size: var(--font-size-sm);
|
||||
line-height: var(--line-height-20);
|
||||
letter-spacing: -0.07px;
|
||||
color: var(--l2-foreground);
|
||||
}
|
||||
|
||||
.retention-controls-container {
|
||||
border: 1px solid var(--l2-border);
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.retention-controls-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0 var(--padding-4);
|
||||
height: 50px;
|
||||
background: var(--l2-background);
|
||||
border-bottom: 1px solid var(--l2-border);
|
||||
}
|
||||
|
||||
.retention-controls-header-label {
|
||||
font-size: var(--font-size-xs);
|
||||
font-weight: var(--font-weight-medium);
|
||||
line-height: var(--line-height-24);
|
||||
letter-spacing: 0.48px;
|
||||
text-transform: uppercase;
|
||||
color: var(--l2-foreground);
|
||||
}
|
||||
|
||||
.retention-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0 var(--padding-4);
|
||||
height: 52px;
|
||||
background: var(--l2-background);
|
||||
|
||||
& + & {
|
||||
border-top: 1px solid var(--l1-border);
|
||||
}
|
||||
}
|
||||
|
||||
.retention-row-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-3);
|
||||
font-size: var(--font-size-sm);
|
||||
font-weight: var(--font-weight-medium);
|
||||
line-height: var(--line-height-20);
|
||||
letter-spacing: -0.07px;
|
||||
color: var(--l1-foreground);
|
||||
|
||||
svg {
|
||||
color: var(--l2-foreground);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.retention-row-controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-4);
|
||||
}
|
||||
|
||||
.retention-input-group {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
|
||||
// todo: https://github.com/SigNoz/components/issues/116
|
||||
input[type='number'] {
|
||||
display: flex;
|
||||
width: 120px;
|
||||
height: 32px;
|
||||
align-items: center;
|
||||
gap: var(--spacing-2);
|
||||
border-radius: 2px 0 0 2px;
|
||||
border: 1px solid var(--l2-border);
|
||||
background: transparent;
|
||||
text-align: left;
|
||||
-moz-appearance: textfield;
|
||||
appearance: textfield;
|
||||
color: var(--l1-foreground);
|
||||
font-size: var(--font-size-xs);
|
||||
font-weight: var(--font-weight-normal);
|
||||
line-height: 16px;
|
||||
box-shadow: none;
|
||||
|
||||
&::-webkit-outer-spin-button,
|
||||
&::-webkit-inner-spin-button {
|
||||
-webkit-appearance: none;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.8;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
|
||||
.ant-select {
|
||||
.ant-select-selector {
|
||||
display: flex;
|
||||
height: 32px;
|
||||
padding: 6px 6px 6px 8px;
|
||||
align-items: center;
|
||||
gap: var(--spacing-2);
|
||||
border: 1px solid var(--l2-border);
|
||||
background: var(--l2-background);
|
||||
width: 80px;
|
||||
border-left: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.retention-error-text {
|
||||
font-size: var(--font-size-xs);
|
||||
color: var(--accent-amber);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.retention-modal-description {
|
||||
margin: 0;
|
||||
color: var(--l1-foreground);
|
||||
font-size: var(--font-size-sm);
|
||||
line-height: 22px;
|
||||
}
|
||||
@@ -3,16 +3,20 @@ import { useTranslation } from 'react-i18next';
|
||||
import { UseQueryResult } from 'react-query';
|
||||
import { useInterval } from 'react-use';
|
||||
import { LoadingOutlined } from '@ant-design/icons';
|
||||
import { Button, Card, Col, Divider, Modal, Row, Spin, Typography } from 'antd';
|
||||
import { Button } from '@signozhq/button';
|
||||
import { Compass, ScrollText } from '@signozhq/icons';
|
||||
import { Modal, Spin } from 'antd';
|
||||
import setRetentionApi from 'api/settings/setRetention';
|
||||
import setRetentionApiV2 from 'api/settings/setRetentionV2';
|
||||
import TextToolTip from 'components/TextToolTip';
|
||||
import CustomDomainSettings from 'container/CustomDomainSettings';
|
||||
import GeneralSettingsCloud from 'container/GeneralSettingsCloud';
|
||||
import useComponentPermission from 'hooks/useComponentPermission';
|
||||
import { useGetTenantLicense } from 'hooks/useGetTenantLicense';
|
||||
import { useNotifications } from 'hooks/useNotifications';
|
||||
import { StatusCodes } from 'http-status-codes';
|
||||
import find from 'lodash-es/find';
|
||||
import { BarChart2 } from 'lucide-react';
|
||||
import { useAppContext } from 'providers/App/App';
|
||||
import {
|
||||
ErrorResponse,
|
||||
@@ -31,13 +35,17 @@ import {
|
||||
PayloadPropsMetrics as GetRetentionPeriodMetricsPayload,
|
||||
PayloadPropsTraces as GetRetentionPeriodTracesPayload,
|
||||
} from 'types/api/settings/getRetention';
|
||||
import { USER_ROLES } from 'types/roles';
|
||||
|
||||
import Retention from './Retention';
|
||||
import StatusMessage from './StatusMessage';
|
||||
import { ActionItemsContainer, ErrorText, ErrorTextContainer } from './styles';
|
||||
|
||||
import './GeneralSettings.styles.scss';
|
||||
|
||||
type NumberOrNull = number | null;
|
||||
|
||||
// eslint-disable-next-line sonarjs/cognitive-complexity
|
||||
function GeneralSettings({
|
||||
metricsTtlValuesPayload,
|
||||
tracesTtlValuesPayload,
|
||||
@@ -455,12 +463,20 @@ function GeneralSettings({
|
||||
onModalToggleHandler(type);
|
||||
};
|
||||
|
||||
const { isCloudUser: isCloudUserVal } = useGetTenantLicense();
|
||||
const {
|
||||
isCloudUser: isCloudUserVal,
|
||||
isEnterpriseSelfHostedUser,
|
||||
} = useGetTenantLicense();
|
||||
|
||||
const isAdmin = user.role === USER_ROLES.ADMIN;
|
||||
const showCustomDomainSettings =
|
||||
(isCloudUserVal || isEnterpriseSelfHostedUser) && isAdmin;
|
||||
|
||||
const renderConfig = [
|
||||
{
|
||||
name: 'Metrics',
|
||||
type: 'metrics',
|
||||
icon: <BarChart2 size={14} />,
|
||||
retentionFields: [
|
||||
{
|
||||
name: t('total_retention_period'),
|
||||
@@ -503,6 +519,7 @@ function GeneralSettings({
|
||||
{
|
||||
name: 'Traces',
|
||||
type: 'traces',
|
||||
icon: <Compass size={14} />,
|
||||
retentionFields: [
|
||||
{
|
||||
name: t('total_retention_period'),
|
||||
@@ -543,6 +560,7 @@ function GeneralSettings({
|
||||
{
|
||||
name: 'Logs',
|
||||
type: 'logs',
|
||||
icon: <ScrollText size={14} />,
|
||||
retentionFields: [
|
||||
{
|
||||
name: t('total_retention_period'),
|
||||
@@ -587,69 +605,66 @@ function GeneralSettings({
|
||||
) {
|
||||
return (
|
||||
<Fragment key={category.name}>
|
||||
<Col xs={22} xl={11} key={category.name} style={{ margin: '0.5rem' }}>
|
||||
<Card style={{ height: '100%' }}>
|
||||
<Typography.Title style={{ margin: 0 }} level={3}>
|
||||
{category.name}
|
||||
</Typography.Title>
|
||||
<Divider
|
||||
style={{
|
||||
margin: '0.5rem 0',
|
||||
padding: 0,
|
||||
opacity: 0.5,
|
||||
marginBottom: '1rem',
|
||||
}}
|
||||
/>
|
||||
{category.retentionFields.map((retentionField) => (
|
||||
<div className="retention-row">
|
||||
<span className="retention-row-label">
|
||||
{category.icon}
|
||||
{category.name}
|
||||
</span>
|
||||
<div className="retention-row-controls">
|
||||
{category.retentionFields.map((field) => (
|
||||
<Retention
|
||||
key={field.name}
|
||||
type={category.type as TTTLType}
|
||||
key={retentionField.name}
|
||||
text={retentionField.name}
|
||||
retentionValue={retentionField.value}
|
||||
setRetentionValue={retentionField.setValue}
|
||||
hide={!!retentionField.hide}
|
||||
isS3Field={'isS3Field' in retentionField && retentionField.isS3Field}
|
||||
text={field.name}
|
||||
retentionValue={field.value}
|
||||
setRetentionValue={field.setValue}
|
||||
hide={!!field.hide}
|
||||
isS3Field={'isS3Field' in field && !!field.isS3Field}
|
||||
compact
|
||||
/>
|
||||
))}
|
||||
|
||||
{!isCloudUserVal && (
|
||||
<>
|
||||
<ActionItemsContainer>
|
||||
<Button
|
||||
type="primary"
|
||||
onClick={category.save.modalOpen}
|
||||
disabled={category.save.isDisabled}
|
||||
>
|
||||
{category.save.saveButtonText}
|
||||
</Button>
|
||||
{category.statusComponent}
|
||||
</ActionItemsContainer>
|
||||
<Modal
|
||||
title={t('retention_confirmation')}
|
||||
focusTriggerAfterClose
|
||||
forceRender
|
||||
destroyOnClose
|
||||
closable
|
||||
onCancel={(): void =>
|
||||
onModalToggleHandler(category.name.toLowerCase() as TTTLType)
|
||||
}
|
||||
onOk={(): Promise<void> =>
|
||||
onOkHandler(category.name.toLowerCase() as TTTLType)
|
||||
}
|
||||
centered
|
||||
open={category.save.modal}
|
||||
confirmLoading={category.save.apiLoading}
|
||||
>
|
||||
<Typography>
|
||||
{t('retention_confirmation_description', {
|
||||
name: category.name.toLowerCase(),
|
||||
})}
|
||||
</Typography>
|
||||
</Modal>
|
||||
</>
|
||||
<Button
|
||||
variant="solid"
|
||||
size="sm"
|
||||
color="primary"
|
||||
onClick={category.save.modalOpen}
|
||||
disabled={category.save.isDisabled}
|
||||
>
|
||||
{category.save.saveButtonText}
|
||||
</Button>
|
||||
)}
|
||||
</Card>
|
||||
</Col>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!isCloudUserVal && (
|
||||
<ActionItemsContainer>{category.statusComponent}</ActionItemsContainer>
|
||||
)}
|
||||
|
||||
{!isCloudUserVal && (
|
||||
<Modal
|
||||
title={t('retention_confirmation')}
|
||||
focusTriggerAfterClose
|
||||
forceRender
|
||||
destroyOnClose
|
||||
closable
|
||||
onCancel={(): void =>
|
||||
onModalToggleHandler(category.name.toLowerCase() as TTTLType)
|
||||
}
|
||||
onOk={(): Promise<void> =>
|
||||
onOkHandler(category.name.toLowerCase() as TTTLType)
|
||||
}
|
||||
centered
|
||||
open={category.save.modal}
|
||||
confirmLoading={category.save.apiLoading}
|
||||
>
|
||||
<p className="retention-modal-description">
|
||||
{t('retention_confirmation_description', {
|
||||
name: category.name.toLowerCase(),
|
||||
})}
|
||||
</p>
|
||||
</Modal>
|
||||
)}
|
||||
</Fragment>
|
||||
);
|
||||
}
|
||||
@@ -657,9 +672,24 @@ function GeneralSettings({
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
{Element}
|
||||
<Col xs={24} md={22} xl={20} xxl={18} style={{ margin: 'auto' }}>
|
||||
<div className="general-settings-page">
|
||||
<div className="general-settings-header">
|
||||
<span className="general-settings-title">General</span>
|
||||
<span className="general-settings-subtitle">
|
||||
Manage your workspace settings.
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{showCustomDomainSettings && <CustomDomainSettings />}
|
||||
|
||||
<div className="retention-controls-container">
|
||||
<div className="retention-controls-header">
|
||||
<span className="retention-controls-header-label">Retention Controls</span>
|
||||
</div>
|
||||
{renderConfig}
|
||||
</div>
|
||||
|
||||
{(!isCloudUserVal || errorText) && (
|
||||
<ErrorTextContainer>
|
||||
{!isCloudUserVal && (
|
||||
<TextToolTip
|
||||
@@ -671,12 +701,10 @@ function GeneralSettings({
|
||||
)}
|
||||
{errorText && <ErrorText>{errorText}</ErrorText>}
|
||||
</ErrorTextContainer>
|
||||
)}
|
||||
|
||||
<Row justify="start">{renderConfig}</Row>
|
||||
|
||||
{isCloudUserVal && <GeneralSettingsCloud />}
|
||||
</Col>
|
||||
</>
|
||||
{isCloudUserVal && <GeneralSettingsCloud />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
import { Input as SignozInput } from '@signozhq/input';
|
||||
import { Col, Row, Select } from 'antd';
|
||||
import { useGetTenantLicense } from 'hooks/useGetTenantLicense';
|
||||
import { find } from 'lodash-es';
|
||||
@@ -34,6 +35,7 @@ function Retention({
|
||||
text,
|
||||
hide,
|
||||
isS3Field = false,
|
||||
compact = false,
|
||||
}: RetentionProps): JSX.Element | null {
|
||||
// Filter available units based on type and field
|
||||
const availableUnits = useMemo(
|
||||
@@ -126,6 +128,27 @@ function Retention({
|
||||
return null;
|
||||
}
|
||||
|
||||
if (compact) {
|
||||
return (
|
||||
<div className="retention-input-group">
|
||||
<SignozInput
|
||||
type="number"
|
||||
min={0}
|
||||
value={selectedValue && selectedValue >= 0 ? selectedValue : ''}
|
||||
disabled={isCloudUserVal}
|
||||
onChange={(e): void => onChangeHandler(e, setSelectedValue)}
|
||||
/>
|
||||
<Select
|
||||
value={selectedTimeUnit}
|
||||
onChange={currentSelectedOption}
|
||||
disabled={isCloudUserVal}
|
||||
>
|
||||
{menuItems}
|
||||
</Select>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<RetentionContainer>
|
||||
<Row justify="space-between">
|
||||
@@ -162,9 +185,11 @@ interface RetentionProps {
|
||||
setRetentionValue: Dispatch<SetStateAction<number | null>>;
|
||||
hide: boolean;
|
||||
isS3Field?: boolean;
|
||||
compact?: boolean;
|
||||
}
|
||||
|
||||
Retention.defaultProps = {
|
||||
isS3Field: false,
|
||||
compact: false,
|
||||
};
|
||||
export default Retention;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import setRetentionApiV2 from 'api/settings/setRetentionV2';
|
||||
import { useGetTenantLicense } from 'hooks/useGetTenantLicense';
|
||||
import {
|
||||
fireEvent,
|
||||
render,
|
||||
@@ -35,14 +36,20 @@ jest.mock('hooks/useComponentPermission', () => ({
|
||||
}));
|
||||
|
||||
jest.mock('hooks/useGetTenantLicense', () => ({
|
||||
useGetTenantLicense: (): { isCloudUser: boolean } => ({
|
||||
useGetTenantLicense: jest.fn(() => ({
|
||||
isCloudUser: false,
|
||||
}),
|
||||
isEnterpriseSelfHostedUser: false,
|
||||
})),
|
||||
}));
|
||||
|
||||
jest.mock('container/GeneralSettingsCloud', () => ({
|
||||
__esModule: true,
|
||||
default: (): null => null,
|
||||
default: (): JSX.Element => <div data-testid="general-settings-cloud" />,
|
||||
}));
|
||||
|
||||
jest.mock('container/CustomDomainSettings', () => ({
|
||||
__esModule: true,
|
||||
default: (): JSX.Element => <div data-testid="custom-domain-settings" />,
|
||||
}));
|
||||
|
||||
// Mock data
|
||||
@@ -93,10 +100,12 @@ const mockDisksWithoutS3: IDiskType[] = [
|
||||
},
|
||||
];
|
||||
|
||||
describe('GeneralSettings - S3 Logs Retention', () => {
|
||||
const BUTTON_SELECTOR = 'button[type="button"]';
|
||||
const PRIMARY_BUTTON_CLASS = 'ant-btn-primary';
|
||||
const getLogsRow = (): HTMLElement => {
|
||||
const logsLabel = screen.getByText('Logs');
|
||||
return logsLabel.closest('.retention-row') as HTMLElement;
|
||||
};
|
||||
|
||||
describe('GeneralSettings - S3 Logs Retention', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
(setRetentionApiV2 as jest.Mock).mockResolvedValue({
|
||||
@@ -121,21 +130,20 @@ describe('GeneralSettings - S3 Logs Retention', () => {
|
||||
/>,
|
||||
);
|
||||
|
||||
// Find the Logs card
|
||||
const logsCard = screen.getByText('Logs').closest('.ant-card');
|
||||
expect(logsCard).toBeInTheDocument();
|
||||
const logsRow = getLogsRow();
|
||||
expect(logsRow).toBeInTheDocument();
|
||||
|
||||
// Find all inputs in the Logs card - there should be 2 (total retention + S3)
|
||||
const inputs = logsCard?.querySelectorAll('input[type="text"]');
|
||||
// Find all inputs in the Logs row - there should be 2 (total retention + S3)
|
||||
const inputs = logsRow.querySelectorAll('input[type="number"]');
|
||||
expect(inputs).toHaveLength(2);
|
||||
|
||||
// The second input is the S3 retention field
|
||||
const s3Input = inputs?.[1] as HTMLInputElement;
|
||||
const s3Input = inputs[1] as HTMLInputElement;
|
||||
|
||||
// Find the S3 dropdown (next sibling of the S3 input)
|
||||
const s3Dropdown = s3Input?.nextElementSibling?.querySelector(
|
||||
'.ant-select-selector',
|
||||
) as HTMLElement;
|
||||
const s3Dropdown = s3Input
|
||||
?.closest('.retention-row-controls')
|
||||
?.querySelectorAll('.ant-select-selector')[1] as HTMLElement;
|
||||
expect(s3Dropdown).toBeInTheDocument();
|
||||
|
||||
// Click the S3 dropdown to open it
|
||||
@@ -155,16 +163,13 @@ describe('GeneralSettings - S3 Logs Retention', () => {
|
||||
await user.clear(s3Input);
|
||||
await user.type(s3Input, '5');
|
||||
|
||||
// Find the save button in the Logs card
|
||||
const buttons = logsCard?.querySelectorAll(BUTTON_SELECTOR);
|
||||
// The primary button should be the save button
|
||||
const saveButton = Array.from(buttons || []).find((btn) =>
|
||||
btn.className.includes(PRIMARY_BUTTON_CLASS),
|
||||
// Find the save button in the Logs row
|
||||
const saveButton = logsRow.querySelector(
|
||||
'button:not([disabled])',
|
||||
) as HTMLButtonElement;
|
||||
|
||||
expect(saveButton).toBeInTheDocument();
|
||||
|
||||
// Wait for button to be enabled (it should enable after value changes)
|
||||
// Wait for button to be enabled
|
||||
await waitFor(() => {
|
||||
expect(saveButton).not.toBeDisabled();
|
||||
});
|
||||
@@ -205,8 +210,8 @@ describe('GeneralSettings - S3 Logs Retention', () => {
|
||||
);
|
||||
|
||||
// Verify S3 field is visible
|
||||
const logsCard = screen.getByText('Logs').closest('.ant-card');
|
||||
const inputs = logsCard?.querySelectorAll('input[type="text"]');
|
||||
const logsRow = getLogsRow();
|
||||
const inputs = logsRow.querySelectorAll('input[type="number"]');
|
||||
expect(inputs).toHaveLength(2); // Total + S3
|
||||
});
|
||||
});
|
||||
@@ -227,19 +232,18 @@ describe('GeneralSettings - S3 Logs Retention', () => {
|
||||
/>,
|
||||
);
|
||||
|
||||
// Find the Logs card
|
||||
const logsCard = screen.getByText('Logs').closest('.ant-card');
|
||||
expect(logsCard).toBeInTheDocument();
|
||||
const logsRow = getLogsRow();
|
||||
expect(logsRow).toBeInTheDocument();
|
||||
|
||||
// Only 1 input should be visible (total retention, no S3)
|
||||
const inputs = logsCard?.querySelectorAll('input[type="text"]');
|
||||
const inputs = logsRow.querySelectorAll('input[type="number"]');
|
||||
expect(inputs).toHaveLength(1);
|
||||
|
||||
// Change total retention value
|
||||
const totalInput = inputs?.[0] as HTMLInputElement;
|
||||
const totalInput = inputs[0] as HTMLInputElement;
|
||||
|
||||
// First, change the dropdown to Days (it defaults to Months)
|
||||
const totalDropdown = totalInput?.nextElementSibling?.querySelector(
|
||||
const totalDropdown = logsRow.querySelector(
|
||||
'.ant-select-selector',
|
||||
) as HTMLElement;
|
||||
await user.click(totalDropdown);
|
||||
@@ -263,14 +267,12 @@ describe('GeneralSettings - S3 Logs Retention', () => {
|
||||
await user.type(totalInput, '60');
|
||||
|
||||
// Find the save button
|
||||
const buttons = logsCard?.querySelectorAll(BUTTON_SELECTOR);
|
||||
const saveButton = Array.from(buttons || []).find((btn) =>
|
||||
btn.className.includes(PRIMARY_BUTTON_CLASS),
|
||||
const saveButton = logsRow.querySelector(
|
||||
'button:not([disabled])',
|
||||
) as HTMLButtonElement;
|
||||
|
||||
expect(saveButton).toBeInTheDocument();
|
||||
|
||||
// Wait for button to be enabled (ensures all state updates have settled)
|
||||
// Wait for button to be enabled
|
||||
await waitFor(() => {
|
||||
expect(saveButton).not.toBeDisabled();
|
||||
});
|
||||
@@ -312,22 +314,21 @@ describe('GeneralSettings - S3 Logs Retention', () => {
|
||||
/>,
|
||||
);
|
||||
|
||||
// Find the Logs card
|
||||
const logsCard = screen.getByText('Logs').closest('.ant-card');
|
||||
const inputs = logsCard?.querySelectorAll('input[type="text"]');
|
||||
const logsRow = getLogsRow();
|
||||
const inputs = logsRow.querySelectorAll('input[type="number"]');
|
||||
|
||||
// Total retention: 720 hours = 30 days = 1 month (displays as 1 Month)
|
||||
const totalInput = inputs?.[0] as HTMLInputElement;
|
||||
// Total retention: 30 days = 1 month (displays as 1 Month)
|
||||
const totalInput = inputs[0] as HTMLInputElement;
|
||||
expect(totalInput.value).toBe('1');
|
||||
|
||||
// S3 retention: 24 day
|
||||
const s3Input = inputs?.[1] as HTMLInputElement;
|
||||
// S3 retention: 24 days
|
||||
const s3Input = inputs[1] as HTMLInputElement;
|
||||
expect(s3Input.value).toBe('24');
|
||||
|
||||
// Verify dropdowns: total shows Months, S3 shows Days
|
||||
const dropdowns = logsCard?.querySelectorAll('.ant-select-selection-item');
|
||||
expect(dropdowns?.[0]).toHaveTextContent('Months');
|
||||
expect(dropdowns?.[1]).toHaveTextContent('Days');
|
||||
const dropdowns = logsRow.querySelectorAll('.ant-select-selection-item');
|
||||
expect(dropdowns[0]).toHaveTextContent('Months');
|
||||
expect(dropdowns[1]).toHaveTextContent('Days');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -347,24 +348,22 @@ describe('GeneralSettings - S3 Logs Retention', () => {
|
||||
/>,
|
||||
);
|
||||
|
||||
// Find the Logs card
|
||||
const logsCard = screen.getByText('Logs').closest('.ant-card');
|
||||
expect(logsCard).toBeInTheDocument();
|
||||
const logsRow = getLogsRow();
|
||||
expect(logsRow).toBeInTheDocument();
|
||||
|
||||
// Find the save button
|
||||
const buttons = logsCard?.querySelectorAll(BUTTON_SELECTOR);
|
||||
const saveButton = Array.from(buttons || []).find((btn) =>
|
||||
btn.className.includes(PRIMARY_BUTTON_CLASS),
|
||||
// Find the save button by accessible name within the Logs row
|
||||
const allSaveButtons = screen.getAllByRole('button', { name: /save/i });
|
||||
const saveButton = allSaveButtons.find((btn) =>
|
||||
logsRow.contains(btn),
|
||||
) as HTMLButtonElement;
|
||||
|
||||
expect(saveButton).toBeInTheDocument();
|
||||
|
||||
// Verify save button is disabled on initial load (no changes, S3 disabled with -1)
|
||||
// Verify save button is disabled on initial load
|
||||
expect(saveButton).toBeDisabled();
|
||||
|
||||
// Find the total retention input
|
||||
const inputs = logsCard?.querySelectorAll('input[type="text"]');
|
||||
const totalInput = inputs?.[0] as HTMLInputElement;
|
||||
const inputs = logsRow.querySelectorAll('input[type="number"]');
|
||||
const totalInput = inputs[0] as HTMLInputElement;
|
||||
|
||||
// Change total retention value to trigger button enable
|
||||
await user.clear(totalInput);
|
||||
@@ -385,4 +384,62 @@ describe('GeneralSettings - S3 Logs Retention', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Cloud User Rendering', () => {
|
||||
beforeEach(() => {
|
||||
(useGetTenantLicense as jest.Mock).mockReturnValue({
|
||||
isCloudUser: true,
|
||||
isEnterpriseSelfHostedUser: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('should render CustomDomainSettings and GeneralSettingsCloud for cloud admin', () => {
|
||||
render(
|
||||
<GeneralSettings
|
||||
metricsTtlValuesPayload={mockMetricsRetention}
|
||||
tracesTtlValuesPayload={mockTracesRetention}
|
||||
logsTtlValuesPayload={mockLogsRetentionWithS3}
|
||||
getAvailableDiskPayload={mockDisksWithS3}
|
||||
metricsTtlValuesRefetch={jest.fn()}
|
||||
tracesTtlValuesRefetch={jest.fn()}
|
||||
logsTtlValuesRefetch={jest.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('custom-domain-settings')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('general-settings-cloud')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Enterprise Self-Hosted User Rendering', () => {
|
||||
beforeEach(() => {
|
||||
(useGetTenantLicense as jest.Mock).mockReturnValue({
|
||||
isCloudUser: false,
|
||||
isEnterpriseSelfHostedUser: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('should render CustomDomainSettings but not GeneralSettingsCloud', () => {
|
||||
render(
|
||||
<GeneralSettings
|
||||
metricsTtlValuesPayload={mockMetricsRetention}
|
||||
tracesTtlValuesPayload={mockTracesRetention}
|
||||
logsTtlValuesPayload={mockLogsRetentionWithS3}
|
||||
getAvailableDiskPayload={mockDisksWithS3}
|
||||
metricsTtlValuesRefetch={jest.fn()}
|
||||
tracesTtlValuesRefetch={jest.fn()}
|
||||
logsTtlValuesRefetch={jest.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('custom-domain-settings')).toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByTestId('general-settings-cloud'),
|
||||
).not.toBeInTheDocument();
|
||||
|
||||
// Save buttons should be visible for self-hosted
|
||||
const saveButtons = screen.getAllByRole('button', { name: /save/i });
|
||||
expect(saveButtons.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
.general-settings-container {
|
||||
margin: 16px 8px;
|
||||
margin: 16px 0px;
|
||||
|
||||
.ant-card-body {
|
||||
display: flex;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Button, Skeleton, Tag, Typography } from 'antd';
|
||||
import { useMemo } from 'react';
|
||||
import { Button } from '@signozhq/button';
|
||||
import { Skeleton } from 'antd';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import { useGetHosts } from 'api/generated/services/zeus';
|
||||
import ROUTES from 'constants/routes';
|
||||
@@ -29,77 +30,60 @@ function DataSourceInfo({
|
||||
query: { enabled: isEnabled || false },
|
||||
});
|
||||
|
||||
const [url, setUrl] = useState<string>('');
|
||||
const activeHost = useMemo(
|
||||
() =>
|
||||
hostsData?.data?.hosts?.find((h) => !h.is_default) ??
|
||||
hostsData?.data?.hosts?.find((h) => h.is_default),
|
||||
[hostsData],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (hostsData) {
|
||||
const defaultHost = hostsData?.data.hosts?.find((h) => h.is_default);
|
||||
if (defaultHost?.url) {
|
||||
const url = defaultHost?.url?.split('://')[1] ?? '';
|
||||
setUrl(url);
|
||||
}
|
||||
const url = useMemo(() => activeHost?.url?.split('://')[1] ?? '', [
|
||||
activeHost,
|
||||
]);
|
||||
|
||||
const handleConnect = (): void => {
|
||||
logEvent('Homepage: Connect dataSource clicked', {});
|
||||
|
||||
if (activeLicense && activeLicense.platform === LicensePlatform.CLOUD) {
|
||||
history.push(ROUTES.GET_STARTED_WITH_CLOUD);
|
||||
} else {
|
||||
window?.open(DOCS_LINKS.ADD_DATA_SOURCE, '_blank', 'noopener noreferrer');
|
||||
}
|
||||
}, [hostsData]);
|
||||
};
|
||||
|
||||
const renderNotSendingData = (): JSX.Element => (
|
||||
<>
|
||||
<Typography className="welcome-title">
|
||||
<h2 className="welcome-title">
|
||||
Hello there, Welcome to your SigNoz workspace
|
||||
</Typography>
|
||||
</h2>
|
||||
|
||||
<Typography className="welcome-description">
|
||||
<p className="welcome-description">
|
||||
You’re not sending any data yet. <br />
|
||||
SigNoz is so much better with your data ⎯ start by sending your telemetry
|
||||
data to SigNoz.
|
||||
</Typography>
|
||||
</p>
|
||||
|
||||
<Card className="welcome-card">
|
||||
<Card.Content>
|
||||
<div className="workspace-ready-container">
|
||||
<div className="workspace-ready-header">
|
||||
<Typography className="workspace-ready-title">
|
||||
<span className="workspace-ready-title">
|
||||
<img src="/Icons/hurray.svg" alt="hurray" />
|
||||
Your workspace is ready
|
||||
</Typography>
|
||||
</span>
|
||||
|
||||
<Button
|
||||
type="primary"
|
||||
variant="solid"
|
||||
color="primary"
|
||||
size="sm"
|
||||
className="periscope-btn primary"
|
||||
icon={<img src="/Icons/container-plus.svg" alt="plus" />}
|
||||
prefixIcon={<img src="/Icons/container-plus.svg" alt="plus" />}
|
||||
onClick={handleConnect}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={(): void => {
|
||||
logEvent('Homepage: Connect dataSource clicked', {});
|
||||
|
||||
if (
|
||||
activeLicense &&
|
||||
activeLicense.platform === LicensePlatform.CLOUD
|
||||
) {
|
||||
history.push(ROUTES.GET_STARTED_WITH_CLOUD);
|
||||
} else {
|
||||
window?.open(
|
||||
DOCS_LINKS.ADD_DATA_SOURCE,
|
||||
'_blank',
|
||||
'noopener noreferrer',
|
||||
);
|
||||
}
|
||||
}}
|
||||
onKeyDown={(e): void => {
|
||||
if (e.key === 'Enter') {
|
||||
logEvent('Homepage: Connect dataSource clicked', {});
|
||||
|
||||
if (
|
||||
activeLicense &&
|
||||
activeLicense.platform === LicensePlatform.CLOUD
|
||||
) {
|
||||
history.push(ROUTES.GET_STARTED_WITH_CLOUD);
|
||||
} else {
|
||||
window?.open(
|
||||
DOCS_LINKS.ADD_DATA_SOURCE,
|
||||
'_blank',
|
||||
'noopener noreferrer',
|
||||
);
|
||||
}
|
||||
handleConnect();
|
||||
}
|
||||
}}
|
||||
>
|
||||
@@ -112,13 +96,7 @@ function DataSourceInfo({
|
||||
<div className="workspace-url">
|
||||
<Link2 size={12} />
|
||||
|
||||
<Typography className="workspace-url-text">
|
||||
{url}
|
||||
|
||||
<Tag color="default" className="workspace-url-tag">
|
||||
default
|
||||
</Tag>
|
||||
</Typography>
|
||||
<span className="workspace-url-text">{url}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -130,9 +108,9 @@ function DataSourceInfo({
|
||||
|
||||
const renderDataReceived = (): JSX.Element => (
|
||||
<>
|
||||
<Typography className="welcome-title">
|
||||
<h2 className="welcome-title">
|
||||
Hello there, Welcome to your SigNoz workspace
|
||||
</Typography>
|
||||
</h2>
|
||||
|
||||
{!isError && hostsData && (
|
||||
<Card className="welcome-card">
|
||||
@@ -142,13 +120,7 @@ function DataSourceInfo({
|
||||
<div className="workspace-url">
|
||||
<Link2 size={12} />
|
||||
|
||||
<Typography className="workspace-url-text">
|
||||
{url}
|
||||
|
||||
<Tag color="default" className="workspace-url-tag">
|
||||
default
|
||||
</Tag>
|
||||
</Typography>
|
||||
<span className="workspace-url-text">{url}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -30,7 +30,7 @@ const mockHostsResponse: GetHosts200 = {
|
||||
describe('DataSourceInfo', () => {
|
||||
afterEach(() => server.resetHandlers());
|
||||
|
||||
it('renders the default workspace URL with protocol stripped', async () => {
|
||||
it('renders the active workspace URL with protocol stripped', async () => {
|
||||
server.use(
|
||||
rest.get(ZEUS_HOSTS_ENDPOINT, (_, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json(mockHostsResponse)),
|
||||
@@ -39,7 +39,7 @@ describe('DataSourceInfo', () => {
|
||||
|
||||
render(<DataSourceInfo dataSentToSigNoz={false} isLoading={false} />);
|
||||
|
||||
await screen.findByText(/accepted-starfish\.test\.cloud/i);
|
||||
await screen.findByText(/custom-host\.test\.cloud/i);
|
||||
});
|
||||
|
||||
it('does not render workspace URL when GET /zeus/hosts fails', async () => {
|
||||
@@ -55,7 +55,7 @@ describe('DataSourceInfo', () => {
|
||||
expect(screen.queryByText(/signoz\.cloud/i)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders workspace URL in the data-received view when telemetry is flowing', async () => {
|
||||
it('renders active workspace URL in the data-received view when telemetry is flowing', async () => {
|
||||
server.use(
|
||||
rest.get(ZEUS_HOSTS_ENDPOINT, (_, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json(mockHostsResponse)),
|
||||
@@ -64,6 +64,6 @@ describe('DataSourceInfo', () => {
|
||||
|
||||
render(<DataSourceInfo dataSentToSigNoz={true} isLoading={false} />);
|
||||
|
||||
await screen.findByText(/accepted-starfish\.test\.cloud/i);
|
||||
await screen.findByText(/custom-host\.test\.cloud/i);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -296,19 +296,11 @@
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
|
||||
.workspace-url-tag {
|
||||
font-size: 10px;
|
||||
font-weight: 400;
|
||||
line-height: 18px; /* 150% */
|
||||
letter-spacing: 0.12px;
|
||||
|
||||
border-radius: 3px;
|
||||
|
||||
border: 1px solid var(--Slate-400, #1d212d);
|
||||
background: var(--Ink-400, #121317);
|
||||
color: var(--Vanilla-400, #c0c1c3);
|
||||
}
|
||||
font-size: 11px;
|
||||
font-weight: 400;
|
||||
line-height: 18px;
|
||||
letter-spacing: 0.12px;
|
||||
color: var(--foreground);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -13,7 +13,6 @@ import {
|
||||
DraftingCompass,
|
||||
FileKey2,
|
||||
Github,
|
||||
Globe,
|
||||
HardDrive,
|
||||
Home,
|
||||
Key,
|
||||
@@ -326,13 +325,7 @@ export const settingsMenuItems: SidebarItem[] = [
|
||||
isEnabled: false,
|
||||
itemKey: 'members-sso',
|
||||
},
|
||||
{
|
||||
key: ROUTES.CUSTOM_DOMAIN_SETTINGS,
|
||||
label: 'Custom Domain',
|
||||
icon: <Globe size={16} />,
|
||||
isEnabled: false,
|
||||
itemKey: 'custom-domain',
|
||||
},
|
||||
|
||||
{
|
||||
key: ROUTES.INTEGRATIONS,
|
||||
label: 'Integrations',
|
||||
|
||||
@@ -19,7 +19,6 @@ const breadcrumbNameMap: Record<string, string> = {
|
||||
[ROUTES.ORG_SETTINGS]: 'Organization Settings',
|
||||
[ROUTES.INGESTION_SETTINGS]: 'Ingestion Settings',
|
||||
[ROUTES.MY_SETTINGS]: 'My Settings',
|
||||
[ROUTES.CUSTOM_DOMAIN_SETTINGS]: 'Custom Domain Settings',
|
||||
[ROUTES.ERROR_DETAIL]: 'Exceptions',
|
||||
[ROUTES.LIST_ALL_ALERT]: 'Alerts',
|
||||
[ROUTES.ALL_DASHBOARD]: 'Dashboard',
|
||||
|
||||
@@ -154,7 +154,6 @@ export const routesToSkip = [
|
||||
ROUTES.ALL_DASHBOARD,
|
||||
ROUTES.ORG_SETTINGS,
|
||||
ROUTES.INGESTION_SETTINGS,
|
||||
ROUTES.CUSTOM_DOMAIN_SETTINGS,
|
||||
ROUTES.API_KEYS,
|
||||
ROUTES.ERROR_DETAIL,
|
||||
ROUTES.LOGS_PIPELINES,
|
||||
|
||||
@@ -79,7 +79,6 @@ function SettingsPage(): JSX.Element {
|
||||
item.key === ROUTES.BILLING ||
|
||||
item.key === ROUTES.ROLES_SETTINGS ||
|
||||
item.key === ROUTES.INTEGRATIONS ||
|
||||
item.key === ROUTES.CUSTOM_DOMAIN_SETTINGS ||
|
||||
item.key === ROUTES.API_KEYS ||
|
||||
item.key === ROUTES.INGESTION_SETTINGS ||
|
||||
item.key === ROUTES.ORG_SETTINGS ||
|
||||
|
||||
@@ -5,7 +5,6 @@ import APIKeys from 'container/APIKeys/APIKeys';
|
||||
import BillingContainer from 'container/BillingContainer/BillingContainer';
|
||||
import CreateAlertChannels from 'container/CreateAlertChannels';
|
||||
import { ChannelType } from 'container/CreateAlertChannels/config';
|
||||
import CustomDomainSettings from 'container/CustomDomainSettings';
|
||||
import GeneralSettings from 'container/GeneralSettings';
|
||||
import GeneralSettingsCloud from 'container/GeneralSettingsCloud';
|
||||
import IngestionSettings from 'container/IngestionSettings/IngestionSettings';
|
||||
@@ -20,7 +19,6 @@ import {
|
||||
Building,
|
||||
Cpu,
|
||||
CreditCard,
|
||||
Globe,
|
||||
Keyboard,
|
||||
KeySquare,
|
||||
Pencil,
|
||||
@@ -124,19 +122,6 @@ export const apiKeys = (t: TFunction): RouteTabProps['routes'] => [
|
||||
},
|
||||
];
|
||||
|
||||
export const customDomainSettings = (t: TFunction): RouteTabProps['routes'] => [
|
||||
{
|
||||
Component: CustomDomainSettings,
|
||||
name: (
|
||||
<div className="periscope-tab">
|
||||
<Globe size={16} /> {t('routes:custom_domain_settings').toString()}
|
||||
</div>
|
||||
),
|
||||
route: ROUTES.CUSTOM_DOMAIN_SETTINGS,
|
||||
key: ROUTES.CUSTOM_DOMAIN_SETTINGS,
|
||||
},
|
||||
];
|
||||
|
||||
export const billingSettings = (t: TFunction): RouteTabProps['routes'] => [
|
||||
{
|
||||
Component: BillingContainer,
|
||||
|
||||
@@ -7,7 +7,6 @@ import {
|
||||
apiKeys,
|
||||
billingSettings,
|
||||
createAlertChannels,
|
||||
customDomainSettings,
|
||||
editAlertChannels,
|
||||
generalSettings,
|
||||
ingestionSettings,
|
||||
@@ -64,7 +63,7 @@ export const getRoutes = (
|
||||
}
|
||||
|
||||
if ((isCloudUser || isEnterpriseSelfHostedUser) && isAdmin) {
|
||||
settings.push(...customDomainSettings(t), ...billingSettings(t));
|
||||
settings.push(...billingSettings(t));
|
||||
}
|
||||
|
||||
if (isAdmin) {
|
||||
|
||||
@@ -106,7 +106,6 @@ export const routePermission: Record<keyof typeof ROUTES, ROLES[]> = {
|
||||
TRACES_FUNNELS: ['ADMIN', 'EDITOR', 'VIEWER'],
|
||||
TRACES_FUNNELS_DETAIL: ['ADMIN', 'EDITOR', 'VIEWER'],
|
||||
API_KEYS: ['ADMIN'],
|
||||
CUSTOM_DOMAIN_SETTINGS: ['ADMIN'],
|
||||
LOGS_BASE: ['ADMIN', 'EDITOR', 'VIEWER'],
|
||||
OLD_LOGS_EXPLORER: ['ADMIN', 'EDITOR', 'VIEWER'],
|
||||
SHORTCUTS: ['ADMIN', 'EDITOR', 'VIEWER'],
|
||||
|
||||
@@ -5066,6 +5066,20 @@
|
||||
resolved "https://registry.yarnpkg.com/@signozhq/design-tokens/-/design-tokens-2.1.1.tgz#9c36d433fd264410713cc0c5ebdd75ce0ebecba3"
|
||||
integrity sha512-SdziCHg5Lwj+6oY6IRUPplaKZ+kTHjbrlhNj//UoAJ8aQLnRdR2F/miPzfSi4vrYw88LtXxNA9J9iJyacCp37A==
|
||||
|
||||
"@signozhq/dialog@0.0.2":
|
||||
version "0.0.2"
|
||||
resolved "https://registry.yarnpkg.com/@signozhq/dialog/-/dialog-0.0.2.tgz#55bd8e693f76325fda9aabe3197350e0adc163c4"
|
||||
integrity sha512-YT5t3oZpGkAuWptTqhCgVtLjxsRQrEIrQHFoXpP9elM1+O4TS9WHr+07BLQutOVg6u9n9pCvW3OYf0SCETkDVQ==
|
||||
dependencies:
|
||||
"@radix-ui/react-dialog" "^1.1.11"
|
||||
"@radix-ui/react-icons" "^1.3.0"
|
||||
"@radix-ui/react-slot" "^1.1.0"
|
||||
class-variance-authority "^0.7.0"
|
||||
clsx "^2.1.1"
|
||||
lucide-react "^0.445.0"
|
||||
tailwind-merge "^2.5.2"
|
||||
tailwindcss-animate "^1.0.7"
|
||||
|
||||
"@signozhq/icons@0.1.0", "@signozhq/icons@^0.1.0":
|
||||
version "0.1.0"
|
||||
resolved "https://registry.yarnpkg.com/@signozhq/icons/-/icons-0.1.0.tgz#00dfb430dbac423bfff715876f91a7b8a72509e4"
|
||||
@@ -6205,10 +6219,10 @@
|
||||
"@types/history" "^4.7.11"
|
||||
"@types/react" "*"
|
||||
|
||||
"@types/react-syntax-highlighter@15.5.7":
|
||||
version "15.5.7"
|
||||
resolved "https://registry.yarnpkg.com/@types/react-syntax-highlighter/-/react-syntax-highlighter-15.5.7.tgz#bd29020ccb118543d88779848f99059b64b02d0f"
|
||||
integrity sha512-bo5fEO5toQeyCp0zVHBeggclqf5SQ/Z5blfFmjwO5dkMVGPgmiwZsJh9nu/Bo5L7IHTuGWrja6LxJVE2uB5ZrQ==
|
||||
"@types/react-syntax-highlighter@15.5.13":
|
||||
version "15.5.13"
|
||||
resolved "https://registry.yarnpkg.com/@types/react-syntax-highlighter/-/react-syntax-highlighter-15.5.13.tgz#c5baf62a3219b3bf28d39cfea55d0a49a263d1f2"
|
||||
integrity sha512-uLGJ87j6Sz8UaBAooU0T6lWJ0dBmjZgN1PZTrj05TNql2/XpC6+4HhMT5syIdFUUt+FASfCeLLv4kBygNU+8qA==
|
||||
dependencies:
|
||||
"@types/react" "*"
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ import (
|
||||
"github.com/SigNoz/signoz/pkg/sqlstore"
|
||||
"github.com/SigNoz/signoz/pkg/types"
|
||||
"github.com/SigNoz/signoz/pkg/types/authtypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/savedviewtypes"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
)
|
||||
|
||||
@@ -24,7 +25,7 @@ func NewModule(sqlstore sqlstore.SQLStore) savedview.Module {
|
||||
}
|
||||
|
||||
func (module *module) GetViewsForFilters(ctx context.Context, orgID string, sourcePage string, name string, category string) ([]*v3.SavedView, error) {
|
||||
var views []types.SavedView
|
||||
var views []savedviewtypes.SavedView
|
||||
var err error
|
||||
if len(category) == 0 {
|
||||
err = module.sqlstore.BunDB().NewSelect().Model(&views).Where("org_id = ? AND source_page = ? AND name LIKE ?", orgID, sourcePage, "%"+name+"%").Scan(ctx)
|
||||
@@ -76,7 +77,7 @@ func (module *module) CreateView(ctx context.Context, orgID string, view v3.Save
|
||||
createBy := claims.Email
|
||||
updatedBy := claims.Email
|
||||
|
||||
dbView := types.SavedView{
|
||||
dbView := savedviewtypes.SavedView{
|
||||
TimeAuditable: types.TimeAuditable{
|
||||
CreatedAt: createdAt,
|
||||
UpdatedAt: updatedAt,
|
||||
@@ -105,7 +106,7 @@ func (module *module) CreateView(ctx context.Context, orgID string, view v3.Save
|
||||
}
|
||||
|
||||
func (module *module) GetView(ctx context.Context, orgID string, uuid valuer.UUID) (*v3.SavedView, error) {
|
||||
var view types.SavedView
|
||||
var view savedviewtypes.SavedView
|
||||
err := module.sqlstore.BunDB().NewSelect().Model(&view).Where("org_id = ? AND id = ?", orgID, uuid.StringValue()).Scan(ctx)
|
||||
if err != nil {
|
||||
return nil, errors.WrapInternalf(err, errors.CodeInternal, "error in getting saved view")
|
||||
@@ -146,7 +147,7 @@ func (module *module) UpdateView(ctx context.Context, orgID string, uuid valuer.
|
||||
updatedBy := claims.Email
|
||||
|
||||
_, err = module.sqlstore.BunDB().NewUpdate().
|
||||
Model(&types.SavedView{}).
|
||||
Model(&savedviewtypes.SavedView{}).
|
||||
Set("updated_at = ?, updated_by = ?, name = ?, category = ?, source_page = ?, tags = ?, data = ?, extra_data = ?",
|
||||
updatedAt, updatedBy, view.Name, view.Category, view.SourcePage, strings.Join(view.Tags, ","), data, view.ExtraData).
|
||||
Where("id = ?", uuid.StringValue()).
|
||||
@@ -160,7 +161,7 @@ func (module *module) UpdateView(ctx context.Context, orgID string, uuid valuer.
|
||||
|
||||
func (module *module) DeleteView(ctx context.Context, orgID string, uuid valuer.UUID) error {
|
||||
_, err := module.sqlstore.BunDB().NewDelete().
|
||||
Model(&types.SavedView{}).
|
||||
Model(&savedviewtypes.SavedView{}).
|
||||
Where("id = ?", uuid.StringValue()).
|
||||
Where("org_id = ?", orgID).
|
||||
Exec(ctx)
|
||||
@@ -171,7 +172,7 @@ func (module *module) DeleteView(ctx context.Context, orgID string, uuid valuer.
|
||||
}
|
||||
|
||||
func (module *module) Collect(ctx context.Context, orgID valuer.UUID) (map[string]any, error) {
|
||||
savedViews := []*types.SavedView{}
|
||||
savedViews := []*savedviewtypes.SavedView{}
|
||||
|
||||
err := module.
|
||||
sqlstore.
|
||||
@@ -184,5 +185,5 @@ func (module *module) Collect(ctx context.Context, orgID valuer.UUID) (map[strin
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return types.NewStatsFromSavedViews(savedViews), nil
|
||||
return savedviewtypes.NewStatsFromSavedViews(savedViews), nil
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ import (
|
||||
root "github.com/SigNoz/signoz/pkg/modules/user"
|
||||
"github.com/SigNoz/signoz/pkg/types"
|
||||
"github.com/SigNoz/signoz/pkg/types/authtypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/integrationtypes"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
"github.com/gorilla/mux"
|
||||
)
|
||||
@@ -462,7 +463,7 @@ func (h *handler) UpdateAPIKey(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
if slices.Contains(types.AllIntegrationUserEmails, types.IntegrationUserEmail(createdByUser.Email.String())) {
|
||||
if slices.Contains(integrationtypes.AllIntegrationUserEmails, integrationtypes.IntegrationUserEmail(createdByUser.Email.String())) {
|
||||
render.Error(w, errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "API Keys for integration users cannot be revoked"))
|
||||
return
|
||||
}
|
||||
@@ -507,7 +508,7 @@ func (h *handler) RevokeAPIKey(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
if slices.Contains(types.AllIntegrationUserEmails, types.IntegrationUserEmail(createdByUser.Email.String())) {
|
||||
if slices.Contains(integrationtypes.AllIntegrationUserEmails, integrationtypes.IntegrationUserEmail(createdByUser.Email.String())) {
|
||||
render.Error(w, errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "API Keys for integration users cannot be revoked"))
|
||||
return
|
||||
}
|
||||
|
||||
@@ -19,6 +19,7 @@ import (
|
||||
"github.com/SigNoz/signoz/pkg/types"
|
||||
"github.com/SigNoz/signoz/pkg/types/authtypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/emailtypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/integrationtypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/roletypes"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
"github.com/dustin/go-humanize"
|
||||
@@ -279,7 +280,7 @@ func (module *Module) DeleteUser(ctx context.Context, orgID valuer.UUID, id stri
|
||||
return errors.WithAdditionalf(err, "cannot delete root user")
|
||||
}
|
||||
|
||||
if slices.Contains(types.AllIntegrationUserEmails, types.IntegrationUserEmail(user.Email.String())) {
|
||||
if slices.Contains(integrationtypes.AllIntegrationUserEmails, integrationtypes.IntegrationUserEmail(user.Email.String())) {
|
||||
return errors.New(errors.TypeForbidden, errors.CodeForbidden, "integration user cannot be deleted")
|
||||
}
|
||||
|
||||
|
||||
@@ -10,15 +10,16 @@ import (
|
||||
"github.com/SigNoz/signoz/pkg/query-service/model"
|
||||
"github.com/SigNoz/signoz/pkg/sqlstore"
|
||||
"github.com/SigNoz/signoz/pkg/types"
|
||||
"github.com/SigNoz/signoz/pkg/types/integrationtypes"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
)
|
||||
|
||||
type cloudProviderAccountsRepository interface {
|
||||
listConnected(ctx context.Context, orgId string, provider string) ([]types.CloudIntegration, *model.ApiError)
|
||||
listConnected(ctx context.Context, orgId string, provider string) ([]integrationtypes.CloudIntegration, *model.ApiError)
|
||||
|
||||
get(ctx context.Context, orgId string, provider string, id string) (*types.CloudIntegration, *model.ApiError)
|
||||
get(ctx context.Context, orgId string, provider string, id string) (*integrationtypes.CloudIntegration, *model.ApiError)
|
||||
|
||||
getConnectedCloudAccount(ctx context.Context, orgId string, provider string, accountID string) (*types.CloudIntegration, *model.ApiError)
|
||||
getConnectedCloudAccount(ctx context.Context, orgId string, provider string, accountID string) (*integrationtypes.CloudIntegration, *model.ApiError)
|
||||
|
||||
// Insert an account or update it by (cloudProvider, id)
|
||||
// for specified non-empty fields
|
||||
@@ -27,11 +28,11 @@ type cloudProviderAccountsRepository interface {
|
||||
orgId string,
|
||||
provider string,
|
||||
id *string,
|
||||
config *types.AccountConfig,
|
||||
config *integrationtypes.AccountConfig,
|
||||
accountId *string,
|
||||
agentReport *types.AgentReport,
|
||||
agentReport *integrationtypes.AgentReport,
|
||||
removedAt *time.Time,
|
||||
) (*types.CloudIntegration, *model.ApiError)
|
||||
) (*integrationtypes.CloudIntegration, *model.ApiError)
|
||||
}
|
||||
|
||||
func newCloudProviderAccountsRepository(store sqlstore.SQLStore) (
|
||||
@@ -48,8 +49,8 @@ type cloudProviderAccountsSQLRepository struct {
|
||||
|
||||
func (r *cloudProviderAccountsSQLRepository) listConnected(
|
||||
ctx context.Context, orgId string, cloudProvider string,
|
||||
) ([]types.CloudIntegration, *model.ApiError) {
|
||||
accounts := []types.CloudIntegration{}
|
||||
) ([]integrationtypes.CloudIntegration, *model.ApiError) {
|
||||
accounts := []integrationtypes.CloudIntegration{}
|
||||
|
||||
err := r.store.BunDB().NewSelect().
|
||||
Model(&accounts).
|
||||
@@ -72,8 +73,8 @@ func (r *cloudProviderAccountsSQLRepository) listConnected(
|
||||
|
||||
func (r *cloudProviderAccountsSQLRepository) get(
|
||||
ctx context.Context, orgId string, provider string, id string,
|
||||
) (*types.CloudIntegration, *model.ApiError) {
|
||||
var result types.CloudIntegration
|
||||
) (*integrationtypes.CloudIntegration, *model.ApiError) {
|
||||
var result integrationtypes.CloudIntegration
|
||||
|
||||
err := r.store.BunDB().NewSelect().
|
||||
Model(&result).
|
||||
@@ -97,8 +98,8 @@ func (r *cloudProviderAccountsSQLRepository) get(
|
||||
|
||||
func (r *cloudProviderAccountsSQLRepository) getConnectedCloudAccount(
|
||||
ctx context.Context, orgId string, provider string, accountId string,
|
||||
) (*types.CloudIntegration, *model.ApiError) {
|
||||
var result types.CloudIntegration
|
||||
) (*integrationtypes.CloudIntegration, *model.ApiError) {
|
||||
var result integrationtypes.CloudIntegration
|
||||
|
||||
err := r.store.BunDB().NewSelect().
|
||||
Model(&result).
|
||||
@@ -127,11 +128,11 @@ func (r *cloudProviderAccountsSQLRepository) upsert(
|
||||
orgId string,
|
||||
provider string,
|
||||
id *string,
|
||||
config *types.AccountConfig,
|
||||
config *integrationtypes.AccountConfig,
|
||||
accountId *string,
|
||||
agentReport *types.AgentReport,
|
||||
agentReport *integrationtypes.AgentReport,
|
||||
removedAt *time.Time,
|
||||
) (*types.CloudIntegration, *model.ApiError) {
|
||||
) (*integrationtypes.CloudIntegration, *model.ApiError) {
|
||||
// Insert
|
||||
if id == nil {
|
||||
temp := valuer.GenerateUUID().StringValue()
|
||||
@@ -181,7 +182,7 @@ func (r *cloudProviderAccountsSQLRepository) upsert(
|
||||
)
|
||||
}
|
||||
|
||||
integration := types.CloudIntegration{
|
||||
integration := integrationtypes.CloudIntegration{
|
||||
OrgID: orgId,
|
||||
Provider: provider,
|
||||
Identifiable: types.Identifiable{ID: valuer.MustNewUUID(*id)},
|
||||
|
||||
@@ -14,6 +14,7 @@ import (
|
||||
"github.com/SigNoz/signoz/pkg/sqlstore"
|
||||
"github.com/SigNoz/signoz/pkg/types"
|
||||
"github.com/SigNoz/signoz/pkg/types/dashboardtypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/integrationtypes"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
"golang.org/x/exp/maps"
|
||||
)
|
||||
@@ -52,7 +53,7 @@ func NewController(sqlStore sqlstore.SQLStore) (*Controller, error) {
|
||||
}
|
||||
|
||||
type ConnectedAccountsListResponse struct {
|
||||
Accounts []types.Account `json:"accounts"`
|
||||
Accounts []integrationtypes.Account `json:"accounts"`
|
||||
}
|
||||
|
||||
func (c *Controller) ListConnectedAccounts(ctx context.Context, orgId string, cloudProvider string) (
|
||||
@@ -67,7 +68,7 @@ func (c *Controller) ListConnectedAccounts(ctx context.Context, orgId string, cl
|
||||
return nil, model.WrapApiError(apiErr, "couldn't list cloud accounts")
|
||||
}
|
||||
|
||||
connectedAccounts := []types.Account{}
|
||||
connectedAccounts := []integrationtypes.Account{}
|
||||
for _, a := range accountRecords {
|
||||
connectedAccounts = append(connectedAccounts, a.Account())
|
||||
}
|
||||
@@ -81,7 +82,7 @@ type GenerateConnectionUrlRequest struct {
|
||||
// Optional. To be specified for updates.
|
||||
AccountId *string `json:"account_id,omitempty"`
|
||||
|
||||
AccountConfig types.AccountConfig `json:"account_config"`
|
||||
AccountConfig integrationtypes.AccountConfig `json:"account_config"`
|
||||
|
||||
AgentConfig SigNozAgentConfig `json:"agent_config"`
|
||||
}
|
||||
@@ -149,9 +150,9 @@ func (c *Controller) GenerateConnectionUrl(ctx context.Context, orgId string, cl
|
||||
}
|
||||
|
||||
type AccountStatusResponse struct {
|
||||
Id string `json:"id"`
|
||||
CloudAccountId *string `json:"cloud_account_id,omitempty"`
|
||||
Status types.AccountStatus `json:"status"`
|
||||
Id string `json:"id"`
|
||||
CloudAccountId *string `json:"cloud_account_id,omitempty"`
|
||||
Status integrationtypes.AccountStatus `json:"status"`
|
||||
}
|
||||
|
||||
func (c *Controller) GetAccountStatus(ctx context.Context, orgId string, cloudProvider string, accountId string) (
|
||||
@@ -217,7 +218,7 @@ func (c *Controller) CheckInAsAgent(ctx context.Context, orgId string, cloudProv
|
||||
))
|
||||
}
|
||||
|
||||
agentReport := types.AgentReport{
|
||||
agentReport := integrationtypes.AgentReport{
|
||||
TimestampMillis: time.Now().UnixMilli(),
|
||||
Data: req.Data,
|
||||
}
|
||||
@@ -286,10 +287,10 @@ func (c *Controller) CheckInAsAgent(ctx context.Context, orgId string, cloudProv
|
||||
}
|
||||
|
||||
type UpdateAccountConfigRequest struct {
|
||||
Config types.AccountConfig `json:"config"`
|
||||
Config integrationtypes.AccountConfig `json:"config"`
|
||||
}
|
||||
|
||||
func (c *Controller) UpdateAccountConfig(ctx context.Context, orgId string, cloudProvider string, accountId string, req UpdateAccountConfigRequest) (*types.Account, *model.ApiError) {
|
||||
func (c *Controller) UpdateAccountConfig(ctx context.Context, orgId string, cloudProvider string, accountId string, req UpdateAccountConfigRequest) (*integrationtypes.Account, *model.ApiError) {
|
||||
if apiErr := validateCloudProviderName(cloudProvider); apiErr != nil {
|
||||
return nil, apiErr
|
||||
}
|
||||
@@ -306,7 +307,7 @@ func (c *Controller) UpdateAccountConfig(ctx context.Context, orgId string, clou
|
||||
return &account, nil
|
||||
}
|
||||
|
||||
func (c *Controller) DisconnectAccount(ctx context.Context, orgId string, cloudProvider string, accountId string) (*types.CloudIntegration, *model.ApiError) {
|
||||
func (c *Controller) DisconnectAccount(ctx context.Context, orgId string, cloudProvider string, accountId string) (*integrationtypes.CloudIntegration, *model.ApiError) {
|
||||
if apiErr := validateCloudProviderName(cloudProvider); apiErr != nil {
|
||||
return nil, apiErr
|
||||
}
|
||||
@@ -346,7 +347,7 @@ func (c *Controller) ListServices(
|
||||
return nil, model.WrapApiError(apiErr, "couldn't list cloud services")
|
||||
}
|
||||
|
||||
svcConfigs := map[string]*types.CloudServiceConfig{}
|
||||
svcConfigs := map[string]*integrationtypes.CloudServiceConfig{}
|
||||
if cloudAccountId != nil {
|
||||
activeAccount, apiErr := c.accountsRepo.getConnectedCloudAccount(
|
||||
ctx, orgID, cloudProvider, *cloudAccountId,
|
||||
@@ -441,8 +442,8 @@ func (c *Controller) GetServiceDetails(
|
||||
}
|
||||
|
||||
type UpdateServiceConfigRequest struct {
|
||||
CloudAccountId string `json:"cloud_account_id"`
|
||||
Config types.CloudServiceConfig `json:"config"`
|
||||
CloudAccountId string `json:"cloud_account_id"`
|
||||
Config integrationtypes.CloudServiceConfig `json:"config"`
|
||||
}
|
||||
|
||||
func (u *UpdateServiceConfigRequest) Validate(def *services.Definition) error {
|
||||
@@ -460,8 +461,8 @@ func (u *UpdateServiceConfigRequest) Validate(def *services.Definition) error {
|
||||
}
|
||||
|
||||
type UpdateServiceConfigResponse struct {
|
||||
Id string `json:"id"`
|
||||
Config types.CloudServiceConfig `json:"config"`
|
||||
Id string `json:"id"`
|
||||
Config integrationtypes.CloudServiceConfig `json:"config"`
|
||||
}
|
||||
|
||||
func (c *Controller) UpdateServiceConfig(
|
||||
|
||||
@@ -3,20 +3,20 @@ package cloudintegrations
|
||||
import (
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/query-service/app/cloudintegrations/services"
|
||||
"github.com/SigNoz/signoz/pkg/types"
|
||||
"github.com/SigNoz/signoz/pkg/types/integrationtypes"
|
||||
)
|
||||
|
||||
type ServiceSummary struct {
|
||||
services.Metadata
|
||||
|
||||
Config *types.CloudServiceConfig `json:"config"`
|
||||
Config *integrationtypes.CloudServiceConfig `json:"config"`
|
||||
}
|
||||
|
||||
type ServiceDetails struct {
|
||||
services.Definition
|
||||
|
||||
Config *types.CloudServiceConfig `json:"config"`
|
||||
ConnectionStatus *ServiceConnectionStatus `json:"status,omitempty"`
|
||||
Config *integrationtypes.CloudServiceConfig `json:"config"`
|
||||
ConnectionStatus *ServiceConnectionStatus `json:"status,omitempty"`
|
||||
}
|
||||
|
||||
type AccountStatus struct {
|
||||
@@ -61,7 +61,7 @@ func NewCompiledCollectionStrategy(provider string) (*CompiledCollectionStrategy
|
||||
|
||||
// Helper for accumulating strategies for enabled services.
|
||||
func AddServiceStrategy(serviceType string, cs *CompiledCollectionStrategy,
|
||||
definitionStrat *services.CollectionStrategy, config *types.CloudServiceConfig) error {
|
||||
definitionStrat *services.CollectionStrategy, config *integrationtypes.CloudServiceConfig) error {
|
||||
if definitionStrat.Provider != cs.Provider {
|
||||
return errors.NewInternalf(CodeMismatchCloudProvider, "can't add %s service strategy to compiled strategy for %s",
|
||||
definitionStrat.Provider, cs.Provider)
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"github.com/SigNoz/signoz/pkg/query-service/model"
|
||||
"github.com/SigNoz/signoz/pkg/sqlstore"
|
||||
"github.com/SigNoz/signoz/pkg/types"
|
||||
"github.com/SigNoz/signoz/pkg/types/integrationtypes"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
)
|
||||
|
||||
@@ -18,7 +19,7 @@ type ServiceConfigDatabase interface {
|
||||
orgID string,
|
||||
cloudAccountId string,
|
||||
serviceType string,
|
||||
) (*types.CloudServiceConfig, *model.ApiError)
|
||||
) (*integrationtypes.CloudServiceConfig, *model.ApiError)
|
||||
|
||||
upsert(
|
||||
ctx context.Context,
|
||||
@@ -26,15 +27,15 @@ type ServiceConfigDatabase interface {
|
||||
cloudProvider string,
|
||||
cloudAccountId string,
|
||||
serviceId string,
|
||||
config types.CloudServiceConfig,
|
||||
) (*types.CloudServiceConfig, *model.ApiError)
|
||||
config integrationtypes.CloudServiceConfig,
|
||||
) (*integrationtypes.CloudServiceConfig, *model.ApiError)
|
||||
|
||||
getAllForAccount(
|
||||
ctx context.Context,
|
||||
orgID string,
|
||||
cloudAccountId string,
|
||||
) (
|
||||
configsBySvcId map[string]*types.CloudServiceConfig,
|
||||
configsBySvcId map[string]*integrationtypes.CloudServiceConfig,
|
||||
apiErr *model.ApiError,
|
||||
)
|
||||
}
|
||||
@@ -56,9 +57,9 @@ func (r *serviceConfigSQLRepository) get(
|
||||
orgID string,
|
||||
cloudAccountId string,
|
||||
serviceType string,
|
||||
) (*types.CloudServiceConfig, *model.ApiError) {
|
||||
) (*integrationtypes.CloudServiceConfig, *model.ApiError) {
|
||||
|
||||
var result types.CloudIntegrationService
|
||||
var result integrationtypes.CloudIntegrationService
|
||||
|
||||
err := r.store.BunDB().NewSelect().
|
||||
Model(&result).
|
||||
@@ -89,14 +90,14 @@ func (r *serviceConfigSQLRepository) upsert(
|
||||
cloudProvider string,
|
||||
cloudAccountId string,
|
||||
serviceId string,
|
||||
config types.CloudServiceConfig,
|
||||
) (*types.CloudServiceConfig, *model.ApiError) {
|
||||
config integrationtypes.CloudServiceConfig,
|
||||
) (*integrationtypes.CloudServiceConfig, *model.ApiError) {
|
||||
|
||||
// get cloud integration id from account id
|
||||
// if the account is not connected, we don't need to upsert the config
|
||||
var cloudIntegrationId string
|
||||
err := r.store.BunDB().NewSelect().
|
||||
Model((*types.CloudIntegration)(nil)).
|
||||
Model((*integrationtypes.CloudIntegration)(nil)).
|
||||
Column("id").
|
||||
Where("provider = ?", cloudProvider).
|
||||
Where("account_id = ?", cloudAccountId).
|
||||
@@ -111,7 +112,7 @@ func (r *serviceConfigSQLRepository) upsert(
|
||||
))
|
||||
}
|
||||
|
||||
serviceConfig := types.CloudIntegrationService{
|
||||
serviceConfig := integrationtypes.CloudIntegrationService{
|
||||
Identifiable: types.Identifiable{ID: valuer.GenerateUUID()},
|
||||
TimeAuditable: types.TimeAuditable{
|
||||
CreatedAt: time.Now(),
|
||||
@@ -139,8 +140,8 @@ func (r *serviceConfigSQLRepository) getAllForAccount(
|
||||
ctx context.Context,
|
||||
orgID string,
|
||||
cloudAccountId string,
|
||||
) (map[string]*types.CloudServiceConfig, *model.ApiError) {
|
||||
serviceConfigs := []types.CloudIntegrationService{}
|
||||
) (map[string]*integrationtypes.CloudServiceConfig, *model.ApiError) {
|
||||
serviceConfigs := []integrationtypes.CloudIntegrationService{}
|
||||
|
||||
err := r.store.BunDB().NewSelect().
|
||||
Model(&serviceConfigs).
|
||||
@@ -154,7 +155,7 @@ func (r *serviceConfigSQLRepository) getAllForAccount(
|
||||
))
|
||||
}
|
||||
|
||||
result := map[string]*types.CloudServiceConfig{}
|
||||
result := map[string]*integrationtypes.CloudServiceConfig{}
|
||||
|
||||
for _, r := range serviceConfigs {
|
||||
result[r.Type] = &r.Config
|
||||
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
"github.com/SigNoz/signoz/pkg/sqlstore"
|
||||
"github.com/SigNoz/signoz/pkg/types"
|
||||
"github.com/SigNoz/signoz/pkg/types/dashboardtypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/integrationtypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/pipelinetypes"
|
||||
ruletypes "github.com/SigNoz/signoz/pkg/types/ruletypes"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
@@ -107,7 +108,7 @@ type IntegrationsListItem struct {
|
||||
|
||||
type Integration struct {
|
||||
IntegrationDetails
|
||||
Installation *types.InstalledIntegration `json:"installation"`
|
||||
Installation *integrationtypes.InstalledIntegration `json:"installation"`
|
||||
}
|
||||
|
||||
type Manager struct {
|
||||
@@ -223,7 +224,7 @@ func (m *Manager) InstallIntegration(
|
||||
ctx context.Context,
|
||||
orgId string,
|
||||
integrationId string,
|
||||
config types.InstalledIntegrationConfig,
|
||||
config integrationtypes.InstalledIntegrationConfig,
|
||||
) (*IntegrationsListItem, *model.ApiError) {
|
||||
integrationDetails, apiErr := m.getIntegrationDetails(ctx, integrationId)
|
||||
if apiErr != nil {
|
||||
@@ -429,7 +430,7 @@ func (m *Manager) getInstalledIntegration(
|
||||
ctx context.Context,
|
||||
orgId string,
|
||||
integrationId string,
|
||||
) (*types.InstalledIntegration, *model.ApiError) {
|
||||
) (*integrationtypes.InstalledIntegration, *model.ApiError) {
|
||||
iis, apiErr := m.installedIntegrationsRepo.get(
|
||||
ctx, orgId, []string{integrationId},
|
||||
)
|
||||
@@ -457,7 +458,7 @@ func (m *Manager) getInstalledIntegrations(
|
||||
return nil, apiErr
|
||||
}
|
||||
|
||||
installedTypes := utils.MapSlice(installations, func(i types.InstalledIntegration) string {
|
||||
installedTypes := utils.MapSlice(installations, func(i integrationtypes.InstalledIntegration) string {
|
||||
return i.Type
|
||||
})
|
||||
integrationDetails, apiErr := m.availableIntegrationsRepo.get(ctx, installedTypes)
|
||||
|
||||
@@ -4,22 +4,22 @@ import (
|
||||
"context"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/query-service/model"
|
||||
"github.com/SigNoz/signoz/pkg/types"
|
||||
"github.com/SigNoz/signoz/pkg/types/integrationtypes"
|
||||
)
|
||||
|
||||
type InstalledIntegrationsRepo interface {
|
||||
list(ctx context.Context, orgId string) ([]types.InstalledIntegration, *model.ApiError)
|
||||
list(ctx context.Context, orgId string) ([]integrationtypes.InstalledIntegration, *model.ApiError)
|
||||
|
||||
get(
|
||||
ctx context.Context, orgId string, integrationTypes []string,
|
||||
) (map[string]types.InstalledIntegration, *model.ApiError)
|
||||
) (map[string]integrationtypes.InstalledIntegration, *model.ApiError)
|
||||
|
||||
upsert(
|
||||
ctx context.Context,
|
||||
orgId string,
|
||||
integrationType string,
|
||||
config types.InstalledIntegrationConfig,
|
||||
) (*types.InstalledIntegration, *model.ApiError)
|
||||
config integrationtypes.InstalledIntegrationConfig,
|
||||
) (*integrationtypes.InstalledIntegration, *model.ApiError)
|
||||
|
||||
delete(ctx context.Context, orgId string, integrationType string) *model.ApiError
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"github.com/SigNoz/signoz/pkg/query-service/model"
|
||||
"github.com/SigNoz/signoz/pkg/sqlstore"
|
||||
"github.com/SigNoz/signoz/pkg/types"
|
||||
"github.com/SigNoz/signoz/pkg/types/integrationtypes"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
"github.com/uptrace/bun"
|
||||
)
|
||||
@@ -26,8 +27,8 @@ func NewInstalledIntegrationsSqliteRepo(store sqlstore.SQLStore) (
|
||||
func (r *InstalledIntegrationsSqliteRepo) list(
|
||||
ctx context.Context,
|
||||
orgId string,
|
||||
) ([]types.InstalledIntegration, *model.ApiError) {
|
||||
integrations := []types.InstalledIntegration{}
|
||||
) ([]integrationtypes.InstalledIntegration, *model.ApiError) {
|
||||
integrations := []integrationtypes.InstalledIntegration{}
|
||||
|
||||
err := r.store.BunDB().NewSelect().
|
||||
Model(&integrations).
|
||||
@@ -44,8 +45,8 @@ func (r *InstalledIntegrationsSqliteRepo) list(
|
||||
|
||||
func (r *InstalledIntegrationsSqliteRepo) get(
|
||||
ctx context.Context, orgId string, integrationTypes []string,
|
||||
) (map[string]types.InstalledIntegration, *model.ApiError) {
|
||||
integrations := []types.InstalledIntegration{}
|
||||
) (map[string]integrationtypes.InstalledIntegration, *model.ApiError) {
|
||||
integrations := []integrationtypes.InstalledIntegration{}
|
||||
|
||||
typeValues := []interface{}{}
|
||||
for _, integrationType := range integrationTypes {
|
||||
@@ -62,7 +63,7 @@ func (r *InstalledIntegrationsSqliteRepo) get(
|
||||
))
|
||||
}
|
||||
|
||||
result := map[string]types.InstalledIntegration{}
|
||||
result := map[string]integrationtypes.InstalledIntegration{}
|
||||
for _, ii := range integrations {
|
||||
result[ii.Type] = ii
|
||||
}
|
||||
@@ -74,10 +75,10 @@ func (r *InstalledIntegrationsSqliteRepo) upsert(
|
||||
ctx context.Context,
|
||||
orgId string,
|
||||
integrationType string,
|
||||
config types.InstalledIntegrationConfig,
|
||||
) (*types.InstalledIntegration, *model.ApiError) {
|
||||
config integrationtypes.InstalledIntegrationConfig,
|
||||
) (*integrationtypes.InstalledIntegration, *model.ApiError) {
|
||||
|
||||
integration := types.InstalledIntegration{
|
||||
integration := integrationtypes.InstalledIntegration{
|
||||
Identifiable: types.Identifiable{
|
||||
ID: valuer.GenerateUUID(),
|
||||
},
|
||||
@@ -114,7 +115,7 @@ func (r *InstalledIntegrationsSqliteRepo) delete(
|
||||
ctx context.Context, orgId string, integrationType string,
|
||||
) *model.ApiError {
|
||||
_, dbErr := r.store.BunDB().NewDelete().
|
||||
Model(&types.InstalledIntegration{}).
|
||||
Model(&integrationtypes.InstalledIntegration{}).
|
||||
Where("type = ?", integrationType).
|
||||
Where("org_id = ?", orgId).
|
||||
Exec(ctx)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
package types
|
||||
package integrationtypes
|
||||
|
||||
import (
|
||||
"database/sql/driver"
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/types"
|
||||
"github.com/uptrace/bun"
|
||||
)
|
||||
|
||||
@@ -26,17 +27,17 @@ var AllIntegrationUserEmails = []IntegrationUserEmail{
|
||||
type InstalledIntegration struct {
|
||||
bun.BaseModel `bun:"table:installed_integration"`
|
||||
|
||||
Identifiable
|
||||
types.Identifiable
|
||||
Type string `json:"type" bun:"type,type:text,unique:org_id_type"`
|
||||
Config InstalledIntegrationConfig `json:"config" bun:"config,type:text"`
|
||||
InstalledAt time.Time `json:"installed_at" bun:"installed_at,default:current_timestamp"`
|
||||
OrgID string `json:"org_id" bun:"org_id,type:text,unique:org_id_type,references:organizations(id),on_delete:cascade"`
|
||||
}
|
||||
|
||||
type InstalledIntegrationConfig map[string]interface{}
|
||||
type InstalledIntegrationConfig map[string]any
|
||||
|
||||
// For serializing from db
|
||||
func (c *InstalledIntegrationConfig) Scan(src interface{}) error {
|
||||
func (c *InstalledIntegrationConfig) Scan(src any) error {
|
||||
var data []byte
|
||||
switch v := src.(type) {
|
||||
case []byte:
|
||||
@@ -67,8 +68,8 @@ func (c *InstalledIntegrationConfig) Value() (driver.Value, error) {
|
||||
type CloudIntegration struct {
|
||||
bun.BaseModel `bun:"table:cloud_integration"`
|
||||
|
||||
Identifiable
|
||||
TimeAuditable
|
||||
types.Identifiable
|
||||
types.TimeAuditable
|
||||
Provider string `json:"provider" bun:"provider,type:text,unique:provider_id"`
|
||||
Config *AccountConfig `json:"config" bun:"config,type:text"`
|
||||
AccountID *string `json:"account_id" bun:"account_id,type:text"`
|
||||
@@ -194,8 +195,8 @@ func (r *AgentReport) Value() (driver.Value, error) {
|
||||
type CloudIntegrationService struct {
|
||||
bun.BaseModel `bun:"table:cloud_integration_service,alias:cis"`
|
||||
|
||||
Identifiable
|
||||
TimeAuditable
|
||||
types.Identifiable
|
||||
types.TimeAuditable
|
||||
Type string `bun:"type,type:text,notnull,unique:cloud_integration_id_type"`
|
||||
Config CloudServiceConfig `bun:"config,type:text"`
|
||||
CloudIntegrationID string `bun:"cloud_integration_id,type:text,notnull,unique:cloud_integration_id_type,references:cloud_integrations(id),on_delete:cascade"`
|
||||
@@ -1,17 +1,18 @@
|
||||
package types
|
||||
package savedviewtypes
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/types"
|
||||
"github.com/uptrace/bun"
|
||||
)
|
||||
|
||||
type SavedView struct {
|
||||
bun.BaseModel `bun:"table:saved_views"`
|
||||
|
||||
Identifiable
|
||||
TimeAuditable
|
||||
UserAuditable
|
||||
types.Identifiable
|
||||
types.TimeAuditable
|
||||
types.UserAuditable
|
||||
OrgID string `json:"orgId" bun:"org_id,notnull"`
|
||||
Name string `json:"name" bun:"name,type:text,notnull"`
|
||||
Category string `json:"category" bun:"category,type:text,notnull"`
|
||||
Reference in New Issue
Block a user