Compare commits
9 Commits
v0.80.0-74
...
testing
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fc1483c56a | ||
|
|
940313d28b | ||
|
|
9815ec7d81 | ||
|
|
a7cad0f1a5 | ||
|
|
a624b4758d | ||
|
|
ee5684b130 | ||
|
|
2f8da5957b | ||
|
|
3f6f77d0e2 | ||
|
|
5bceffbeaa |
42
.github/workflows/README.md
vendored
42
.github/workflows/README.md
vendored
@@ -1,42 +0,0 @@
|
||||
# Github actions
|
||||
|
||||
## Testing the UI manually on each PR
|
||||
|
||||
First we need to make sure the UI is ready
|
||||
* Check the `Start tunnel` step in `e2e-k8s/deploy-on-k3s-cluster` job and make sure you see `your url is: https://pull-<number>-signoz.loca.lt`
|
||||
* This job will run until the PR is merged or closed to keep the local tunneling alive
|
||||
- github will cancel this job if the PR wasn't merged after 6h
|
||||
- if the job was cancel, go to the action and press `Re-run all jobs`
|
||||
|
||||
Now you can open your browser at https://pull-<number>-signoz.loca.lt and check the UI.
|
||||
|
||||
## Environment Variables
|
||||
|
||||
To run GitHub workflow, a few environment variables needs to add in GitHub secrets
|
||||
|
||||
<table>
|
||||
<tr>
|
||||
<th> Variables </th>
|
||||
<th> Description </th>
|
||||
<th> Example </th>
|
||||
</tr>
|
||||
<tr>
|
||||
<td> REPONAME </td>
|
||||
<td> Provide the DockerHub user/organisation name of the image. </td>
|
||||
<td> signoz</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td> DOCKERHUB_USERNAME </td>
|
||||
<td> Docker hub username </td>
|
||||
<td> signoz</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td> DOCKERHUB_TOKEN </td>
|
||||
<td> Docker hub password/token with push permission </td>
|
||||
<td> **** </td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td> SONAR_TOKEN </td>
|
||||
<td> <a href="https://sonarcloud.io">SonarCloud</a> token </td>
|
||||
<td> **** </td>
|
||||
</tr>
|
||||
16
.github/workflows/remove-label.yaml
vendored
16
.github/workflows/remove-label.yaml
vendored
@@ -1,16 +0,0 @@
|
||||
name: remove-label
|
||||
|
||||
on:
|
||||
pull_request_target:
|
||||
types: [synchronize]
|
||||
|
||||
jobs:
|
||||
remove:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Remove label testing-deploy from PR
|
||||
uses: buildsville/add-remove-label@v2.0.0
|
||||
with:
|
||||
label: testing-deploy
|
||||
type: remove
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
@@ -103,6 +103,13 @@ telemetrystore:
|
||||
clickhouse:
|
||||
# The DSN to use for clickhouse.
|
||||
dsn: tcp://localhost:9000
|
||||
# The query settings for clickhouse.
|
||||
settings:
|
||||
max_execution_time: 0
|
||||
max_execution_time_leaf: 0
|
||||
timeout_before_checking_execution_speed: 0
|
||||
max_bytes_to_read: 0
|
||||
max_result_rows_for_ch_query: 0
|
||||
|
||||
##################### Prometheus #####################
|
||||
prometheus:
|
||||
|
||||
@@ -13,9 +13,6 @@ import (
|
||||
"github.com/SigNoz/signoz/pkg/alertmanager"
|
||||
"github.com/SigNoz/signoz/pkg/apis/fields"
|
||||
"github.com/SigNoz/signoz/pkg/http/middleware"
|
||||
"github.com/SigNoz/signoz/pkg/modules/organization/implorganization"
|
||||
"github.com/SigNoz/signoz/pkg/modules/preference"
|
||||
preferencecore "github.com/SigNoz/signoz/pkg/modules/preference/core"
|
||||
baseapp "github.com/SigNoz/signoz/pkg/query-service/app"
|
||||
"github.com/SigNoz/signoz/pkg/query-service/app/cloudintegrations"
|
||||
"github.com/SigNoz/signoz/pkg/query-service/app/integrations"
|
||||
@@ -26,14 +23,12 @@ import (
|
||||
rules "github.com/SigNoz/signoz/pkg/query-service/rules"
|
||||
"github.com/SigNoz/signoz/pkg/signoz"
|
||||
"github.com/SigNoz/signoz/pkg/types/authtypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/preferencetypes"
|
||||
"github.com/SigNoz/signoz/pkg/version"
|
||||
"github.com/gorilla/mux"
|
||||
)
|
||||
|
||||
type APIHandlerOptions struct {
|
||||
DataConnector interfaces.DataConnector
|
||||
SkipConfig *basemodel.SkipConfig
|
||||
PreferSpanMetrics bool
|
||||
AppDao dao.ModelDao
|
||||
RulesManager *rules.Manager
|
||||
@@ -60,13 +55,8 @@ type APIHandler struct {
|
||||
|
||||
// NewAPIHandler returns an APIHandler
|
||||
func NewAPIHandler(opts APIHandlerOptions, signoz *signoz.SigNoz) (*APIHandler, error) {
|
||||
preference := preference.NewAPI(preferencecore.NewPreference(preferencecore.NewStore(signoz.SQLStore), preferencetypes.NewDefaultPreferenceMap()))
|
||||
organizationAPI := implorganization.NewAPI(implorganization.NewModule(implorganization.NewStore(signoz.SQLStore)))
|
||||
organizationModule := implorganization.NewModule(implorganization.NewStore(signoz.SQLStore))
|
||||
|
||||
baseHandler, err := baseapp.NewAPIHandler(baseapp.APIHandlerOpts{
|
||||
Reader: opts.DataConnector,
|
||||
SkipConfig: opts.SkipConfig,
|
||||
PreferSpanMetrics: opts.PreferSpanMetrics,
|
||||
AppDao: opts.AppDao,
|
||||
RuleManager: opts.RulesManager,
|
||||
@@ -81,9 +71,6 @@ func NewAPIHandler(opts APIHandlerOptions, signoz *signoz.SigNoz) (*APIHandler,
|
||||
AlertmanagerAPI: alertmanager.NewAPI(signoz.Alertmanager),
|
||||
FieldsAPI: fields.NewAPI(signoz.TelemetryStore),
|
||||
Signoz: signoz,
|
||||
Preference: preference,
|
||||
OrganizationAPI: organizationAPI,
|
||||
OrganizationModule: organizationModule,
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
|
||||
@@ -134,7 +134,7 @@ func (ah *APIHandler) registerUser(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
_, registerError := baseauth.Register(ctx, req, ah.Signoz.Alertmanager, ah.OrganizationModule)
|
||||
_, registerError := baseauth.Register(ctx, req, ah.Signoz.Alertmanager, ah.Signoz.Modules.Organization)
|
||||
if !registerError.IsNil() {
|
||||
RespondError(w, apierr, nil)
|
||||
return
|
||||
@@ -152,7 +152,7 @@ func (ah *APIHandler) getInvite(w http.ResponseWriter, r *http.Request) {
|
||||
token := mux.Vars(r)["token"]
|
||||
sourceUrl := r.URL.Query().Get("ref")
|
||||
|
||||
inviteObject, err := baseauth.GetInvite(r.Context(), token, ah.OrganizationModule)
|
||||
inviteObject, err := baseauth.GetInvite(r.Context(), token, ah.Signoz.Modules.Organization)
|
||||
if err != nil {
|
||||
RespondError(w, model.BadRequest(err), nil)
|
||||
return
|
||||
|
||||
@@ -45,25 +45,17 @@ import (
|
||||
baseconst "github.com/SigNoz/signoz/pkg/query-service/constants"
|
||||
"github.com/SigNoz/signoz/pkg/query-service/healthcheck"
|
||||
baseint "github.com/SigNoz/signoz/pkg/query-service/interfaces"
|
||||
basemodel "github.com/SigNoz/signoz/pkg/query-service/model"
|
||||
baserules "github.com/SigNoz/signoz/pkg/query-service/rules"
|
||||
"github.com/SigNoz/signoz/pkg/query-service/telemetry"
|
||||
"github.com/SigNoz/signoz/pkg/query-service/utils"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
const AppDbEngine = "sqlite"
|
||||
|
||||
type ServerOptions struct {
|
||||
Config signoz.Config
|
||||
SigNoz *signoz.SigNoz
|
||||
PromConfigPath string
|
||||
SkipTopLvlOpsPath string
|
||||
HTTPHostPort string
|
||||
PrivateHostPort string
|
||||
// alert specific params
|
||||
DisableRules bool
|
||||
RuleRepoURL string
|
||||
Config signoz.Config
|
||||
SigNoz *signoz.SigNoz
|
||||
HTTPHostPort string
|
||||
PrivateHostPort string
|
||||
PreferSpanMetrics bool
|
||||
CacheConfigPath string
|
||||
FluxInterval string
|
||||
@@ -146,14 +138,6 @@ func NewServer(serverOptions *ServerOptions) (*Server, error) {
|
||||
serverOptions.SigNoz.Cache,
|
||||
)
|
||||
|
||||
skipConfig := &basemodel.SkipConfig{}
|
||||
if serverOptions.SkipTopLvlOpsPath != "" {
|
||||
// read skip config
|
||||
skipConfig, err = basemodel.ReadSkipConfig(serverOptions.SkipTopLvlOpsPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
var c cache.Cache
|
||||
if serverOptions.CacheConfigPath != "" {
|
||||
cacheOpts, err := cache.LoadFromYAMLCacheConfigFile(serverOptions.CacheConfigPath)
|
||||
@@ -164,11 +148,9 @@ func NewServer(serverOptions *ServerOptions) (*Server, error) {
|
||||
}
|
||||
|
||||
rm, err := makeRulesManager(
|
||||
serverOptions.RuleRepoURL,
|
||||
serverOptions.SigNoz.SQLStore.SQLxDB(),
|
||||
reader,
|
||||
c,
|
||||
serverOptions.DisableRules,
|
||||
serverOptions.UseLogsNewSchema,
|
||||
serverOptions.UseTraceNewSchema,
|
||||
serverOptions.SigNoz.Alertmanager,
|
||||
@@ -238,7 +220,6 @@ func NewServer(serverOptions *ServerOptions) (*Server, error) {
|
||||
|
||||
apiOpts := api.APIHandlerOptions{
|
||||
DataConnector: reader,
|
||||
SkipConfig: skipConfig,
|
||||
PreferSpanMetrics: serverOptions.PreferSpanMetrics,
|
||||
AppDao: modelDao,
|
||||
RulesManager: rm,
|
||||
@@ -356,6 +337,7 @@ func (s *Server) createPublicServer(apiHandler *api.APIHandler, web web.Web) (*h
|
||||
apiHandler.RegisterMessagingQueuesRoutes(r, am)
|
||||
apiHandler.RegisterThirdPartyApiRoutes(r, am)
|
||||
apiHandler.MetricExplorerRoutes(r, am)
|
||||
apiHandler.RegisterTraceFunnelsRoutes(r, am)
|
||||
|
||||
c := cors.New(cors.Options{
|
||||
AllowedOrigins: []string{"*"},
|
||||
@@ -411,13 +393,7 @@ func (s *Server) initListeners() error {
|
||||
|
||||
// Start listening on http and private http port concurrently
|
||||
func (s *Server) Start(ctx context.Context) error {
|
||||
|
||||
// initiate rule manager first
|
||||
if !s.serverOptions.DisableRules {
|
||||
s.ruleManager.Start(ctx)
|
||||
} else {
|
||||
zap.L().Info("msg: Rules disabled as rules.disable is set to TRUE")
|
||||
}
|
||||
s.ruleManager.Start(ctx)
|
||||
|
||||
err := s.initListeners()
|
||||
if err != nil {
|
||||
@@ -508,11 +484,9 @@ func (s *Server) Stop() error {
|
||||
}
|
||||
|
||||
func makeRulesManager(
|
||||
ruleRepoURL string,
|
||||
db *sqlx.DB,
|
||||
ch baseint.Reader,
|
||||
cache cache.Cache,
|
||||
disableRules bool,
|
||||
useLogsNewSchema bool,
|
||||
useTraceNewSchema bool,
|
||||
alertmanager alertmanager.Alertmanager,
|
||||
@@ -524,11 +498,9 @@ func makeRulesManager(
|
||||
managerOpts := &baserules.ManagerOptions{
|
||||
TelemetryStore: telemetryStore,
|
||||
Prometheus: prometheus,
|
||||
RepoURL: ruleRepoURL,
|
||||
DBConn: db,
|
||||
Context: context.Background(),
|
||||
Logger: zap.L(),
|
||||
DisableRules: disableRules,
|
||||
Reader: ch,
|
||||
Cache: cache,
|
||||
EvalDelay: baseconst.GetEvalDelay(),
|
||||
|
||||
@@ -52,19 +52,27 @@ func main() {
|
||||
|
||||
flag.BoolVar(&useLogsNewSchema, "use-logs-new-schema", false, "use logs_v2 schema for logs")
|
||||
flag.BoolVar(&useTraceNewSchema, "use-trace-new-schema", false, "use new schema for traces")
|
||||
// Deprecated
|
||||
flag.StringVar(&promConfigPath, "config", "./config/prometheus.yml", "(prometheus config to read metrics)")
|
||||
// Deprecated
|
||||
flag.StringVar(&skipTopLvlOpsPath, "skip-top-level-ops", "", "(config file to skip top level operations)")
|
||||
// Deprecated
|
||||
flag.BoolVar(&disableRules, "rules.disable", false, "(disable rule evaluation)")
|
||||
flag.BoolVar(&preferSpanMetrics, "prefer-span-metrics", false, "(prefer span metrics for service level metrics)")
|
||||
// Deprecated
|
||||
flag.IntVar(&maxIdleConns, "max-idle-conns", 50, "(number of connections to maintain in the pool.)")
|
||||
// Deprecated
|
||||
flag.IntVar(&maxOpenConns, "max-open-conns", 100, "(max connections for use at any time.)")
|
||||
// Deprecated
|
||||
flag.DurationVar(&dialTimeout, "dial-timeout", 5*time.Second, "(the maximum time to establish a connection.)")
|
||||
// Deprecated
|
||||
flag.StringVar(&ruleRepoURL, "rules.repo-url", baseconst.AlertHelpPage, "(host address used to build rule link in alert messages)")
|
||||
flag.StringVar(&cacheConfigPath, "experimental.cache-config", "", "(cache config to use)")
|
||||
flag.StringVar(&fluxInterval, "flux-interval", "5m", "(the interval to exclude data from being cached to avoid incorrect cache for data in motion)")
|
||||
flag.StringVar(&fluxIntervalForTraceDetail, "flux-interval-trace-detail", "2m", "(the interval to exclude data from being cached to avoid incorrect cache for trace data in motion)")
|
||||
flag.StringVar(&cluster, "cluster", "cluster", "(cluster name - defaults to 'cluster')")
|
||||
flag.StringVar(&gatewayUrl, "gateway-url", "", "(url to the gateway)")
|
||||
// Deprecated
|
||||
flag.BoolVar(&useLicensesV3, "use-licenses-v3", false, "use licenses_v3 schema for licenses")
|
||||
flag.Parse()
|
||||
|
||||
@@ -121,12 +129,8 @@ func main() {
|
||||
Config: config,
|
||||
SigNoz: signoz,
|
||||
HTTPHostPort: baseconst.HTTPHostPort,
|
||||
PromConfigPath: promConfigPath,
|
||||
SkipTopLvlOpsPath: skipTopLvlOpsPath,
|
||||
PreferSpanMetrics: preferSpanMetrics,
|
||||
PrivateHostPort: baseconst.PrivateHostPort,
|
||||
DisableRules: disableRules,
|
||||
RuleRepoURL: ruleRepoURL,
|
||||
CacheConfigPath: cacheConfigPath,
|
||||
FluxInterval: fluxInterval,
|
||||
FluxIntervalForTraceDetail: fluxIntervalForTraceDetail,
|
||||
|
||||
@@ -194,7 +194,7 @@ func (dialect *dialect) RenameColumn(ctx context.Context, bun bun.IDB, table str
|
||||
}
|
||||
|
||||
if !oldColumnExists {
|
||||
return false, errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, fmt.Sprintf("old column: %s doesn't exist", oldColumnName))
|
||||
return false, errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "old column: %s doesn't exist", oldColumnName)
|
||||
}
|
||||
|
||||
_, err = bun.
|
||||
|
||||
@@ -4,8 +4,10 @@ import (
|
||||
"context"
|
||||
"database/sql"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/factory"
|
||||
"github.com/SigNoz/signoz/pkg/sqlstore"
|
||||
"github.com/jackc/pgx/v5/pgconn"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
"github.com/jackc/pgx/v5/stdlib"
|
||||
"github.com/jmoiron/sqlx"
|
||||
@@ -87,3 +89,20 @@ func (provider *provider) BunDBCtx(ctx context.Context) bun.IDB {
|
||||
func (provider *provider) RunInTxCtx(ctx context.Context, opts *sql.TxOptions, cb func(ctx context.Context) error) error {
|
||||
return provider.bundb.RunInTxCtx(ctx, opts, cb)
|
||||
}
|
||||
|
||||
func (provider *provider) WrapNotFoundErrf(err error, code errors.Code, format string, args ...any) error {
|
||||
if err == sql.ErrNoRows {
|
||||
return errors.Wrapf(err, errors.TypeNotFound, code, format, args...)
|
||||
}
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
func (provider *provider) WrapAlreadyExistsErrf(err error, code errors.Code, format string, args ...any) error {
|
||||
var pgErr *pgconn.PgError
|
||||
if errors.As(err, &pgErr) && pgErr.Code == "23505" {
|
||||
return errors.Wrapf(err, errors.TypeAlreadyExists, code, format, args...)
|
||||
}
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -55,7 +55,7 @@
|
||||
"ansi-to-html": "0.7.2",
|
||||
"antd": "5.11.0",
|
||||
"antd-table-saveas-excel": "2.2.1",
|
||||
"axios": "1.7.7",
|
||||
"axios": "1.8.2",
|
||||
"babel-eslint": "^10.1.0",
|
||||
"babel-jest": "^29.6.4",
|
||||
"babel-loader": "9.1.3",
|
||||
|
||||
13
frontend/src/components/NewSelect/CustomMultiSelect.scss
Normal file
13
frontend/src/components/NewSelect/CustomMultiSelect.scss
Normal file
@@ -0,0 +1,13 @@
|
||||
.custom-multiselect-dropdown {
|
||||
.divider {
|
||||
height: 1px;
|
||||
background-color: #e8e8e8;
|
||||
margin: 4px 0;
|
||||
}
|
||||
|
||||
.all-option {
|
||||
font-weight: 500;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
}
|
||||
1765
frontend/src/components/NewSelect/CustomMultiSelect.tsx
Normal file
1765
frontend/src/components/NewSelect/CustomMultiSelect.tsx
Normal file
File diff suppressed because it is too large
Load Diff
606
frontend/src/components/NewSelect/CustomSelect.tsx
Normal file
606
frontend/src/components/NewSelect/CustomSelect.tsx
Normal file
@@ -0,0 +1,606 @@
|
||||
/* eslint-disable no-nested-ternary */
|
||||
/* eslint-disable sonarjs/cognitive-complexity */
|
||||
/* eslint-disable react/jsx-props-no-spreading */
|
||||
/* eslint-disable react/function-component-definition */
|
||||
import './styles.scss';
|
||||
|
||||
import {
|
||||
CloseOutlined,
|
||||
DownOutlined,
|
||||
LoadingOutlined,
|
||||
ReloadOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import { Color } from '@signozhq/design-tokens';
|
||||
import { Select } from 'antd';
|
||||
import cx from 'classnames';
|
||||
import { SOMETHING_WENT_WRONG } from 'constants/api';
|
||||
import { capitalize, isEmpty } from 'lodash-es';
|
||||
import { ArrowDown, ArrowUp } from 'lucide-react';
|
||||
import type { BaseSelectRef } from 'rc-select';
|
||||
import React, {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
import { popupContainer } from 'utils/selectPopupContainer';
|
||||
|
||||
import { CustomSelectProps, OptionData } from './types';
|
||||
import {
|
||||
filterOptionsBySearch,
|
||||
prioritizeOrAddOptionForSingleSelect,
|
||||
SPACEKEY,
|
||||
} from './utils';
|
||||
|
||||
/**
|
||||
* CustomSelect Component
|
||||
*
|
||||
*/
|
||||
const CustomSelect: React.FC<CustomSelectProps> = ({
|
||||
placeholder = 'Search...',
|
||||
className,
|
||||
loading = false,
|
||||
onSearch,
|
||||
options = [],
|
||||
value,
|
||||
onChange,
|
||||
defaultActiveFirstOption = true,
|
||||
noDataMessage,
|
||||
onClear,
|
||||
getPopupContainer,
|
||||
dropdownRender,
|
||||
highlightSearch = true,
|
||||
placement = 'bottomLeft',
|
||||
popupMatchSelectWidth = true,
|
||||
popupClassName,
|
||||
errorMessage,
|
||||
allowClear = false,
|
||||
onRetry,
|
||||
...rest
|
||||
}) => {
|
||||
// ===== State & Refs =====
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [searchText, setSearchText] = useState('');
|
||||
const [activeOptionIndex, setActiveOptionIndex] = useState<number>(-1);
|
||||
|
||||
// Refs for element access and scroll behavior
|
||||
const selectRef = useRef<BaseSelectRef>(null);
|
||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||
const optionRefs = useRef<Record<number, HTMLDivElement | null>>({});
|
||||
|
||||
// ===== Option Filtering & Processing Utilities =====
|
||||
|
||||
/**
|
||||
* Checks if a label exists in the provided options
|
||||
*/
|
||||
const isLabelPresent = useCallback(
|
||||
(options: OptionData[], label: string): boolean =>
|
||||
options.some((option) => {
|
||||
const lowerLabel = label.toLowerCase();
|
||||
|
||||
// Check in nested options if they exist
|
||||
if ('options' in option && Array.isArray(option.options)) {
|
||||
return option.options.some(
|
||||
(subOption) => subOption.label.toLowerCase() === lowerLabel,
|
||||
);
|
||||
}
|
||||
|
||||
// Check top-level option
|
||||
return option.label.toLowerCase() === lowerLabel;
|
||||
}),
|
||||
[],
|
||||
);
|
||||
|
||||
/**
|
||||
* Separates section and non-section options
|
||||
*/
|
||||
const splitOptions = useCallback((options: OptionData[]): {
|
||||
sectionOptions: OptionData[];
|
||||
nonSectionOptions: OptionData[];
|
||||
} => {
|
||||
const sectionOptions: OptionData[] = [];
|
||||
const nonSectionOptions: OptionData[] = [];
|
||||
|
||||
options.forEach((option) => {
|
||||
if ('options' in option && Array.isArray(option.options)) {
|
||||
sectionOptions.push(option);
|
||||
} else {
|
||||
nonSectionOptions.push(option);
|
||||
}
|
||||
});
|
||||
|
||||
return { sectionOptions, nonSectionOptions };
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Apply search filtering to options
|
||||
*/
|
||||
const filteredOptions = useMemo(
|
||||
(): OptionData[] => filterOptionsBySearch(options, searchText),
|
||||
[options, searchText],
|
||||
);
|
||||
|
||||
// ===== UI & Rendering Functions =====
|
||||
|
||||
/**
|
||||
* Highlights matched text in search results
|
||||
*/
|
||||
const highlightMatchedText = useCallback(
|
||||
(text: string, searchQuery: string): React.ReactNode => {
|
||||
if (!searchQuery || !highlightSearch) return text;
|
||||
|
||||
const parts = text.split(new RegExp(`(${searchQuery})`, 'gi'));
|
||||
return (
|
||||
<>
|
||||
{parts.map((part, i) => {
|
||||
// Create a deterministic but unique key
|
||||
const uniqueKey = `${text.substring(0, 3)}-${part.substring(0, 3)}-${i}`;
|
||||
|
||||
return part.toLowerCase() === searchQuery.toLowerCase() ? (
|
||||
<span key={uniqueKey} className="highlight-text">
|
||||
{part}
|
||||
</span>
|
||||
) : (
|
||||
part
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
},
|
||||
[highlightSearch],
|
||||
);
|
||||
|
||||
/**
|
||||
* Renders an individual option with proper keyboard navigation support
|
||||
*/
|
||||
const renderOptionItem = useCallback(
|
||||
(
|
||||
option: OptionData,
|
||||
isSelected: boolean,
|
||||
index?: number,
|
||||
): React.ReactElement => {
|
||||
const handleSelection = (): void => {
|
||||
if (onChange) {
|
||||
onChange(option.value, option);
|
||||
setIsOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
const isActive = index === activeOptionIndex;
|
||||
const optionId = `option-${index}`;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={option.value}
|
||||
id={optionId}
|
||||
ref={(el): void => {
|
||||
if (index !== undefined) {
|
||||
optionRefs.current[index] = el;
|
||||
}
|
||||
}}
|
||||
className={cx('option-item', {
|
||||
selected: isSelected,
|
||||
active: isActive,
|
||||
})}
|
||||
onClick={(e): void => {
|
||||
e.stopPropagation();
|
||||
handleSelection();
|
||||
}}
|
||||
onKeyDown={(e): void => {
|
||||
if (e.key === 'Enter' || e.key === SPACEKEY) {
|
||||
e.preventDefault();
|
||||
handleSelection();
|
||||
}
|
||||
}}
|
||||
onMouseEnter={(): void => setActiveOptionIndex(index || -1)}
|
||||
role="option"
|
||||
aria-selected={isSelected}
|
||||
aria-disabled={option.disabled}
|
||||
tabIndex={isActive ? 0 : -1}
|
||||
>
|
||||
<div className="option-content">
|
||||
<div>{highlightMatchedText(String(option.label || ''), searchText)}</div>
|
||||
{option.type === 'custom' && (
|
||||
<div className="option-badge">{capitalize(option.type)}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
[highlightMatchedText, searchText, onChange, activeOptionIndex],
|
||||
);
|
||||
|
||||
/**
|
||||
* Helper function to render option with index tracking
|
||||
*/
|
||||
const renderOptionWithIndex = useCallback(
|
||||
(option: OptionData, isSelected: boolean, idx: number) =>
|
||||
renderOptionItem(option, isSelected, idx),
|
||||
[renderOptionItem],
|
||||
);
|
||||
|
||||
/**
|
||||
* Custom clear button renderer
|
||||
*/
|
||||
const clearIcon = useCallback(
|
||||
() => (
|
||||
<CloseOutlined
|
||||
onClick={(e): void => {
|
||||
e.stopPropagation();
|
||||
if (onChange) onChange(undefined, []);
|
||||
if (onClear) onClear();
|
||||
}}
|
||||
/>
|
||||
),
|
||||
[onChange, onClear],
|
||||
);
|
||||
|
||||
// ===== Event Handlers =====
|
||||
|
||||
/**
|
||||
* Handles search input changes
|
||||
*/
|
||||
const handleSearch = useCallback(
|
||||
(value: string): void => {
|
||||
const trimmedValue = value.trim();
|
||||
setSearchText(trimmedValue);
|
||||
|
||||
if (onSearch) onSearch(trimmedValue);
|
||||
},
|
||||
[onSearch],
|
||||
);
|
||||
|
||||
/**
|
||||
* Prevents event propagation for dropdown clicks
|
||||
*/
|
||||
const handleDropdownClick = useCallback((e: React.MouseEvent): void => {
|
||||
e.stopPropagation();
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Comprehensive keyboard navigation handler
|
||||
*/
|
||||
const handleKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent): void => {
|
||||
// Handle keyboard navigation when dropdown is open
|
||||
if (isOpen) {
|
||||
// Get flattened list of all selectable options
|
||||
const getFlatOptions = (): OptionData[] => {
|
||||
if (!filteredOptions) return [];
|
||||
|
||||
const flatList: OptionData[] = [];
|
||||
|
||||
// Process options
|
||||
const { sectionOptions, nonSectionOptions } = splitOptions(
|
||||
isEmpty(value)
|
||||
? filteredOptions
|
||||
: prioritizeOrAddOptionForSingleSelect(filteredOptions, value),
|
||||
);
|
||||
|
||||
// Add custom option if needed
|
||||
if (!isEmpty(searchText) && !isLabelPresent(filteredOptions, searchText)) {
|
||||
flatList.push({
|
||||
label: searchText,
|
||||
value: searchText,
|
||||
type: 'custom',
|
||||
});
|
||||
}
|
||||
|
||||
// Add all options to flat list
|
||||
flatList.push(...nonSectionOptions);
|
||||
sectionOptions.forEach((section) => {
|
||||
if (section.options) {
|
||||
flatList.push(...section.options);
|
||||
}
|
||||
});
|
||||
|
||||
return flatList;
|
||||
};
|
||||
|
||||
const options = getFlatOptions();
|
||||
|
||||
switch (e.key) {
|
||||
case 'ArrowDown':
|
||||
e.preventDefault();
|
||||
setActiveOptionIndex((prev) =>
|
||||
prev < options.length - 1 ? prev + 1 : 0,
|
||||
);
|
||||
break;
|
||||
|
||||
case 'ArrowUp':
|
||||
e.preventDefault();
|
||||
setActiveOptionIndex((prev) =>
|
||||
prev > 0 ? prev - 1 : options.length - 1,
|
||||
);
|
||||
break;
|
||||
|
||||
case 'Tab':
|
||||
// Tab navigation with Shift key support
|
||||
if (e.shiftKey) {
|
||||
e.preventDefault();
|
||||
setActiveOptionIndex((prev) =>
|
||||
prev > 0 ? prev - 1 : options.length - 1,
|
||||
);
|
||||
} else {
|
||||
e.preventDefault();
|
||||
setActiveOptionIndex((prev) =>
|
||||
prev < options.length - 1 ? prev + 1 : 0,
|
||||
);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'Enter':
|
||||
e.preventDefault();
|
||||
if (activeOptionIndex >= 0 && activeOptionIndex < options.length) {
|
||||
// Select the focused option
|
||||
const selectedOption = options[activeOptionIndex];
|
||||
if (onChange) {
|
||||
onChange(selectedOption.value, selectedOption);
|
||||
setIsOpen(false);
|
||||
setActiveOptionIndex(-1);
|
||||
}
|
||||
} else if (!isEmpty(searchText)) {
|
||||
// Add custom value when no option is focused
|
||||
const customOption = {
|
||||
label: searchText,
|
||||
value: searchText,
|
||||
type: 'custom',
|
||||
};
|
||||
if (onChange) {
|
||||
onChange(customOption.value, customOption);
|
||||
setIsOpen(false);
|
||||
setActiveOptionIndex(-1);
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
case 'Escape':
|
||||
e.preventDefault();
|
||||
setIsOpen(false);
|
||||
setActiveOptionIndex(-1);
|
||||
break;
|
||||
|
||||
case ' ': // Space key
|
||||
if (activeOptionIndex >= 0 && activeOptionIndex < options.length) {
|
||||
e.preventDefault();
|
||||
const selectedOption = options[activeOptionIndex];
|
||||
if (onChange) {
|
||||
onChange(selectedOption.value, selectedOption);
|
||||
setIsOpen(false);
|
||||
setActiveOptionIndex(-1);
|
||||
}
|
||||
}
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
} else if (e.key === 'ArrowDown' || e.key === 'Tab') {
|
||||
// Open dropdown when Down or Tab is pressed while closed
|
||||
e.preventDefault();
|
||||
setIsOpen(true);
|
||||
setActiveOptionIndex(0);
|
||||
}
|
||||
},
|
||||
[
|
||||
isOpen,
|
||||
activeOptionIndex,
|
||||
filteredOptions,
|
||||
searchText,
|
||||
onChange,
|
||||
splitOptions,
|
||||
value,
|
||||
isLabelPresent,
|
||||
],
|
||||
);
|
||||
|
||||
// ===== Dropdown Rendering =====
|
||||
|
||||
/**
|
||||
* Renders the custom dropdown with sections and keyboard navigation
|
||||
*/
|
||||
const customDropdownRender = useCallback((): React.ReactElement => {
|
||||
// Process options based on current value
|
||||
let processedOptions = isEmpty(value)
|
||||
? filteredOptions
|
||||
: prioritizeOrAddOptionForSingleSelect(filteredOptions, value);
|
||||
|
||||
if (!isEmpty(searchText)) {
|
||||
processedOptions = filterOptionsBySearch(processedOptions, searchText);
|
||||
}
|
||||
|
||||
const { sectionOptions, nonSectionOptions } = splitOptions(processedOptions);
|
||||
|
||||
// Check if we need to add a custom option based on search text
|
||||
const isSearchTextNotPresent =
|
||||
!isEmpty(searchText) && !isLabelPresent(processedOptions, searchText);
|
||||
|
||||
let optionIndex = 0;
|
||||
|
||||
// Add custom option if needed
|
||||
if (isSearchTextNotPresent) {
|
||||
nonSectionOptions.unshift({
|
||||
label: searchText,
|
||||
value: searchText,
|
||||
type: 'custom',
|
||||
});
|
||||
}
|
||||
|
||||
// Helper function to map options with index tracking
|
||||
const mapOptions = (options: OptionData[]): React.ReactNode =>
|
||||
options.map((option) => {
|
||||
const result = renderOptionWithIndex(
|
||||
option,
|
||||
option.value === value,
|
||||
optionIndex,
|
||||
);
|
||||
optionIndex += 1;
|
||||
return result;
|
||||
});
|
||||
|
||||
const customMenu = (
|
||||
<div
|
||||
ref={dropdownRef}
|
||||
className="custom-select-dropdown"
|
||||
onClick={handleDropdownClick}
|
||||
onKeyDown={handleKeyDown}
|
||||
role="listbox"
|
||||
tabIndex={-1}
|
||||
aria-activedescendant={
|
||||
activeOptionIndex >= 0 ? `option-${activeOptionIndex}` : undefined
|
||||
}
|
||||
>
|
||||
{/* Non-section options */}
|
||||
<div className="no-section-options">
|
||||
{nonSectionOptions.length > 0 && mapOptions(nonSectionOptions)}
|
||||
</div>
|
||||
|
||||
{/* Section options */}
|
||||
{sectionOptions.length > 0 &&
|
||||
sectionOptions.map((section) =>
|
||||
!isEmpty(section.options) ? (
|
||||
<div className="select-group" key={section.label}>
|
||||
<div className="group-label" role="heading" aria-level={2}>
|
||||
{section.label}
|
||||
</div>
|
||||
<div role="group" aria-label={`${section.label} options`}>
|
||||
{section.options && mapOptions(section.options)}
|
||||
</div>
|
||||
</div>
|
||||
) : null,
|
||||
)}
|
||||
|
||||
{/* Navigation help footer */}
|
||||
<div className="navigation-footer" role="note">
|
||||
{!loading && !errorMessage && !noDataMessage && (
|
||||
<section className="navigate">
|
||||
<ArrowDown size={8} className="icons" />
|
||||
<ArrowUp size={8} className="icons" />
|
||||
<span className="keyboard-text">to navigate</span>
|
||||
</section>
|
||||
)}
|
||||
{loading && (
|
||||
<div className="navigation-loading">
|
||||
<div className="navigation-icons">
|
||||
<LoadingOutlined />
|
||||
</div>
|
||||
<div className="navigation-text">We are updating the values...</div>
|
||||
</div>
|
||||
)}
|
||||
{errorMessage && !loading && (
|
||||
<div className="navigation-error">
|
||||
<div className="navigation-text">
|
||||
{errorMessage || SOMETHING_WENT_WRONG}
|
||||
</div>
|
||||
<div className="navigation-icons">
|
||||
<ReloadOutlined
|
||||
twoToneColor={Color.BG_CHERRY_400}
|
||||
onClick={(e): void => {
|
||||
e.stopPropagation();
|
||||
if (onRetry) onRetry();
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{noDataMessage && !loading && (
|
||||
<div className="navigation-text">{noDataMessage}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
return dropdownRender ? dropdownRender(customMenu) : customMenu;
|
||||
}, [
|
||||
value,
|
||||
filteredOptions,
|
||||
searchText,
|
||||
splitOptions,
|
||||
isLabelPresent,
|
||||
handleDropdownClick,
|
||||
handleKeyDown,
|
||||
activeOptionIndex,
|
||||
loading,
|
||||
errorMessage,
|
||||
noDataMessage,
|
||||
dropdownRender,
|
||||
renderOptionWithIndex,
|
||||
onRetry,
|
||||
]);
|
||||
|
||||
// ===== Side Effects =====
|
||||
|
||||
// Clear search text when dropdown closes
|
||||
useEffect(() => {
|
||||
if (!isOpen) {
|
||||
setSearchText('');
|
||||
setActiveOptionIndex(-1);
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
// Auto-scroll to active option for keyboard navigation
|
||||
useEffect(() => {
|
||||
if (
|
||||
isOpen &&
|
||||
activeOptionIndex >= 0 &&
|
||||
optionRefs.current[activeOptionIndex]
|
||||
) {
|
||||
optionRefs.current[activeOptionIndex]?.scrollIntoView({
|
||||
behavior: 'smooth',
|
||||
block: 'nearest',
|
||||
});
|
||||
}
|
||||
}, [isOpen, activeOptionIndex]);
|
||||
|
||||
// ===== Final Processing =====
|
||||
|
||||
// Apply highlight to matched text in options
|
||||
const optionsWithHighlight = useMemo(
|
||||
() =>
|
||||
options
|
||||
?.filter((option) =>
|
||||
String(option.label || '')
|
||||
.toLowerCase()
|
||||
.includes(searchText.toLowerCase()),
|
||||
)
|
||||
?.map((option) => ({
|
||||
...option,
|
||||
label: highlightMatchedText(String(option.label || ''), searchText),
|
||||
})),
|
||||
[options, searchText, highlightMatchedText],
|
||||
);
|
||||
|
||||
// ===== Component Rendering =====
|
||||
return (
|
||||
<Select
|
||||
ref={selectRef}
|
||||
className={cx('custom-select', className)}
|
||||
placeholder={placeholder}
|
||||
showSearch
|
||||
filterOption={false}
|
||||
onSearch={handleSearch}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
onDropdownVisibleChange={setIsOpen}
|
||||
open={isOpen}
|
||||
options={optionsWithHighlight}
|
||||
defaultActiveFirstOption={defaultActiveFirstOption}
|
||||
popupMatchSelectWidth={popupMatchSelectWidth}
|
||||
allowClear={allowClear ? { clearIcon } : false}
|
||||
getPopupContainer={getPopupContainer ?? popupContainer}
|
||||
suffixIcon={<DownOutlined style={{ cursor: 'default' }} />}
|
||||
dropdownRender={customDropdownRender}
|
||||
menuItemSelectedIcon={null}
|
||||
popupClassName={cx('custom-select-dropdown-container', popupClassName)}
|
||||
listHeight={300}
|
||||
placement={placement}
|
||||
optionFilterProp="label"
|
||||
notFoundContent={<div className="empty-message">{noDataMessage}</div>}
|
||||
onKeyDown={handleKeyDown}
|
||||
{...rest}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default CustomSelect;
|
||||
@@ -0,0 +1,263 @@
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
|
||||
import CustomMultiSelect from '../CustomMultiSelect';
|
||||
|
||||
// Mock scrollIntoView which isn't available in JSDOM
|
||||
window.HTMLElement.prototype.scrollIntoView = jest.fn();
|
||||
|
||||
// Mock options data
|
||||
const mockOptions = [
|
||||
{ label: 'Option 1', value: 'option1' },
|
||||
{ label: 'Option 2', value: 'option2' },
|
||||
{ label: 'Option 3', value: 'option3' },
|
||||
];
|
||||
|
||||
const mockGroupedOptions = [
|
||||
{
|
||||
label: 'Group 1',
|
||||
options: [
|
||||
{ label: 'Group 1 - Option 1', value: 'g1-option1' },
|
||||
{ label: 'Group 1 - Option 2', value: 'g1-option2' },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Group 2',
|
||||
options: [
|
||||
{ label: 'Group 2 - Option 1', value: 'g2-option1' },
|
||||
{ label: 'Group 2 - Option 2', value: 'g2-option2' },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
describe('CustomMultiSelect Component', () => {
|
||||
it('renders with placeholder', () => {
|
||||
const handleChange = jest.fn();
|
||||
render(
|
||||
<CustomMultiSelect
|
||||
placeholder="Select multiple options"
|
||||
options={mockOptions}
|
||||
onChange={handleChange}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Check placeholder exists
|
||||
const placeholderElement = screen.getByText('Select multiple options');
|
||||
expect(placeholderElement).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('opens dropdown when clicked', async () => {
|
||||
const handleChange = jest.fn();
|
||||
render(<CustomMultiSelect options={mockOptions} onChange={handleChange} />);
|
||||
|
||||
// Click to open the dropdown
|
||||
const selectElement = screen.getByRole('combobox');
|
||||
fireEvent.mouseDown(selectElement);
|
||||
|
||||
// Wait for dropdown to appear
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('ALL')).toBeInTheDocument(); // The ALL option
|
||||
expect(screen.getByText('Option 1')).toBeInTheDocument();
|
||||
expect(screen.getByText('Option 2')).toBeInTheDocument();
|
||||
expect(screen.getByText('Option 3')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('selects multiple options', async () => {
|
||||
const handleChange = jest.fn();
|
||||
|
||||
// Start with option1 already selected
|
||||
render(
|
||||
<CustomMultiSelect
|
||||
options={mockOptions}
|
||||
onChange={handleChange}
|
||||
value={['option1']}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Open dropdown
|
||||
const selectElement = screen.getByRole('combobox');
|
||||
fireEvent.mouseDown(selectElement);
|
||||
|
||||
// Wait for dropdown to appear
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Option 3')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Click on Option 3
|
||||
const option3 = screen.getByText('Option 3');
|
||||
fireEvent.click(option3);
|
||||
|
||||
// Verify onChange was called with the right values
|
||||
expect(handleChange).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('selects ALL options when ALL is clicked', async () => {
|
||||
const handleChange = jest.fn();
|
||||
render(
|
||||
<CustomMultiSelect
|
||||
options={mockOptions}
|
||||
onChange={handleChange}
|
||||
enableAllSelection
|
||||
/>,
|
||||
);
|
||||
|
||||
// Open dropdown
|
||||
const selectElement = screen.getByRole('combobox');
|
||||
fireEvent.mouseDown(selectElement);
|
||||
|
||||
// Wait for dropdown to appear
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('ALL')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Click on ALL option
|
||||
const allOption = screen.getByText('ALL');
|
||||
fireEvent.click(allOption);
|
||||
|
||||
// Verify onChange was called with all option values
|
||||
expect(handleChange).toHaveBeenCalledWith(
|
||||
['option1', 'option2', 'option3'],
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ value: 'option1' }),
|
||||
expect.objectContaining({ value: 'option2' }),
|
||||
expect.objectContaining({ value: 'option3' }),
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it('displays selected options as tags', async () => {
|
||||
render(
|
||||
<CustomMultiSelect options={mockOptions} value={['option1', 'option2']} />,
|
||||
);
|
||||
|
||||
// Check that option values are shown as tags (not labels)
|
||||
expect(screen.getByText('option1')).toBeInTheDocument();
|
||||
expect(screen.getByText('option2')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('removes a tag when clicked', async () => {
|
||||
const handleChange = jest.fn();
|
||||
render(
|
||||
<CustomMultiSelect
|
||||
options={mockOptions}
|
||||
value={['option1', 'option2']}
|
||||
onChange={handleChange}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Find close button on Option 1 tag and click it
|
||||
const closeButtons = document.querySelectorAll(
|
||||
'.ant-select-selection-item-remove',
|
||||
);
|
||||
fireEvent.click(closeButtons[0]);
|
||||
|
||||
// Verify onChange was called with remaining option
|
||||
expect(handleChange).toHaveBeenCalledWith(
|
||||
['option2'],
|
||||
expect.arrayContaining([expect.objectContaining({ value: 'option2' })]),
|
||||
);
|
||||
});
|
||||
|
||||
it('filters options when searching', async () => {
|
||||
render(<CustomMultiSelect options={mockOptions} />);
|
||||
|
||||
// Open dropdown
|
||||
const selectElement = screen.getByRole('combobox');
|
||||
fireEvent.mouseDown(selectElement);
|
||||
|
||||
// Type into search box - get input directly
|
||||
const inputElement = selectElement.querySelector('input');
|
||||
if (inputElement) {
|
||||
fireEvent.change(inputElement, { target: { value: '2' } });
|
||||
}
|
||||
|
||||
// Wait for the dropdown filtering to happen
|
||||
await waitFor(() => {
|
||||
// Check that the dropdown is present
|
||||
const dropdownElement = document.querySelector(
|
||||
'.custom-multiselect-dropdown',
|
||||
);
|
||||
expect(dropdownElement).toBeInTheDocument();
|
||||
|
||||
// Verify Option 2 is visible in the dropdown
|
||||
const options = document.querySelectorAll('.option-label-text');
|
||||
let foundOption2 = false;
|
||||
|
||||
options.forEach((option) => {
|
||||
const text = option.textContent || '';
|
||||
if (text.includes('Option 2')) foundOption2 = true;
|
||||
});
|
||||
|
||||
expect(foundOption2).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
it('renders grouped options correctly', async () => {
|
||||
render(<CustomMultiSelect options={mockGroupedOptions} />);
|
||||
|
||||
// Open dropdown
|
||||
const selectElement = screen.getByRole('combobox');
|
||||
fireEvent.mouseDown(selectElement);
|
||||
|
||||
// Check group headers and options
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Group 1')).toBeInTheDocument();
|
||||
expect(screen.getByText('Group 2')).toBeInTheDocument();
|
||||
expect(screen.getByText('Group 1 - Option 1')).toBeInTheDocument();
|
||||
expect(screen.getByText('Group 1 - Option 2')).toBeInTheDocument();
|
||||
expect(screen.getByText('Group 2 - Option 1')).toBeInTheDocument();
|
||||
expect(screen.getByText('Group 2 - Option 2')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('shows loading state', () => {
|
||||
render(<CustomMultiSelect options={mockOptions} loading />);
|
||||
|
||||
// Open dropdown
|
||||
const selectElement = screen.getByRole('combobox');
|
||||
fireEvent.mouseDown(selectElement);
|
||||
|
||||
// Check loading text is displayed
|
||||
expect(screen.getByText('We are updating the values...')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows error message', () => {
|
||||
render(
|
||||
<CustomMultiSelect
|
||||
options={mockOptions}
|
||||
errorMessage="Test error message"
|
||||
/>,
|
||||
);
|
||||
|
||||
// Open dropdown
|
||||
const selectElement = screen.getByRole('combobox');
|
||||
fireEvent.mouseDown(selectElement);
|
||||
|
||||
// Check error message is displayed
|
||||
expect(screen.getByText('Test error message')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows no data message', () => {
|
||||
render(<CustomMultiSelect options={[]} noDataMessage="No data available" />);
|
||||
|
||||
// Open dropdown
|
||||
const selectElement = screen.getByRole('combobox');
|
||||
fireEvent.mouseDown(selectElement);
|
||||
|
||||
// Check no data message is displayed
|
||||
expect(screen.getByText('No data available')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows "ALL" tag when all options are selected', () => {
|
||||
render(
|
||||
<CustomMultiSelect
|
||||
options={mockOptions}
|
||||
value={['option1', 'option2', 'option3']}
|
||||
maxTagCount={2}
|
||||
/>,
|
||||
);
|
||||
|
||||
// When all options are selected, component shows ALL tag instead
|
||||
expect(screen.getByText('ALL')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
206
frontend/src/components/NewSelect/__test__/CustomSelect.test.tsx
Normal file
206
frontend/src/components/NewSelect/__test__/CustomSelect.test.tsx
Normal file
@@ -0,0 +1,206 @@
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
|
||||
import CustomSelect from '../CustomSelect';
|
||||
|
||||
// Mock scrollIntoView which isn't available in JSDOM
|
||||
window.HTMLElement.prototype.scrollIntoView = jest.fn();
|
||||
|
||||
// Mock options data
|
||||
const mockOptions = [
|
||||
{ label: 'Option 1', value: 'option1' },
|
||||
{ label: 'Option 2', value: 'option2' },
|
||||
{ label: 'Option 3', value: 'option3' },
|
||||
];
|
||||
|
||||
const mockGroupedOptions = [
|
||||
{
|
||||
label: 'Group 1',
|
||||
options: [
|
||||
{ label: 'Group 1 - Option 1', value: 'g1-option1' },
|
||||
{ label: 'Group 1 - Option 2', value: 'g1-option2' },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Group 2',
|
||||
options: [
|
||||
{ label: 'Group 2 - Option 1', value: 'g2-option1' },
|
||||
{ label: 'Group 2 - Option 2', value: 'g2-option2' },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
describe('CustomSelect Component', () => {
|
||||
it('renders with placeholder and options', () => {
|
||||
const handleChange = jest.fn();
|
||||
render(
|
||||
<CustomSelect
|
||||
placeholder="Test placeholder"
|
||||
options={mockOptions}
|
||||
onChange={handleChange}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Check placeholder exists in the DOM (not using getByPlaceholderText)
|
||||
const placeholderElement = screen.getByText('Test placeholder');
|
||||
expect(placeholderElement).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('opens dropdown when clicked', async () => {
|
||||
const handleChange = jest.fn();
|
||||
render(<CustomSelect options={mockOptions} onChange={handleChange} />);
|
||||
|
||||
// Click to open the dropdown
|
||||
const selectElement = screen.getByRole('combobox');
|
||||
fireEvent.mouseDown(selectElement);
|
||||
|
||||
// Wait for dropdown to appear
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Option 1')).toBeInTheDocument();
|
||||
expect(screen.getByText('Option 2')).toBeInTheDocument();
|
||||
expect(screen.getByText('Option 3')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('calls onChange when option is selected', async () => {
|
||||
const handleChange = jest.fn();
|
||||
render(<CustomSelect options={mockOptions} onChange={handleChange} />);
|
||||
|
||||
// Open dropdown
|
||||
const selectElement = screen.getByRole('combobox');
|
||||
fireEvent.mouseDown(selectElement);
|
||||
|
||||
// Click on an option
|
||||
await waitFor(() => {
|
||||
const option = screen.getByText('Option 2');
|
||||
fireEvent.click(option);
|
||||
});
|
||||
|
||||
// Check onChange was called with correct value
|
||||
expect(handleChange).toHaveBeenCalledWith('option2', expect.anything());
|
||||
});
|
||||
|
||||
it('filters options when searching', async () => {
|
||||
render(<CustomSelect options={mockOptions} />);
|
||||
|
||||
// Open dropdown
|
||||
const selectElement = screen.getByRole('combobox');
|
||||
fireEvent.mouseDown(selectElement);
|
||||
|
||||
// Type into search box
|
||||
fireEvent.change(selectElement, { target: { value: '2' } });
|
||||
|
||||
// Dropdown should only show Option 2
|
||||
await waitFor(() => {
|
||||
// Check that the dropdown is present
|
||||
const dropdownElement = document.querySelector('.custom-select-dropdown');
|
||||
expect(dropdownElement).toBeInTheDocument();
|
||||
|
||||
// Use a simple approach to verify filtering
|
||||
const allOptionsInDropdown = document.querySelectorAll('.option-item');
|
||||
let foundOption2 = false;
|
||||
|
||||
allOptionsInDropdown.forEach((option) => {
|
||||
if (option.textContent?.includes('Option 2')) {
|
||||
foundOption2 = true;
|
||||
}
|
||||
|
||||
// Should not show Options 1 or 3
|
||||
expect(option.textContent).not.toContain('Option 1');
|
||||
expect(option.textContent).not.toContain('Option 3');
|
||||
});
|
||||
|
||||
expect(foundOption2).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
it('renders grouped options correctly', async () => {
|
||||
const handleChange = jest.fn();
|
||||
render(<CustomSelect options={mockGroupedOptions} onChange={handleChange} />);
|
||||
|
||||
// Open dropdown
|
||||
const selectElement = screen.getByRole('combobox');
|
||||
fireEvent.mouseDown(selectElement);
|
||||
|
||||
// Check group headers and options
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Group 1')).toBeInTheDocument();
|
||||
expect(screen.getByText('Group 2')).toBeInTheDocument();
|
||||
expect(screen.getByText('Group 1 - Option 1')).toBeInTheDocument();
|
||||
expect(screen.getByText('Group 1 - Option 2')).toBeInTheDocument();
|
||||
expect(screen.getByText('Group 2 - Option 1')).toBeInTheDocument();
|
||||
expect(screen.getByText('Group 2 - Option 2')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('shows loading state', () => {
|
||||
render(<CustomSelect options={mockOptions} loading />);
|
||||
|
||||
// Open dropdown
|
||||
const selectElement = screen.getByRole('combobox');
|
||||
fireEvent.mouseDown(selectElement);
|
||||
|
||||
// Check loading text is displayed
|
||||
expect(screen.getByText('We are updating the values...')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows error message', () => {
|
||||
render(
|
||||
<CustomSelect options={mockOptions} errorMessage="Test error message" />,
|
||||
);
|
||||
|
||||
// Open dropdown
|
||||
const selectElement = screen.getByRole('combobox');
|
||||
fireEvent.mouseDown(selectElement);
|
||||
|
||||
// Check error message is displayed
|
||||
expect(screen.getByText('Test error message')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows no data message', () => {
|
||||
render(<CustomSelect options={[]} noDataMessage="No data available" />);
|
||||
|
||||
// Open dropdown
|
||||
const selectElement = screen.getByRole('combobox');
|
||||
fireEvent.mouseDown(selectElement);
|
||||
|
||||
// Check no data message is displayed
|
||||
expect(screen.getByText('No data available')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('supports keyboard navigation', async () => {
|
||||
const handleChange = jest.fn();
|
||||
render(<CustomSelect options={mockOptions} onChange={handleChange} />);
|
||||
|
||||
// Open dropdown using keyboard
|
||||
const selectElement = screen.getByRole('combobox');
|
||||
fireEvent.focus(selectElement);
|
||||
|
||||
// Press down arrow to open dropdown
|
||||
fireEvent.keyDown(selectElement, { key: 'ArrowDown' });
|
||||
|
||||
// Wait for dropdown to appear
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Option 1')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('handles selection via keyboard', async () => {
|
||||
const handleChange = jest.fn();
|
||||
render(<CustomSelect options={mockOptions} onChange={handleChange} />);
|
||||
|
||||
// Open dropdown
|
||||
const selectElement = screen.getByRole('combobox');
|
||||
fireEvent.mouseDown(selectElement);
|
||||
|
||||
// Wait for dropdown to appear then press Enter
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Option 1')).toBeInTheDocument();
|
||||
|
||||
// Press Enter to select first option
|
||||
fireEvent.keyDown(screen.getByText('Option 1'), { key: 'Enter' });
|
||||
});
|
||||
|
||||
// Check onChange was called
|
||||
expect(handleChange).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
4
frontend/src/components/NewSelect/index.ts
Normal file
4
frontend/src/components/NewSelect/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
import CustomMultiSelect from './CustomMultiSelect';
|
||||
import CustomSelect from './CustomSelect';
|
||||
|
||||
export { CustomMultiSelect, CustomSelect };
|
||||
838
frontend/src/components/NewSelect/styles.scss
Normal file
838
frontend/src/components/NewSelect/styles.scss
Normal file
@@ -0,0 +1,838 @@
|
||||
// Main container styles
|
||||
|
||||
// make const of #2c3044
|
||||
$custom-border-color: #2c3044;
|
||||
|
||||
.custom-select {
|
||||
width: 100%;
|
||||
position: relative;
|
||||
|
||||
&.ant-select-focused {
|
||||
.ant-select-selector {
|
||||
border-color: var(--bg-robin-500);
|
||||
box-shadow: 0 0 0 2px rgba(78, 116, 248, 0.2);
|
||||
}
|
||||
}
|
||||
|
||||
.ant-select-selection-placeholder {
|
||||
color: rgba(192, 193, 195, 0.45);
|
||||
}
|
||||
|
||||
// Base styles are for dark mode
|
||||
.ant-select-selector {
|
||||
background-color: var(--bg-ink-400);
|
||||
border-color: var(--bg-slate-400);
|
||||
}
|
||||
|
||||
.ant-select-clear {
|
||||
background-color: var(--bg-ink-400);
|
||||
color: rgba(192, 193, 195, 0.7);
|
||||
}
|
||||
}
|
||||
|
||||
// Keep chip styles ONLY in the multi-select
|
||||
.custom-multiselect {
|
||||
width: 100%;
|
||||
position: relative;
|
||||
|
||||
.ant-select-selector {
|
||||
max-height: 200px;
|
||||
overflow: auto;
|
||||
scrollbar-width: thin;
|
||||
background-color: var(--bg-ink-400);
|
||||
border-color: var(--bg-slate-400);
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-thumb {
|
||||
background-color: $custom-border-color;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-track {
|
||||
background-color: var(--bg-slate-400);
|
||||
}
|
||||
}
|
||||
|
||||
&.ant-select-focused {
|
||||
.ant-select-selector {
|
||||
border-color: var(--bg-robin-500);
|
||||
box-shadow: 0 0 0 2px rgba(78, 116, 248, 0.2);
|
||||
}
|
||||
}
|
||||
|
||||
.ant-select-selection-placeholder {
|
||||
color: rgba(192, 193, 195, 0.45);
|
||||
}
|
||||
|
||||
// Customize tags in multiselect (dark mode by default)
|
||||
.ant-select-selection-item {
|
||||
background-color: var(--bg-slate-400);
|
||||
border-radius: 4px;
|
||||
border: 1px solid $custom-border-color;
|
||||
margin-right: 4px;
|
||||
transition: all 0.2s;
|
||||
color: var(--bg-vanilla-400);
|
||||
|
||||
// Style for active tag (keyboard navigation)
|
||||
&-active {
|
||||
border-color: var(--bg-robin-500) !important;
|
||||
background-color: rgba(78, 116, 248, 0.15) !important;
|
||||
outline: 2px solid rgba(78, 116, 248, 0.2);
|
||||
}
|
||||
|
||||
// Style for selected tags (via keyboard or mouse selection)
|
||||
&-selected {
|
||||
border-color: var(--bg-robin-500) !important;
|
||||
background-color: rgba(78, 116, 248, 0.15) !important;
|
||||
box-shadow: 0 0 0 2px rgba(78, 116, 248, 0.2);
|
||||
}
|
||||
|
||||
.ant-select-selection-item-content {
|
||||
color: var(--bg-vanilla-400);
|
||||
}
|
||||
|
||||
.ant-select-selection-item-remove {
|
||||
color: rgba(192, 193, 195, 0.7);
|
||||
&:hover {
|
||||
color: rgba(192, 193, 195, 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Class applied when in selection mode
|
||||
&.has-selection {
|
||||
.ant-select-selection-item-selected {
|
||||
cursor: move; // Indicate draggable
|
||||
}
|
||||
|
||||
// Change cursor for selection
|
||||
.ant-select-selector {
|
||||
cursor: text;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Dropdown styles
|
||||
.custom-select-dropdown-container,
|
||||
.custom-multiselect-dropdown-container {
|
||||
z-index: 1050 !important;
|
||||
padding: 0;
|
||||
box-shadow: 0 3px 6px -4px rgba(0, 0, 0, 0.5), 0 6px 16px 0 rgba(0, 0, 0, 0.4),
|
||||
0 9px 28px 8px rgba(0, 0, 0, 0.3);
|
||||
background-color: var(--bg-ink-400);
|
||||
border: 1px solid var(--bg-slate-400);
|
||||
|
||||
.ant-select-item {
|
||||
padding: 8px 12px;
|
||||
color: var(--bg-vanilla-400);
|
||||
|
||||
// Make keyboard navigation visible
|
||||
&-option-active {
|
||||
background-color: var(--bg-slate-400) !important;
|
||||
}
|
||||
|
||||
&-option-selected {
|
||||
background-color: rgba(78, 116, 248, 0.15) !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.custom-select-dropdown-container,
|
||||
.custom-multiselect-dropdown-container {
|
||||
width: 100%;
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
resize: horizontal;
|
||||
min-width: 300px !important;
|
||||
|
||||
.empty-message {
|
||||
padding: 12px;
|
||||
text-align: center;
|
||||
color: rgba(192, 193, 195, 0.45);
|
||||
}
|
||||
}
|
||||
|
||||
// Custom dropdown styles for single select
|
||||
.custom-select-dropdown {
|
||||
padding: 8px 0 0 0;
|
||||
max-height: 500px;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
scrollbar-width: thin;
|
||||
border-radius: 4px;
|
||||
border: 1px solid var(--bg-slate-400);
|
||||
width: 100%;
|
||||
background-color: var(--bg-ink-400);
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-thumb {
|
||||
background-color: $custom-border-color;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-track {
|
||||
background-color: var(--bg-slate-400);
|
||||
}
|
||||
|
||||
.no-section-options {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.select-group {
|
||||
margin-bottom: 16px;
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
|
||||
.group-label {
|
||||
font-weight: 500;
|
||||
padding: 4px 12px;
|
||||
font-size: 13px;
|
||||
color: var(--bg-vanilla-400);
|
||||
background-color: var(--bg-slate-400);
|
||||
border-bottom: 1px solid $custom-border-color;
|
||||
border-top: 1px solid $custom-border-color;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
.option-item {
|
||||
padding: 8px 12px;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
color: var(--bg-vanilla-400);
|
||||
|
||||
&:hover {
|
||||
background-color: var(--bg-slate-400);
|
||||
}
|
||||
|
||||
&.selected {
|
||||
background-color: rgba(78, 116, 248, 0.15);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
&.active {
|
||||
background-color: rgba(78, 116, 248, 0.15);
|
||||
border-color: var(--bg-robin-500);
|
||||
}
|
||||
|
||||
.option-content {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
|
||||
.option-label-text {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.option-badge {
|
||||
font-size: 12px;
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
background-color: $custom-border-color;
|
||||
color: var(--bg-vanilla-400);
|
||||
margin-left: 8px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.loading-container {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
.navigation-footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 8px 12px;
|
||||
border-top: 1px solid var(--bg-slate-400);
|
||||
position: sticky;
|
||||
bottom: 0;
|
||||
background-color: var(--bg-ink-400);
|
||||
z-index: 1;
|
||||
|
||||
.navigation-icons {
|
||||
display: flex;
|
||||
margin-right: 8px;
|
||||
color: var(--bg-vanilla-400);
|
||||
}
|
||||
|
||||
.navigation-text {
|
||||
color: var(--bg-vanilla-400);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.navigation-error {
|
||||
.navigation-text,
|
||||
.navigation-icons {
|
||||
color: var(--bg-cherry-500) !important;
|
||||
}
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.navigation-loading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
|
||||
.navigation-text,
|
||||
.navigation-icons {
|
||||
color: var(--bg-robin-600) !important;
|
||||
}
|
||||
}
|
||||
|
||||
.navigate {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding-right: 12px;
|
||||
gap: 6px;
|
||||
|
||||
.icons {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
flex-shrink: 0;
|
||||
border-radius: 2.286px;
|
||||
border-top: 1.143px solid var(--bg-ink-200);
|
||||
border-right: 1.143px solid var(--bg-ink-200);
|
||||
border-bottom: 2.286px solid var(--bg-ink-200);
|
||||
border-left: 1.143px solid var(--bg-ink-200);
|
||||
background: var(--Ink-400, var(--bg-ink-400));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Custom dropdown styles for multi-select
|
||||
.custom-multiselect-dropdown {
|
||||
padding: 8px 0 0 0;
|
||||
max-height: 500px;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
scrollbar-width: thin;
|
||||
border-radius: 4px;
|
||||
border: 1px solid var(--bg-slate-400);
|
||||
width: 100%;
|
||||
background-color: var(--bg-ink-400);
|
||||
|
||||
.select-all-option,
|
||||
.custom-value-option {
|
||||
padding: 8px 12px;
|
||||
border-bottom: 1px solid $custom-border-color;
|
||||
margin-bottom: 8px;
|
||||
background-color: var(--bg-slate-400);
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.selected-values-section {
|
||||
padding: 0 0 8px 0;
|
||||
border-bottom: 1px solid $custom-border-color;
|
||||
margin-bottom: 8px;
|
||||
|
||||
.selected-option {
|
||||
padding: 4px 12px;
|
||||
}
|
||||
}
|
||||
|
||||
.select-group {
|
||||
margin-bottom: 12px;
|
||||
overflow: hidden;
|
||||
|
||||
.group-label {
|
||||
font-weight: 500;
|
||||
padding: 4px 12px;
|
||||
font-size: 13px;
|
||||
color: var(--bg-vanilla-400);
|
||||
background-color: var(--bg-slate-400);
|
||||
border-bottom: 1px solid $custom-border-color;
|
||||
border-top: 1px solid $custom-border-color;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.option-item {
|
||||
padding: 8px 12px;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
color: var(--bg-vanilla-400);
|
||||
|
||||
&.active {
|
||||
background-color: rgba(78, 116, 248, 0.15);
|
||||
border-color: var(--bg-robin-500);
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background-color: var(--bg-slate-400);
|
||||
}
|
||||
|
||||
&.selected {
|
||||
background-color: rgba(78, 116, 248, 0.15);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
&.all-option {
|
||||
font-weight: 500;
|
||||
border-bottom: 1px solid $custom-border-color;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.option-checkbox {
|
||||
width: 100%;
|
||||
|
||||
> span:not(.ant-checkbox) {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.option-content {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
|
||||
.option-label-text {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.option-badge {
|
||||
font-size: 12px;
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
background-color: $custom-border-color;
|
||||
color: var(--bg-vanilla-400);
|
||||
margin-left: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
.only-btn {
|
||||
display: none;
|
||||
}
|
||||
.toggle-btn {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.only-btn:hover {
|
||||
background-color: unset;
|
||||
}
|
||||
.toggle-btn:hover {
|
||||
background-color: unset;
|
||||
}
|
||||
|
||||
.option-content:hover {
|
||||
.only-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 21px;
|
||||
}
|
||||
.toggle-btn {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.option-badge {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.option-checkbox:hover {
|
||||
.toggle-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 21px;
|
||||
}
|
||||
.option-badge {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.loading-container {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.empty-message {
|
||||
padding: 12px;
|
||||
text-align: center;
|
||||
color: rgba(192, 193, 195, 0.45);
|
||||
}
|
||||
|
||||
.status-message {
|
||||
padding: 8px 12px;
|
||||
text-align: center;
|
||||
font-style: italic;
|
||||
color: rgba(192, 193, 195, 0.65);
|
||||
border-top: 1px dashed $custom-border-color;
|
||||
}
|
||||
}
|
||||
|
||||
// Custom styles for highlight text
|
||||
.highlight-text {
|
||||
background-color: rgba(78, 116, 248, 0.2);
|
||||
padding: 0 1px;
|
||||
border-radius: 2px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
// Custom option styles for keyboard navigation
|
||||
.custom-option {
|
||||
&.focused,
|
||||
&.ant-select-item-option-active {
|
||||
background-color: var(--bg-slate-400) !important;
|
||||
}
|
||||
}
|
||||
|
||||
// Improve the sticky headers appearance
|
||||
.custom-select-dropdown-container {
|
||||
.group-label,
|
||||
.ant-select-item-group {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 2;
|
||||
background-color: var(--bg-slate-400);
|
||||
border-bottom: 1px solid $custom-border-color;
|
||||
padding: 4px 12px;
|
||||
margin: 0;
|
||||
width: 100%; // Ensure the header spans full width
|
||||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.2); // Add subtle shadow for separation
|
||||
}
|
||||
|
||||
// Ensure proper spacing between sections
|
||||
.select-group {
|
||||
margin-bottom: 8px;
|
||||
position: relative; // Create a positioning context
|
||||
}
|
||||
}
|
||||
|
||||
// Custom scrollbar styling (shared between components)
|
||||
@mixin custom-scrollbar {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: rgba(192, 193, 195, 0.3) rgba(29, 33, 45, 0.6);
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-track {
|
||||
background-color: rgba(29, 33, 45, 0.6);
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-thumb {
|
||||
background-color: rgba(192, 193, 195, 0.3);
|
||||
border-radius: 10px;
|
||||
transition: background-color 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
background-color: rgba(192, 193, 195, 0.5);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Subtle nested scrollbar styling
|
||||
@mixin nested-scrollbar {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: rgba(192, 193, 195, 0.2) rgba(29, 33, 45, 0.6);
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
width: 4px;
|
||||
height: 4px;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-track {
|
||||
background-color: rgba(29, 33, 45, 0.6);
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-thumb {
|
||||
background-color: rgba(192, 193, 195, 0.2);
|
||||
border-radius: 10px;
|
||||
|
||||
&:hover {
|
||||
background-color: rgba(192, 193, 195, 0.3);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Apply to main dropdown containers
|
||||
.custom-select-dropdown,
|
||||
.custom-multiselect-dropdown {
|
||||
@include custom-scrollbar;
|
||||
|
||||
// Main content area
|
||||
.options-container {
|
||||
@include custom-scrollbar;
|
||||
padding-right: 2px; // Add slight padding to prevent content touching scrollbar
|
||||
}
|
||||
|
||||
// Non-sectioned options
|
||||
.no-section-options {
|
||||
@include nested-scrollbar;
|
||||
margin-right: 2px;
|
||||
padding-right: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
// Apply to dropdown container wrappers
|
||||
.custom-select-dropdown-container,
|
||||
.custom-multiselect-dropdown-container {
|
||||
@include custom-scrollbar;
|
||||
|
||||
// Add subtle shadow inside to indicate scrollable area
|
||||
&.has-overflow {
|
||||
box-shadow: inset 0 -10px 10px -10px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
}
|
||||
|
||||
// Light Mode Overrides
|
||||
.lightMode {
|
||||
.custom-select {
|
||||
.ant-select-selector {
|
||||
background-color: var(--bg-vanilla-100);
|
||||
border-color: #e9e9e9;
|
||||
}
|
||||
|
||||
.ant-select-selection-placeholder {
|
||||
color: rgba(0, 0, 0, 0.45);
|
||||
}
|
||||
|
||||
.ant-select-clear {
|
||||
background-color: var(--bg-vanilla-100);
|
||||
color: rgba(0, 0, 0, 0.45);
|
||||
}
|
||||
|
||||
&.ant-select-focused {
|
||||
.ant-select-selector {
|
||||
border-color: #1890ff;
|
||||
box-shadow: 0 0 0 2px rgba(24, 144, 255, 0.2);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.custom-multiselect {
|
||||
.ant-select-selector {
|
||||
background-color: var(--bg-vanilla-100);
|
||||
border-color: #e9e9e9;
|
||||
|
||||
&::-webkit-scrollbar-thumb {
|
||||
background-color: #ccc;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-track {
|
||||
background-color: #f0f0f0;
|
||||
}
|
||||
}
|
||||
|
||||
.ant-select-selection-placeholder {
|
||||
color: rgba(0, 0, 0, 0.45);
|
||||
}
|
||||
|
||||
.ant-select-selection-item {
|
||||
background-color: #f5f5f5;
|
||||
border: 1px solid #e8e8e8;
|
||||
color: rgba(0, 0, 0, 0.85);
|
||||
|
||||
.ant-select-selection-item-content {
|
||||
color: rgba(0, 0, 0, 0.85);
|
||||
}
|
||||
|
||||
.ant-select-selection-item-remove {
|
||||
color: rgba(0, 0, 0, 0.45);
|
||||
&:hover {
|
||||
color: rgba(0, 0, 0, 0.85);
|
||||
}
|
||||
}
|
||||
|
||||
&-active {
|
||||
border-color: var(--bg-robin-500) !important;
|
||||
background-color: var(--bg-vanilla-300) !important;
|
||||
}
|
||||
|
||||
&-selected {
|
||||
border-color: #1890ff !important;
|
||||
background-color: var(--bg-vanilla-300) !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.custom-select-dropdown-container,
|
||||
.custom-multiselect-dropdown-container {
|
||||
background-color: var(--bg-vanilla-100);
|
||||
border: 1px solid #f0f0f0;
|
||||
box-shadow: 0 3px 6px -4px rgba(0, 0, 0, 0.12),
|
||||
0 6px 16px 0 rgba(0, 0, 0, 0.08), 0 9px 28px 8px rgba(0, 0, 0, 0.05);
|
||||
|
||||
.empty-message {
|
||||
color: rgba(0, 0, 0, 0.45);
|
||||
}
|
||||
|
||||
.ant-select-item {
|
||||
color: rgba(0, 0, 0, 0.85);
|
||||
|
||||
&-option-active {
|
||||
background-color: #f5f5f5 !important;
|
||||
}
|
||||
|
||||
&-option-selected {
|
||||
background-color: var(--bg-vanilla-300) !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.custom-select-dropdown,
|
||||
.custom-multiselect-dropdown {
|
||||
border: 1px solid #f0f0f0;
|
||||
background-color: var(--bg-vanilla-100);
|
||||
|
||||
&::-webkit-scrollbar-thumb {
|
||||
background-color: #ccc;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-track {
|
||||
background-color: #f0f0f0;
|
||||
}
|
||||
|
||||
.select-group {
|
||||
.group-label {
|
||||
color: rgba(0, 0, 0, 0.85);
|
||||
background-color: #fafafa;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
border-top: 1px solid #f0f0f0;
|
||||
}
|
||||
}
|
||||
|
||||
.option-item {
|
||||
color: rgba(0, 0, 0, 0.85);
|
||||
|
||||
&:hover {
|
||||
background-color: #f5f5f5;
|
||||
}
|
||||
|
||||
&.selected {
|
||||
background-color: var(--bg-vanilla-300);
|
||||
}
|
||||
|
||||
&.active {
|
||||
background-color: var(--bg-vanilla-300);
|
||||
border-color: #91d5ff;
|
||||
}
|
||||
|
||||
.option-content {
|
||||
.option-badge {
|
||||
background-color: #f0f0f0;
|
||||
color: #666;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.navigation-footer {
|
||||
border-top: 1px solid #f0f0f0;
|
||||
background-color: var(--bg-vanilla-100);
|
||||
|
||||
.navigation-icons {
|
||||
color: rgba(0, 0, 0, 0.45);
|
||||
}
|
||||
|
||||
.navigation-text {
|
||||
color: rgba(0, 0, 0, 0.45);
|
||||
}
|
||||
|
||||
.navigate {
|
||||
.icons {
|
||||
border-top: 1.143px solid var(--bg-ink-200);
|
||||
border-right: 1.143px solid var(--bg-ink-200);
|
||||
border-bottom: 2.286px solid var(--bg-ink-200);
|
||||
border-left: 1.143px solid var(--bg-ink-200);
|
||||
background: var(--bg-vanilla-300);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.custom-multiselect-dropdown {
|
||||
.select-all-option,
|
||||
.custom-value-option {
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
background-color: #fafafa;
|
||||
}
|
||||
|
||||
.selected-values-section {
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
}
|
||||
|
||||
.status-message {
|
||||
color: rgba(0, 0, 0, 0.65);
|
||||
border-top: 1px dashed #f0f0f0;
|
||||
}
|
||||
|
||||
.option-item {
|
||||
&.all-option {
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.highlight-text {
|
||||
background-color: rgba(24, 144, 255, 0.2);
|
||||
}
|
||||
|
||||
.custom-option {
|
||||
&.focused,
|
||||
&.ant-select-item-option-active {
|
||||
background-color: #f5f5f5 !important;
|
||||
}
|
||||
}
|
||||
|
||||
.custom-select-dropdown-container {
|
||||
.group-label,
|
||||
.ant-select-item-group {
|
||||
background-color: #f5f0f0;
|
||||
border-bottom: 1px solid #e8e8e8;
|
||||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
}
|
||||
|
||||
// Light mode scrollbar overrides
|
||||
.custom-select-dropdown,
|
||||
.custom-multiselect-dropdown,
|
||||
.custom-select-dropdown-container,
|
||||
.custom-multiselect-dropdown-container {
|
||||
scrollbar-color: rgba(0, 0, 0, 0.2) rgba(0, 0, 0, 0.05);
|
||||
|
||||
&::-webkit-scrollbar-track {
|
||||
background-color: rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-thumb {
|
||||
background-color: rgba(0, 0, 0, 0.2);
|
||||
|
||||
&:hover {
|
||||
background-color: rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
60
frontend/src/components/NewSelect/types.ts
Normal file
60
frontend/src/components/NewSelect/types.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import { SelectProps } from 'antd';
|
||||
|
||||
export interface OptionData {
|
||||
label: string;
|
||||
value?: string;
|
||||
disabled?: boolean;
|
||||
className?: string;
|
||||
style?: React.CSSProperties;
|
||||
options?: OptionData[];
|
||||
type?: 'defined' | 'custom' | 'regex';
|
||||
}
|
||||
|
||||
export interface CustomSelectProps extends Omit<SelectProps, 'options'> {
|
||||
placeholder?: string;
|
||||
className?: string;
|
||||
loading?: boolean;
|
||||
onSearch?: (value: string) => void;
|
||||
options?: OptionData[];
|
||||
defaultActiveFirstOption?: boolean;
|
||||
noDataMessage?: string;
|
||||
onClear?: () => void;
|
||||
getPopupContainer?: (triggerNode: HTMLElement) => HTMLElement;
|
||||
dropdownRender?: (menu: React.ReactElement) => React.ReactElement;
|
||||
highlightSearch?: boolean;
|
||||
placement?: 'topLeft' | 'topRight' | 'bottomLeft' | 'bottomRight';
|
||||
popupMatchSelectWidth?: boolean;
|
||||
errorMessage?: string;
|
||||
allowClear?: SelectProps['allowClear'];
|
||||
onRetry?: () => void;
|
||||
}
|
||||
|
||||
export interface CustomTagProps {
|
||||
label: React.ReactNode;
|
||||
value: string;
|
||||
closable: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export interface CustomMultiSelectProps
|
||||
extends Omit<SelectProps<string[] | string>, 'options'> {
|
||||
placeholder?: string;
|
||||
className?: string;
|
||||
loading?: boolean;
|
||||
onSearch?: (value: string) => void;
|
||||
options?: OptionData[];
|
||||
defaultActiveFirstOption?: boolean;
|
||||
dropdownMatchSelectWidth?: boolean | number;
|
||||
noDataMessage?: string;
|
||||
onClear?: () => void;
|
||||
enableAllSelection?: boolean;
|
||||
getPopupContainer?: (triggerNode: HTMLElement) => HTMLElement;
|
||||
dropdownRender?: (menu: React.ReactElement) => React.ReactElement;
|
||||
highlightSearch?: boolean;
|
||||
errorMessage?: string;
|
||||
popupClassName?: string;
|
||||
placement?: 'topLeft' | 'topRight' | 'bottomLeft' | 'bottomRight';
|
||||
maxTagCount?: number;
|
||||
allowClear?: SelectProps['allowClear'];
|
||||
onRetry?: () => void;
|
||||
}
|
||||
135
frontend/src/components/NewSelect/utils.ts
Normal file
135
frontend/src/components/NewSelect/utils.ts
Normal file
@@ -0,0 +1,135 @@
|
||||
/* eslint-disable sonarjs/cognitive-complexity */
|
||||
import { OptionData } from './types';
|
||||
|
||||
export const SPACEKEY = ' ';
|
||||
|
||||
export const prioritizeOrAddOptionForSingleSelect = (
|
||||
options: OptionData[],
|
||||
value: string,
|
||||
label?: string,
|
||||
): OptionData[] => {
|
||||
let foundOption: OptionData | null = null;
|
||||
|
||||
// Separate the found option and the rest
|
||||
const filteredOptions = options
|
||||
.map((option) => {
|
||||
if ('options' in option && Array.isArray(option.options)) {
|
||||
// Filter out the value from nested options
|
||||
const remainingSubOptions = option.options.filter(
|
||||
(subOption) => subOption.value !== value,
|
||||
);
|
||||
const extractedOption = option.options.find(
|
||||
(subOption) => subOption.value === value,
|
||||
);
|
||||
|
||||
if (extractedOption) foundOption = extractedOption;
|
||||
|
||||
// Keep the group if it still has remaining options
|
||||
return remainingSubOptions.length > 0
|
||||
? { ...option, options: remainingSubOptions }
|
||||
: null;
|
||||
}
|
||||
|
||||
// Check top-level options
|
||||
if (option.value === value) {
|
||||
foundOption = option;
|
||||
return null; // Remove it from the list
|
||||
}
|
||||
|
||||
return option;
|
||||
})
|
||||
.filter(Boolean) as OptionData[]; // Remove null values
|
||||
|
||||
// If not found, create a new option
|
||||
if (!foundOption) {
|
||||
foundOption = { value, label: label ?? value };
|
||||
}
|
||||
|
||||
// Add the found/new option at the top
|
||||
return [foundOption, ...filteredOptions];
|
||||
};
|
||||
|
||||
export const prioritizeOrAddOptionForMultiSelect = (
|
||||
options: OptionData[],
|
||||
values: string[], // Only supports multiple values (string[])
|
||||
labels?: Record<string, string>,
|
||||
): OptionData[] => {
|
||||
const foundOptions: OptionData[] = [];
|
||||
|
||||
// Separate the found options and the rest
|
||||
const filteredOptions = options
|
||||
.map((option) => {
|
||||
if ('options' in option && Array.isArray(option.options)) {
|
||||
// Filter out selected values from nested options
|
||||
const remainingSubOptions = option.options.filter(
|
||||
(subOption) => subOption.value && !values.includes(subOption.value),
|
||||
);
|
||||
const extractedOptions = option.options.filter(
|
||||
(subOption) => subOption.value && values.includes(subOption.value),
|
||||
);
|
||||
|
||||
if (extractedOptions.length > 0) {
|
||||
foundOptions.push(...extractedOptions);
|
||||
}
|
||||
|
||||
// Keep the group if it still has remaining options
|
||||
return remainingSubOptions.length > 0
|
||||
? { ...option, options: remainingSubOptions }
|
||||
: null;
|
||||
}
|
||||
|
||||
// Check top-level options
|
||||
if (option.value && values.includes(option.value)) {
|
||||
foundOptions.push(option);
|
||||
return null; // Remove it from the list
|
||||
}
|
||||
|
||||
return option;
|
||||
})
|
||||
.filter(Boolean) as OptionData[]; // Remove null values
|
||||
|
||||
// Find missing values that were not present in the original options and create new ones
|
||||
const missingValues = values.filter(
|
||||
(value) => !foundOptions.some((opt) => opt.value === value),
|
||||
);
|
||||
|
||||
const newOptions = missingValues.map((value) => ({
|
||||
value,
|
||||
label: labels?.[value] ?? value, // Use provided label or default to value
|
||||
}));
|
||||
|
||||
// Add found & new options to the top
|
||||
return [...newOptions, ...foundOptions, ...filteredOptions];
|
||||
};
|
||||
|
||||
/**
|
||||
* Filters options based on search text
|
||||
*/
|
||||
export const filterOptionsBySearch = (
|
||||
options: OptionData[],
|
||||
searchText: string,
|
||||
): OptionData[] => {
|
||||
if (!searchText.trim()) return options;
|
||||
|
||||
const lowerSearchText = searchText.toLowerCase();
|
||||
|
||||
return options
|
||||
.map((option) => {
|
||||
if ('options' in option && Array.isArray(option.options)) {
|
||||
// Filter nested options
|
||||
const filteredSubOptions = option.options.filter((subOption) =>
|
||||
subOption.label.toLowerCase().includes(lowerSearchText),
|
||||
);
|
||||
|
||||
return filteredSubOptions.length > 0
|
||||
? { ...option, options: filteredSubOptions }
|
||||
: undefined;
|
||||
}
|
||||
|
||||
// Filter top-level options
|
||||
return option.label.toLowerCase().includes(lowerSearchText)
|
||||
? option
|
||||
: undefined;
|
||||
})
|
||||
.filter(Boolean) as OptionData[];
|
||||
};
|
||||
@@ -3,7 +3,7 @@ import 'dayjs/locale/en';
|
||||
|
||||
import { PlusOutlined } from '@ant-design/icons';
|
||||
import { Color } from '@signozhq/design-tokens';
|
||||
import { Button, Flex, Form, Input, Typography } from 'antd';
|
||||
import { Button, Flex, Form, Input, Tooltip, Typography } from 'antd';
|
||||
import getAll from 'api/alerts/getAll';
|
||||
import { useDeleteDowntimeSchedule } from 'api/plannedDowntime/deleteDowntimeSchedule';
|
||||
import {
|
||||
@@ -13,8 +13,10 @@ import {
|
||||
import dayjs from 'dayjs';
|
||||
import { useNotifications } from 'hooks/useNotifications';
|
||||
import { Search } from 'lucide-react';
|
||||
import { useAppContext } from 'providers/App/App';
|
||||
import React, { ChangeEvent, useEffect, useState } from 'react';
|
||||
import { useQuery } from 'react-query';
|
||||
import { USER_ROLES } from 'types/roles';
|
||||
|
||||
import { PlannedDowntimeDeleteModal } from './PlannedDowntimeDeleteModal';
|
||||
import { PlannedDowntimeForm } from './PlannedDowntimeForm';
|
||||
@@ -33,6 +35,7 @@ export function PlannedDowntime(): JSX.Element {
|
||||
});
|
||||
const [isOpen, setIsOpen] = React.useState(false);
|
||||
const [form] = Form.useForm();
|
||||
const { user } = useAppContext();
|
||||
|
||||
const [initialValues, setInitialValues] = useState<
|
||||
Partial<DowntimeSchedules & { editMode: boolean }>
|
||||
@@ -108,18 +111,27 @@ export function PlannedDowntime(): JSX.Element {
|
||||
value={searchValue}
|
||||
onChange={handleSearch}
|
||||
/>
|
||||
<Button
|
||||
icon={<PlusOutlined />}
|
||||
type="primary"
|
||||
onClick={(): void => {
|
||||
setInitialValues({ ...defautlInitialValues, editMode: false });
|
||||
setIsOpen(true);
|
||||
setEditMode(false);
|
||||
form.resetFields();
|
||||
}}
|
||||
<Tooltip
|
||||
title={
|
||||
user?.role === USER_ROLES.VIEWER
|
||||
? 'You need edit permissions to create a planned downtime'
|
||||
: ''
|
||||
}
|
||||
>
|
||||
New downtime
|
||||
</Button>
|
||||
<Button
|
||||
icon={<PlusOutlined />}
|
||||
type="primary"
|
||||
onClick={(): void => {
|
||||
setInitialValues({ ...defautlInitialValues, editMode: false });
|
||||
setIsOpen(true);
|
||||
setEditMode(false);
|
||||
form.resetFields();
|
||||
}}
|
||||
disabled={user?.role === USER_ROLES.VIEWER}
|
||||
>
|
||||
New downtime
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</Flex>
|
||||
<br />
|
||||
<PlannedDowntimeList
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import { screen } from '@testing-library/react';
|
||||
import { render } from 'tests/test-utils';
|
||||
import { USER_ROLES } from 'types/roles';
|
||||
|
||||
import { PlannedDowntime } from '../PlannedDowntime';
|
||||
|
||||
describe('PlannedDowntime Component', () => {
|
||||
it('renders the PlannedDowntime component properly', () => {
|
||||
render(<PlannedDowntime />, {}, 'ADMIN');
|
||||
|
||||
// Check if title is rendered
|
||||
expect(screen.getByText('Planned Downtime')).toBeInTheDocument();
|
||||
|
||||
// Check if subtitle is rendered
|
||||
expect(
|
||||
screen.getByText('Create and manage planned downtimes.'),
|
||||
).toBeInTheDocument();
|
||||
|
||||
// Check if search input is rendered
|
||||
expect(
|
||||
screen.getByPlaceholderText('Search for a planned downtime...'),
|
||||
).toBeInTheDocument();
|
||||
|
||||
// Check if "New downtime" button is enabled for ADMIN
|
||||
const newDowntimeButton = screen.getByRole('button', {
|
||||
name: /new downtime/i,
|
||||
});
|
||||
expect(newDowntimeButton).toBeInTheDocument();
|
||||
expect(newDowntimeButton).not.toBeDisabled();
|
||||
});
|
||||
|
||||
it('disables the "New downtime" button for users with VIEWER role', () => {
|
||||
render(<PlannedDowntime />, {}, USER_ROLES.VIEWER);
|
||||
|
||||
// Check if "New downtime" button is disabled for VIEWER
|
||||
const newDowntimeButton = screen.getByRole('button', {
|
||||
name: /new downtime/i,
|
||||
});
|
||||
expect(newDowntimeButton).toBeInTheDocument();
|
||||
expect(newDowntimeButton).toBeDisabled();
|
||||
|
||||
expect(newDowntimeButton).toHaveAttribute('disabled');
|
||||
});
|
||||
});
|
||||
@@ -5522,10 +5522,10 @@ axe-core@^4.6.2:
|
||||
resolved "https://registry.npmjs.org/axe-core/-/axe-core-4.7.0.tgz"
|
||||
integrity sha512-M0JtH+hlOL5pLQwHOLNYZaXuhqmvS8oExsqB1SBYgA4Dk7u/xx+YdGHXaK5pyUfed5mYXdlYiphWq3G8cRi5JQ==
|
||||
|
||||
axios@1.7.7:
|
||||
version "1.7.7"
|
||||
resolved "https://registry.yarnpkg.com/axios/-/axios-1.7.7.tgz#2f554296f9892a72ac8d8e4c5b79c14a91d0a47f"
|
||||
integrity sha512-S4kL7XrjgBmvdGut0sN3yJxqYzrDOnivkBiN0OFs6hLiUam3UPvswUo0kqGyhqUZGEOytHyumEdXsAkgCOUf3Q==
|
||||
axios@1.8.2:
|
||||
version "1.8.2"
|
||||
resolved "https://registry.yarnpkg.com/axios/-/axios-1.8.2.tgz#fabe06e241dfe83071d4edfbcaa7b1c3a40f7979"
|
||||
integrity sha512-ls4GYBm5aig9vWx8AWDSGLpnpDQRtWAfrjU+EuytuODrFBkqesN2RkOQCBzrA1RQNHw1SmRMSDDDSwzNAYQ6Rg==
|
||||
dependencies:
|
||||
follow-redirects "^1.15.6"
|
||||
form-data "^4.0.0"
|
||||
|
||||
@@ -6,31 +6,31 @@ import (
|
||||
)
|
||||
|
||||
var (
|
||||
CodeInvalidInput code = code{"invalid_input"}
|
||||
CodeInternal = code{"internal"}
|
||||
CodeUnsupported = code{"unsupported"}
|
||||
CodeNotFound = code{"not_found"}
|
||||
CodeMethodNotAllowed = code{"method_not_allowed"}
|
||||
CodeAlreadyExists = code{"already_exists"}
|
||||
CodeUnauthenticated = code{"unauthenticated"}
|
||||
CodeForbidden = code{"forbidden"}
|
||||
CodeInvalidInput Code = Code{"invalid_input"}
|
||||
CodeInternal = Code{"internal"}
|
||||
CodeUnsupported = Code{"unsupported"}
|
||||
CodeNotFound = Code{"not_found"}
|
||||
CodeMethodNotAllowed = Code{"method_not_allowed"}
|
||||
CodeAlreadyExists = Code{"already_exists"}
|
||||
CodeUnauthenticated = Code{"unauthenticated"}
|
||||
CodeForbidden = Code{"forbidden"}
|
||||
)
|
||||
|
||||
var (
|
||||
codeRegex = regexp.MustCompile(`^[a-z_]+$`)
|
||||
)
|
||||
|
||||
type code struct{ s string }
|
||||
type Code struct{ s string }
|
||||
|
||||
func NewCode(s string) (code, error) {
|
||||
func NewCode(s string) (Code, error) {
|
||||
if !codeRegex.MatchString(s) {
|
||||
return code{}, fmt.Errorf("invalid code: %v", s)
|
||||
return Code{}, fmt.Errorf("invalid code: %v", s)
|
||||
}
|
||||
|
||||
return code{s: s}, nil
|
||||
return Code{s: s}, nil
|
||||
}
|
||||
|
||||
func MustNewCode(s string) code {
|
||||
func MustNewCode(s string) Code {
|
||||
code, err := NewCode(s)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
@@ -39,6 +39,6 @@ func MustNewCode(s string) code {
|
||||
return code
|
||||
}
|
||||
|
||||
func (c code) String() string {
|
||||
func (c Code) String() string {
|
||||
return c.s
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ import (
|
||||
)
|
||||
|
||||
var (
|
||||
codeUnknown code = MustNewCode("unknown")
|
||||
codeUnknown Code = MustNewCode("unknown")
|
||||
)
|
||||
|
||||
// base is the fundamental struct that implements the error interface.
|
||||
@@ -16,7 +16,7 @@ type base struct {
|
||||
// t denotes the custom type of the error.
|
||||
t typ
|
||||
// c denotes the short code for the error message.
|
||||
c code
|
||||
c Code
|
||||
// m contains error message passed through errors.New.
|
||||
m string
|
||||
// e is the actual error being wrapped.
|
||||
@@ -47,7 +47,7 @@ func (b *base) Error() string {
|
||||
}
|
||||
|
||||
// New returns a base error. It requires type, code and message as input.
|
||||
func New(t typ, code code, message string) *base {
|
||||
func New(t typ, code Code, message string) *base {
|
||||
return &base{
|
||||
t: t,
|
||||
c: code,
|
||||
@@ -59,7 +59,7 @@ func New(t typ, code code, message string) *base {
|
||||
}
|
||||
|
||||
// Newf returns a new base by formatting the error message with the supplied format specifier.
|
||||
func Newf(t typ, code code, format string, args ...interface{}) *base {
|
||||
func Newf(t typ, code Code, format string, args ...interface{}) *base {
|
||||
return &base{
|
||||
t: t,
|
||||
c: code,
|
||||
@@ -70,7 +70,7 @@ func Newf(t typ, code code, format string, args ...interface{}) *base {
|
||||
|
||||
// Wrapf returns a new error by formatting the error message with the supplied format specifier
|
||||
// and wrapping another error with base.
|
||||
func Wrapf(cause error, t typ, code code, format string, args ...interface{}) *base {
|
||||
func Wrapf(cause error, t typ, code Code, format string, args ...interface{}) *base {
|
||||
return &base{
|
||||
t: t,
|
||||
c: code,
|
||||
@@ -110,7 +110,7 @@ func (b *base) WithAdditional(a ...string) *base {
|
||||
// and the error itself.
|
||||
//
|
||||
//lint:ignore ST1008 we want to return arguments in the 'TCMEUA' order of the struct
|
||||
func Unwrapb(cause error) (typ, code, string, error, string, []string) {
|
||||
func Unwrapb(cause error) (typ, Code, string, error, string, []string) {
|
||||
base, ok := cause.(*base)
|
||||
if ok {
|
||||
return base.t, base.c, base.m, base.e, base.u, base.a
|
||||
@@ -127,7 +127,7 @@ func Ast(cause error, typ typ) bool {
|
||||
}
|
||||
|
||||
// Ast checks if the provided error matches the specified custom error code.
|
||||
func Asc(cause error, code code) bool {
|
||||
func Asc(cause error, code Code) bool {
|
||||
_, c, _, _, _, _ := Unwrapb(cause)
|
||||
|
||||
return c.s == code.s
|
||||
@@ -137,3 +137,7 @@ func Asc(cause error, code code) bool {
|
||||
func Join(errs ...error) error {
|
||||
return errors.Join(errs...)
|
||||
}
|
||||
|
||||
func As(err error, target any) bool {
|
||||
return errors.As(err, target)
|
||||
}
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
package implorganization
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/http/render"
|
||||
@@ -12,16 +14,19 @@ import (
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
)
|
||||
|
||||
type organizationAPI struct {
|
||||
type handler struct {
|
||||
module organization.Module
|
||||
}
|
||||
|
||||
func NewAPI(module organization.Module) organization.API {
|
||||
return &organizationAPI{module: module}
|
||||
func NewHandler(module organization.Module) organization.Handler {
|
||||
return &handler{module: module}
|
||||
}
|
||||
|
||||
func (api *organizationAPI) Get(rw http.ResponseWriter, r *http.Request) {
|
||||
claims, err := authtypes.ClaimsFromContext(r.Context())
|
||||
func (handler *handler) Get(rw http.ResponseWriter, r *http.Request) {
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
claims, err := authtypes.ClaimsFromContext(ctx)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
@@ -29,11 +34,11 @@ func (api *organizationAPI) Get(rw http.ResponseWriter, r *http.Request) {
|
||||
|
||||
orgID, err := valuer.NewUUID(claims.OrgID)
|
||||
if err != nil {
|
||||
render.Error(rw, errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "invalid org id"))
|
||||
render.Error(rw, errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "orgId is invalid"))
|
||||
return
|
||||
}
|
||||
|
||||
organization, err := api.module.Get(r.Context(), orgID)
|
||||
organization, err := handler.module.Get(ctx, orgID)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
@@ -42,18 +47,11 @@ func (api *organizationAPI) Get(rw http.ResponseWriter, r *http.Request) {
|
||||
render.Success(rw, http.StatusOK, organization)
|
||||
}
|
||||
|
||||
func (api *organizationAPI) GetAll(rw http.ResponseWriter, r *http.Request) {
|
||||
organizations, err := api.module.GetAll(r.Context())
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
func (handler *handler) Update(rw http.ResponseWriter, r *http.Request) {
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
render.Success(rw, http.StatusOK, organizations)
|
||||
}
|
||||
|
||||
func (api *organizationAPI) Update(rw http.ResponseWriter, r *http.Request) {
|
||||
claims, err := authtypes.ClaimsFromContext(r.Context())
|
||||
claims, err := authtypes.ClaimsFromContext(ctx)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
@@ -72,7 +70,7 @@ func (api *organizationAPI) Update(rw http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
req.ID = orgID
|
||||
err = api.module.Update(r.Context(), req)
|
||||
err = handler.module.Update(ctx, req)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
@@ -8,26 +8,26 @@ import (
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
)
|
||||
|
||||
type organizationModule struct {
|
||||
type module struct {
|
||||
store types.OrganizationStore
|
||||
}
|
||||
|
||||
func NewModule(organizationStore types.OrganizationStore) organization.Module {
|
||||
return &organizationModule{store: organizationStore}
|
||||
return &module{store: organizationStore}
|
||||
}
|
||||
|
||||
func (o *organizationModule) Create(ctx context.Context, organization *types.Organization) error {
|
||||
return o.store.Create(ctx, organization)
|
||||
func (module *module) Create(ctx context.Context, organization *types.Organization) error {
|
||||
return module.store.Create(ctx, organization)
|
||||
}
|
||||
|
||||
func (o *organizationModule) Get(ctx context.Context, id valuer.UUID) (*types.Organization, error) {
|
||||
return o.store.Get(ctx, id)
|
||||
func (module *module) Get(ctx context.Context, id valuer.UUID) (*types.Organization, error) {
|
||||
return module.store.Get(ctx, id)
|
||||
}
|
||||
|
||||
func (o *organizationModule) GetAll(ctx context.Context) ([]*types.Organization, error) {
|
||||
return o.store.GetAll(ctx)
|
||||
func (module *module) GetAll(ctx context.Context) ([]*types.Organization, error) {
|
||||
return module.store.GetAll(ctx)
|
||||
}
|
||||
|
||||
func (o *organizationModule) Update(ctx context.Context, updatedOrganization *types.Organization) error {
|
||||
return o.store.Update(ctx, updatedOrganization)
|
||||
func (module *module) Update(ctx context.Context, updatedOrganization *types.Organization) error {
|
||||
return module.store.Update(ctx, updatedOrganization)
|
||||
}
|
||||
|
||||
@@ -2,77 +2,69 @@ package implorganization
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"time"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/sqlstore"
|
||||
"github.com/SigNoz/signoz/pkg/types"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
)
|
||||
|
||||
type store struct {
|
||||
store sqlstore.SQLStore
|
||||
sqlstore sqlstore.SQLStore
|
||||
}
|
||||
|
||||
func NewStore(db sqlstore.SQLStore) types.OrganizationStore {
|
||||
return &store{store: db}
|
||||
func NewStore(sqlstore sqlstore.SQLStore) types.OrganizationStore {
|
||||
return &store{sqlstore: sqlstore}
|
||||
}
|
||||
|
||||
func (s *store) Create(ctx context.Context, organization *types.Organization) error {
|
||||
_, err := s.
|
||||
store.
|
||||
func (store *store) Create(ctx context.Context, organization *types.Organization) error {
|
||||
_, err := store.
|
||||
sqlstore.
|
||||
BunDB().
|
||||
NewInsert().
|
||||
Model(organization).
|
||||
Exec(ctx)
|
||||
if err != nil {
|
||||
return errors.Wrapf(err, errors.TypeInternal, errors.CodeInternal, "failed to create organization")
|
||||
return store.sqlstore.WrapAlreadyExistsErrf(err, types.ErrOrganizationAlreadyExists, "organization with name: %s already exists", organization.Name)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *store) Get(ctx context.Context, id valuer.UUID) (*types.Organization, error) {
|
||||
func (store *store) Get(ctx context.Context, id valuer.UUID) (*types.Organization, error) {
|
||||
organization := new(types.Organization)
|
||||
err := s.
|
||||
store.
|
||||
err := store.
|
||||
sqlstore.
|
||||
BunDB().
|
||||
NewSelect().
|
||||
Model(organization).
|
||||
Where("id = ?", id.StringValue()).
|
||||
Scan(ctx)
|
||||
if err != nil {
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, errors.Wrapf(err, errors.TypeInternal, errors.CodeInternal, "no organization found with id: %s", id.StringValue())
|
||||
}
|
||||
return nil, errors.Wrapf(err, errors.TypeInternal, errors.CodeInternal, "failed to get organization with id: %s", id.StringValue())
|
||||
return nil, store.sqlstore.WrapNotFoundErrf(err, types.ErrOrganizationNotFound, "organization with id: %s does not exist", id.StringValue())
|
||||
}
|
||||
|
||||
return organization, nil
|
||||
}
|
||||
|
||||
func (s *store) GetAll(ctx context.Context) ([]*types.Organization, error) {
|
||||
func (store *store) GetAll(ctx context.Context) ([]*types.Organization, error) {
|
||||
organizations := make([]*types.Organization, 0)
|
||||
err := s.
|
||||
store.
|
||||
err := store.
|
||||
sqlstore.
|
||||
BunDB().
|
||||
NewSelect().
|
||||
Model(&organizations).
|
||||
Scan(ctx)
|
||||
if err != nil {
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, errors.Wrapf(err, errors.TypeInternal, errors.CodeInternal, "no organizations found")
|
||||
}
|
||||
return nil, errors.Wrapf(err, errors.TypeInternal, errors.CodeInternal, "failed to get all organizations")
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return organizations, nil
|
||||
}
|
||||
|
||||
func (s *store) Update(ctx context.Context, organization *types.Organization) error {
|
||||
_, err := s.
|
||||
store.
|
||||
func (store *store) Update(ctx context.Context, organization *types.Organization) error {
|
||||
_, err := store.
|
||||
sqlstore.
|
||||
BunDB().
|
||||
NewUpdate().
|
||||
Model(organization).
|
||||
@@ -81,21 +73,21 @@ func (s *store) Update(ctx context.Context, organization *types.Organization) er
|
||||
Where("id = ?", organization.ID.StringValue()).
|
||||
Exec(ctx)
|
||||
if err != nil {
|
||||
return errors.Wrapf(err, errors.TypeInternal, errors.CodeInternal, "failed to update organization with id: %s", organization.ID.StringValue())
|
||||
return store.sqlstore.WrapAlreadyExistsErrf(err, types.ErrOrganizationAlreadyExists, "organization already exists")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *store) Delete(ctx context.Context, id valuer.UUID) error {
|
||||
_, err := s.
|
||||
store.
|
||||
func (store *store) Delete(ctx context.Context, id valuer.UUID) error {
|
||||
_, err := store.
|
||||
sqlstore.
|
||||
BunDB().
|
||||
NewDelete().
|
||||
Model(new(types.Organization)).
|
||||
Where("id = ?", id.StringValue()).
|
||||
Exec(ctx)
|
||||
if err != nil {
|
||||
return errors.Wrapf(err, errors.TypeInternal, errors.CodeInternal, "failed to delete organization with id: %s", id.StringValue())
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
|
||||
@@ -22,13 +22,10 @@ type Module interface {
|
||||
Update(context.Context, *types.Organization) error
|
||||
}
|
||||
|
||||
type API interface {
|
||||
type Handler interface {
|
||||
// Get gets the organization based on the id in claims
|
||||
Get(http.ResponseWriter, *http.Request)
|
||||
|
||||
// GetAll gets all the organizations
|
||||
GetAll(http.ResponseWriter, *http.Request)
|
||||
|
||||
// Update updates the organization based on the id in claims
|
||||
Update(http.ResponseWriter, *http.Request)
|
||||
}
|
||||
|
||||
@@ -1,147 +0,0 @@
|
||||
package preference
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/http/render"
|
||||
"github.com/SigNoz/signoz/pkg/types/authtypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/preferencetypes"
|
||||
"github.com/gorilla/mux"
|
||||
)
|
||||
|
||||
type API interface {
|
||||
GetOrgPreference(http.ResponseWriter, *http.Request)
|
||||
UpdateOrgPreference(http.ResponseWriter, *http.Request)
|
||||
GetAllOrgPreferences(http.ResponseWriter, *http.Request)
|
||||
GetUserPreference(http.ResponseWriter, *http.Request)
|
||||
UpdateUserPreference(http.ResponseWriter, *http.Request)
|
||||
GetAllUserPreferences(http.ResponseWriter, *http.Request)
|
||||
}
|
||||
|
||||
type preferenceAPI struct {
|
||||
usecase Usecase
|
||||
}
|
||||
|
||||
func NewAPI(usecase Usecase) API {
|
||||
return &preferenceAPI{usecase: usecase}
|
||||
}
|
||||
|
||||
func (p *preferenceAPI) GetOrgPreference(rw http.ResponseWriter, r *http.Request) {
|
||||
preferenceId := mux.Vars(r)["preferenceId"]
|
||||
claims, err := authtypes.ClaimsFromContext(r.Context())
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
preference, err := p.usecase.GetOrgPreference(
|
||||
r.Context(), preferenceId, claims.OrgID,
|
||||
)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
render.Success(rw, http.StatusOK, preference)
|
||||
}
|
||||
|
||||
func (p *preferenceAPI) UpdateOrgPreference(rw http.ResponseWriter, r *http.Request) {
|
||||
preferenceId := mux.Vars(r)["preferenceId"]
|
||||
req := preferencetypes.UpdatablePreference{}
|
||||
claims, err := authtypes.ClaimsFromContext(r.Context())
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
err = json.NewDecoder(r.Body).Decode(&req)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
err = p.usecase.UpdateOrgPreference(r.Context(), preferenceId, req.PreferenceValue, claims.OrgID)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
render.Success(rw, http.StatusNoContent, nil)
|
||||
}
|
||||
|
||||
func (p *preferenceAPI) GetAllOrgPreferences(rw http.ResponseWriter, r *http.Request) {
|
||||
claims, err := authtypes.ClaimsFromContext(r.Context())
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
preferences, err := p.usecase.GetAllOrgPreferences(
|
||||
r.Context(), claims.OrgID,
|
||||
)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
render.Success(rw, http.StatusOK, preferences)
|
||||
}
|
||||
|
||||
func (p *preferenceAPI) GetUserPreference(rw http.ResponseWriter, r *http.Request) {
|
||||
preferenceId := mux.Vars(r)["preferenceId"]
|
||||
claims, err := authtypes.ClaimsFromContext(r.Context())
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
preference, err := p.usecase.GetUserPreference(
|
||||
r.Context(), preferenceId, claims.OrgID, claims.UserID,
|
||||
)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
render.Success(rw, http.StatusOK, preference)
|
||||
}
|
||||
|
||||
func (p *preferenceAPI) UpdateUserPreference(rw http.ResponseWriter, r *http.Request) {
|
||||
preferenceId := mux.Vars(r)["preferenceId"]
|
||||
claims, err := authtypes.ClaimsFromContext(r.Context())
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
req := preferencetypes.UpdatablePreference{}
|
||||
|
||||
err = json.NewDecoder(r.Body).Decode(&req)
|
||||
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
err = p.usecase.UpdateUserPreference(r.Context(), preferenceId, req.PreferenceValue, claims.UserID)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
render.Success(rw, http.StatusNoContent, nil)
|
||||
}
|
||||
|
||||
func (p *preferenceAPI) GetAllUserPreferences(rw http.ResponseWriter, r *http.Request) {
|
||||
claims, err := authtypes.ClaimsFromContext(r.Context())
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
preferences, err := p.usecase.GetAllUserPreferences(
|
||||
r.Context(), claims.OrgID, claims.UserID,
|
||||
)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
render.Success(rw, http.StatusOK, preferences)
|
||||
}
|
||||
176
pkg/modules/preference/implpreference/handler.go
Normal file
176
pkg/modules/preference/implpreference/handler.go
Normal file
@@ -0,0 +1,176 @@
|
||||
package implpreference
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/http/render"
|
||||
"github.com/SigNoz/signoz/pkg/modules/preference"
|
||||
"github.com/SigNoz/signoz/pkg/types/authtypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/preferencetypes"
|
||||
"github.com/gorilla/mux"
|
||||
)
|
||||
|
||||
type handler struct {
|
||||
module preference.Module
|
||||
}
|
||||
|
||||
func NewHandler(module preference.Module) preference.Handler {
|
||||
return &handler{module: module}
|
||||
}
|
||||
|
||||
func (handler *handler) GetOrg(rw http.ResponseWriter, r *http.Request) {
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
claims, err := authtypes.ClaimsFromContext(ctx)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
id, ok := mux.Vars(r)["preferenceId"]
|
||||
if !ok {
|
||||
render.Error(rw, errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "id is required"))
|
||||
return
|
||||
}
|
||||
|
||||
preference, err := handler.module.GetOrg(ctx, id, claims.OrgID)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
render.Success(rw, http.StatusOK, preference)
|
||||
}
|
||||
|
||||
func (handler *handler) UpdateOrg(rw http.ResponseWriter, r *http.Request) {
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
claims, err := authtypes.ClaimsFromContext(ctx)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
id, ok := mux.Vars(r)["preferenceId"]
|
||||
if !ok {
|
||||
render.Error(rw, errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "id is required"))
|
||||
return
|
||||
}
|
||||
|
||||
req := new(preferencetypes.UpdatablePreference)
|
||||
|
||||
err = json.NewDecoder(r.Body).Decode(req)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
err = handler.module.UpdateOrg(ctx, id, req.PreferenceValue, claims.OrgID)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
render.Success(rw, http.StatusNoContent, nil)
|
||||
}
|
||||
|
||||
func (handler *handler) GetAllOrg(rw http.ResponseWriter, r *http.Request) {
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
claims, err := authtypes.ClaimsFromContext(ctx)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
preferences, err := handler.module.GetAllOrg(ctx, claims.OrgID)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
render.Success(rw, http.StatusOK, preferences)
|
||||
}
|
||||
|
||||
func (handler *handler) GetUser(rw http.ResponseWriter, r *http.Request) {
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
claims, err := authtypes.ClaimsFromContext(ctx)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
id, ok := mux.Vars(r)["preferenceId"]
|
||||
if !ok {
|
||||
render.Error(rw, errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "id is required"))
|
||||
return
|
||||
}
|
||||
|
||||
preference, err := handler.module.GetUser(ctx, id, claims.OrgID, claims.UserID)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
render.Success(rw, http.StatusOK, preference)
|
||||
}
|
||||
|
||||
func (handler *handler) UpdateUser(rw http.ResponseWriter, r *http.Request) {
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
claims, err := authtypes.ClaimsFromContext(ctx)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
id, ok := mux.Vars(r)["preferenceId"]
|
||||
if !ok {
|
||||
render.Error(rw, errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "id is required"))
|
||||
return
|
||||
}
|
||||
|
||||
req := new(preferencetypes.UpdatablePreference)
|
||||
err = json.NewDecoder(r.Body).Decode(req)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
err = handler.module.UpdateUser(ctx, id, req.PreferenceValue, claims.UserID)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
render.Success(rw, http.StatusNoContent, nil)
|
||||
}
|
||||
|
||||
func (handler *handler) GetAllUser(rw http.ResponseWriter, r *http.Request) {
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
claims, err := authtypes.ClaimsFromContext(ctx)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
preferences, err := handler.module.GetAllUser(ctx, claims.OrgID, claims.UserID)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
render.Success(rw, http.StatusOK, preferences)
|
||||
}
|
||||
@@ -1,10 +1,9 @@
|
||||
package core
|
||||
package implpreference
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/modules/preference"
|
||||
@@ -12,27 +11,28 @@ import (
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
)
|
||||
|
||||
type usecase struct {
|
||||
store preferencetypes.PreferenceStore
|
||||
// Do not take inspiration from this code, it is a work in progress. See Organization module for a better implementation.
|
||||
type module struct {
|
||||
store preferencetypes.Store
|
||||
defaultMap map[string]preferencetypes.Preference
|
||||
}
|
||||
|
||||
func NewPreference(store preferencetypes.PreferenceStore, defaultMap map[string]preferencetypes.Preference) preference.Usecase {
|
||||
return &usecase{store: store, defaultMap: defaultMap}
|
||||
func NewModule(store preferencetypes.Store, defaultMap map[string]preferencetypes.Preference) preference.Module {
|
||||
return &module{store: store, defaultMap: defaultMap}
|
||||
}
|
||||
|
||||
func (usecase *usecase) GetOrgPreference(ctx context.Context, preferenceID string, orgID string) (*preferencetypes.GettablePreference, error) {
|
||||
preference, seen := usecase.defaultMap[preferenceID]
|
||||
func (module *module) GetOrg(ctx context.Context, preferenceID string, orgID string) (*preferencetypes.GettablePreference, error) {
|
||||
preference, seen := module.defaultMap[preferenceID]
|
||||
if !seen {
|
||||
return nil, errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, fmt.Sprintf("no such preferenceID exists: %s", preferenceID))
|
||||
return nil, errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "cannot find preference with id: %s", preferenceID)
|
||||
}
|
||||
|
||||
isPreferenceEnabled := preference.IsEnabledForScope(preferencetypes.OrgAllowedScope)
|
||||
if !isPreferenceEnabled {
|
||||
return nil, errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, fmt.Sprintf("preference is not enabled at org scope: %s", preferenceID))
|
||||
isEnabled := preference.IsEnabledForScope(preferencetypes.OrgAllowedScope)
|
||||
if !isEnabled {
|
||||
return nil, errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "preference is not enabled at org scope: %s", preferenceID)
|
||||
}
|
||||
|
||||
orgPreference, err := usecase.store.GetOrgPreference(ctx, orgID, preferenceID)
|
||||
org, err := module.store.GetOrg(ctx, orgID, preferenceID)
|
||||
if err != nil {
|
||||
if err == sql.ErrNoRows {
|
||||
return &preferencetypes.GettablePreference{
|
||||
@@ -40,24 +40,24 @@ func (usecase *usecase) GetOrgPreference(ctx context.Context, preferenceID strin
|
||||
PreferenceValue: preference.DefaultValue,
|
||||
}, nil
|
||||
}
|
||||
return nil, errors.Wrapf(err, errors.TypeInternal, errors.CodeInternal, fmt.Sprintf("error in fetching the org preference: %s", preferenceID))
|
||||
return nil, errors.Wrapf(err, errors.TypeInternal, errors.CodeInternal, "error in fetching the org preference: %s", preferenceID)
|
||||
}
|
||||
|
||||
return &preferencetypes.GettablePreference{
|
||||
PreferenceID: preferenceID,
|
||||
PreferenceValue: preference.SanitizeValue(orgPreference.PreferenceValue),
|
||||
PreferenceValue: preference.SanitizeValue(org.PreferenceValue),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (usecase *usecase) UpdateOrgPreference(ctx context.Context, preferenceID string, preferenceValue interface{}, orgID string) error {
|
||||
preference, seen := usecase.defaultMap[preferenceID]
|
||||
func (module *module) UpdateOrg(ctx context.Context, preferenceID string, preferenceValue interface{}, orgID string) error {
|
||||
preference, seen := module.defaultMap[preferenceID]
|
||||
if !seen {
|
||||
return errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, fmt.Sprintf("no such preferenceID exists: %s", preferenceID))
|
||||
return errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "cannot find preference with id: %s", preferenceID)
|
||||
}
|
||||
|
||||
isPreferenceEnabled := preference.IsEnabledForScope(preferencetypes.OrgAllowedScope)
|
||||
if !isPreferenceEnabled {
|
||||
return errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, fmt.Sprintf("preference is not enabled at org scope: %s", preferenceID))
|
||||
isEnabled := preference.IsEnabledForScope(preferencetypes.OrgAllowedScope)
|
||||
if !isEnabled {
|
||||
return errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "preference is not enabled at org scope: %s", preferenceID)
|
||||
}
|
||||
|
||||
err := preference.IsValidValue(preferenceValue)
|
||||
@@ -65,26 +65,26 @@ func (usecase *usecase) UpdateOrgPreference(ctx context.Context, preferenceID st
|
||||
return err
|
||||
}
|
||||
|
||||
storablePreferenceValue, encodeErr := json.Marshal(preferenceValue)
|
||||
storableValue, encodeErr := json.Marshal(preferenceValue)
|
||||
if encodeErr != nil {
|
||||
return errors.Wrapf(encodeErr, errors.TypeInvalidInput, errors.CodeInvalidInput, "error in encoding the preference value")
|
||||
}
|
||||
|
||||
orgPreference, dberr := usecase.store.GetOrgPreference(ctx, orgID, preferenceID)
|
||||
org, dberr := module.store.GetOrg(ctx, orgID, preferenceID)
|
||||
if dberr != nil && dberr != sql.ErrNoRows {
|
||||
return errors.Wrapf(dberr, errors.TypeInternal, errors.CodeInternal, "error in getting the preference value")
|
||||
}
|
||||
|
||||
if dberr != nil {
|
||||
orgPreference.ID = valuer.GenerateUUID()
|
||||
orgPreference.PreferenceID = preferenceID
|
||||
orgPreference.PreferenceValue = string(storablePreferenceValue)
|
||||
orgPreference.OrgID = orgID
|
||||
org.ID = valuer.GenerateUUID()
|
||||
org.PreferenceID = preferenceID
|
||||
org.PreferenceValue = string(storableValue)
|
||||
org.OrgID = orgID
|
||||
} else {
|
||||
orgPreference.PreferenceValue = string(storablePreferenceValue)
|
||||
org.PreferenceValue = string(storableValue)
|
||||
}
|
||||
|
||||
dberr = usecase.store.UpsertOrgPreference(ctx, orgPreference)
|
||||
dberr = module.store.UpsertOrg(ctx, org)
|
||||
if dberr != nil {
|
||||
return errors.Wrapf(dberr, errors.TypeInternal, errors.CodeInternal, "error in setting the preference value")
|
||||
}
|
||||
@@ -92,19 +92,19 @@ func (usecase *usecase) UpdateOrgPreference(ctx context.Context, preferenceID st
|
||||
return nil
|
||||
}
|
||||
|
||||
func (usecase *usecase) GetAllOrgPreferences(ctx context.Context, orgID string) ([]*preferencetypes.PreferenceWithValue, error) {
|
||||
allOrgPreferences := []*preferencetypes.PreferenceWithValue{}
|
||||
orgPreferences, err := usecase.store.GetAllOrgPreferences(ctx, orgID)
|
||||
func (module *module) GetAllOrg(ctx context.Context, orgID string) ([]*preferencetypes.PreferenceWithValue, error) {
|
||||
allOrgs := []*preferencetypes.PreferenceWithValue{}
|
||||
orgs, err := module.store.GetAllOrg(ctx, orgID)
|
||||
if err != nil {
|
||||
return nil, errors.Wrapf(err, errors.TypeInternal, errors.CodeInternal, "error in setting all org preference values")
|
||||
}
|
||||
|
||||
preferenceValueMap := map[string]interface{}{}
|
||||
for _, preferenceValue := range orgPreferences {
|
||||
for _, preferenceValue := range orgs {
|
||||
preferenceValueMap[preferenceValue.PreferenceID] = preferenceValue.PreferenceValue
|
||||
}
|
||||
|
||||
for _, preference := range usecase.defaultMap {
|
||||
for _, preference := range module.defaultMap {
|
||||
isEnabledForOrgScope := preference.IsEnabledForScope(preferencetypes.OrgAllowedScope)
|
||||
if isEnabledForOrgScope {
|
||||
preferenceWithValue := &preferencetypes.PreferenceWithValue{}
|
||||
@@ -126,16 +126,16 @@ func (usecase *usecase) GetAllOrgPreferences(ctx context.Context, orgID string)
|
||||
}
|
||||
|
||||
preferenceWithValue.Value = preference.SanitizeValue(preferenceWithValue.Value)
|
||||
allOrgPreferences = append(allOrgPreferences, preferenceWithValue)
|
||||
allOrgs = append(allOrgs, preferenceWithValue)
|
||||
}
|
||||
}
|
||||
return allOrgPreferences, nil
|
||||
return allOrgs, nil
|
||||
}
|
||||
|
||||
func (usecase *usecase) GetUserPreference(ctx context.Context, preferenceID string, orgID string, userID string) (*preferencetypes.GettablePreference, error) {
|
||||
preference, seen := usecase.defaultMap[preferenceID]
|
||||
func (module *module) GetUser(ctx context.Context, preferenceID string, orgID string, userID string) (*preferencetypes.GettablePreference, error) {
|
||||
preference, seen := module.defaultMap[preferenceID]
|
||||
if !seen {
|
||||
return nil, errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, fmt.Sprintf("no such preferenceID exists: %s", preferenceID))
|
||||
return nil, errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "cannot find preference with id: %s", preferenceID)
|
||||
}
|
||||
|
||||
preferenceValue := preferencetypes.GettablePreference{
|
||||
@@ -143,29 +143,29 @@ func (usecase *usecase) GetUserPreference(ctx context.Context, preferenceID stri
|
||||
PreferenceValue: preference.DefaultValue,
|
||||
}
|
||||
|
||||
isPreferenceEnabledAtUserScope := preference.IsEnabledForScope(preferencetypes.UserAllowedScope)
|
||||
if !isPreferenceEnabledAtUserScope {
|
||||
return nil, errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, fmt.Sprintf("preference is not enabled at user scope: %s", preferenceID))
|
||||
isEnabledAtUserScope := preference.IsEnabledForScope(preferencetypes.UserAllowedScope)
|
||||
if !isEnabledAtUserScope {
|
||||
return nil, errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "preference is not enabled at user scope: %s", preferenceID)
|
||||
}
|
||||
|
||||
isPreferenceEnabledAtOrgScope := preference.IsEnabledForScope(preferencetypes.OrgAllowedScope)
|
||||
if isPreferenceEnabledAtOrgScope {
|
||||
orgPreference, err := usecase.store.GetOrgPreference(ctx, orgID, preferenceID)
|
||||
isEnabledAtOrgScope := preference.IsEnabledForScope(preferencetypes.OrgAllowedScope)
|
||||
if isEnabledAtOrgScope {
|
||||
org, err := module.store.GetOrg(ctx, orgID, preferenceID)
|
||||
if err != nil && err != sql.ErrNoRows {
|
||||
return nil, errors.Wrapf(err, errors.TypeInternal, errors.CodeInternal, fmt.Sprintf("error in fetching the org preference: %s", preferenceID))
|
||||
return nil, errors.Wrapf(err, errors.TypeInternal, errors.CodeInternal, "error in fetching the org preference: %s", preferenceID)
|
||||
}
|
||||
if err == nil {
|
||||
preferenceValue.PreferenceValue = orgPreference.PreferenceValue
|
||||
preferenceValue.PreferenceValue = org.PreferenceValue
|
||||
}
|
||||
}
|
||||
|
||||
userPreference, err := usecase.store.GetUserPreference(ctx, userID, preferenceID)
|
||||
user, err := module.store.GetUser(ctx, userID, preferenceID)
|
||||
if err != nil && err != sql.ErrNoRows {
|
||||
return nil, errors.Wrapf(err, errors.TypeInternal, errors.CodeInternal, fmt.Sprintf("error in fetching the user preference: %s", preferenceID))
|
||||
return nil, errors.Wrapf(err, errors.TypeInternal, errors.CodeInternal, "error in fetching the user preference: %s", preferenceID)
|
||||
}
|
||||
|
||||
if err == nil {
|
||||
preferenceValue.PreferenceValue = userPreference.PreferenceValue
|
||||
preferenceValue.PreferenceValue = user.PreferenceValue
|
||||
}
|
||||
|
||||
return &preferencetypes.GettablePreference{
|
||||
@@ -174,15 +174,15 @@ func (usecase *usecase) GetUserPreference(ctx context.Context, preferenceID stri
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (usecase *usecase) UpdateUserPreference(ctx context.Context, preferenceID string, preferenceValue interface{}, userID string) error {
|
||||
preference, seen := usecase.defaultMap[preferenceID]
|
||||
func (module *module) UpdateUser(ctx context.Context, preferenceID string, preferenceValue interface{}, userID string) error {
|
||||
preference, seen := module.defaultMap[preferenceID]
|
||||
if !seen {
|
||||
return errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, fmt.Sprintf("no such preferenceID exists: %s", preferenceID))
|
||||
return errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "cannot find preference with id: %s", preferenceID)
|
||||
}
|
||||
|
||||
isPreferenceEnabledAtUserScope := preference.IsEnabledForScope(preferencetypes.UserAllowedScope)
|
||||
if !isPreferenceEnabledAtUserScope {
|
||||
return errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, fmt.Sprintf("preference is not enabled at user scope: %s", preferenceID))
|
||||
isEnabledAtUserScope := preference.IsEnabledForScope(preferencetypes.UserAllowedScope)
|
||||
if !isEnabledAtUserScope {
|
||||
return errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "preference is not enabled at user scope: %s", preferenceID)
|
||||
}
|
||||
|
||||
err := preference.IsValidValue(preferenceValue)
|
||||
@@ -190,26 +190,26 @@ func (usecase *usecase) UpdateUserPreference(ctx context.Context, preferenceID s
|
||||
return err
|
||||
}
|
||||
|
||||
storablePreferenceValue, encodeErr := json.Marshal(preferenceValue)
|
||||
storableValue, encodeErr := json.Marshal(preferenceValue)
|
||||
if encodeErr != nil {
|
||||
return errors.Wrapf(encodeErr, errors.TypeInvalidInput, errors.CodeInvalidInput, "error in encoding the preference value")
|
||||
}
|
||||
|
||||
userPreference, dberr := usecase.store.GetUserPreference(ctx, userID, preferenceID)
|
||||
user, dberr := module.store.GetUser(ctx, userID, preferenceID)
|
||||
if dberr != nil && dberr != sql.ErrNoRows {
|
||||
return errors.Wrapf(dberr, errors.TypeInternal, errors.CodeInternal, "error in getting the preference value")
|
||||
}
|
||||
|
||||
if dberr != nil {
|
||||
userPreference.ID = valuer.GenerateUUID()
|
||||
userPreference.PreferenceID = preferenceID
|
||||
userPreference.PreferenceValue = string(storablePreferenceValue)
|
||||
userPreference.UserID = userID
|
||||
user.ID = valuer.GenerateUUID()
|
||||
user.PreferenceID = preferenceID
|
||||
user.PreferenceValue = string(storableValue)
|
||||
user.UserID = userID
|
||||
} else {
|
||||
userPreference.PreferenceValue = string(storablePreferenceValue)
|
||||
user.PreferenceValue = string(storableValue)
|
||||
}
|
||||
|
||||
dberr = usecase.store.UpsertUserPreference(ctx, userPreference)
|
||||
dberr = module.store.UpsertUser(ctx, user)
|
||||
if dberr != nil {
|
||||
return errors.Wrapf(dberr, errors.TypeInternal, errors.CodeInternal, "error in setting the preference value")
|
||||
}
|
||||
@@ -217,30 +217,30 @@ func (usecase *usecase) UpdateUserPreference(ctx context.Context, preferenceID s
|
||||
return nil
|
||||
}
|
||||
|
||||
func (usecase *usecase) GetAllUserPreferences(ctx context.Context, orgID string, userID string) ([]*preferencetypes.PreferenceWithValue, error) {
|
||||
allUserPreferences := []*preferencetypes.PreferenceWithValue{}
|
||||
func (module *module) GetAllUser(ctx context.Context, orgID string, userID string) ([]*preferencetypes.PreferenceWithValue, error) {
|
||||
allUsers := []*preferencetypes.PreferenceWithValue{}
|
||||
|
||||
orgPreferences, err := usecase.store.GetAllOrgPreferences(ctx, orgID)
|
||||
orgs, err := module.store.GetAllOrg(ctx, orgID)
|
||||
if err != nil {
|
||||
return nil, errors.Wrapf(err, errors.TypeInternal, errors.CodeInternal, "error in setting all org preference values")
|
||||
}
|
||||
|
||||
preferenceOrgValueMap := map[string]interface{}{}
|
||||
for _, preferenceValue := range orgPreferences {
|
||||
for _, preferenceValue := range orgs {
|
||||
preferenceOrgValueMap[preferenceValue.PreferenceID] = preferenceValue.PreferenceValue
|
||||
}
|
||||
|
||||
userPreferences, err := usecase.store.GetAllUserPreferences(ctx, userID)
|
||||
users, err := module.store.GetAllUser(ctx, userID)
|
||||
if err != nil {
|
||||
return nil, errors.Wrapf(err, errors.TypeInternal, errors.CodeInternal, "error in setting all user preference values")
|
||||
}
|
||||
|
||||
preferenceUserValueMap := map[string]interface{}{}
|
||||
for _, preferenceValue := range userPreferences {
|
||||
for _, preferenceValue := range users {
|
||||
preferenceUserValueMap[preferenceValue.PreferenceID] = preferenceValue.PreferenceValue
|
||||
}
|
||||
|
||||
for _, preference := range usecase.defaultMap {
|
||||
for _, preference := range module.defaultMap {
|
||||
isEnabledForUserScope := preference.IsEnabledForScope(preferencetypes.UserAllowedScope)
|
||||
|
||||
if isEnabledForUserScope {
|
||||
@@ -271,8 +271,8 @@ func (usecase *usecase) GetAllUserPreferences(ctx context.Context, orgID string,
|
||||
}
|
||||
|
||||
preferenceWithValue.Value = preference.SanitizeValue(preferenceWithValue.Value)
|
||||
allUserPreferences = append(allUserPreferences, preferenceWithValue)
|
||||
allUsers = append(allUsers, preferenceWithValue)
|
||||
}
|
||||
}
|
||||
return allUserPreferences, nil
|
||||
return allUsers, nil
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
package core
|
||||
package implpreference
|
||||
|
||||
import (
|
||||
"context"
|
||||
@@ -11,11 +11,11 @@ type store struct {
|
||||
store sqlstore.SQLStore
|
||||
}
|
||||
|
||||
func NewStore(db sqlstore.SQLStore) preferencetypes.PreferenceStore {
|
||||
func NewStore(db sqlstore.SQLStore) preferencetypes.Store {
|
||||
return &store{store: db}
|
||||
}
|
||||
|
||||
func (store *store) GetOrgPreference(ctx context.Context, orgID string, preferenceID string) (*preferencetypes.StorableOrgPreference, error) {
|
||||
func (store *store) GetOrg(ctx context.Context, orgID string, preferenceID string) (*preferencetypes.StorableOrgPreference, error) {
|
||||
orgPreference := new(preferencetypes.StorableOrgPreference)
|
||||
err := store.
|
||||
store.
|
||||
@@ -33,7 +33,7 @@ func (store *store) GetOrgPreference(ctx context.Context, orgID string, preferen
|
||||
return orgPreference, nil
|
||||
}
|
||||
|
||||
func (store *store) GetAllOrgPreferences(ctx context.Context, orgID string) ([]*preferencetypes.StorableOrgPreference, error) {
|
||||
func (store *store) GetAllOrg(ctx context.Context, orgID string) ([]*preferencetypes.StorableOrgPreference, error) {
|
||||
orgPreferences := make([]*preferencetypes.StorableOrgPreference, 0)
|
||||
err := store.
|
||||
store.
|
||||
@@ -50,7 +50,7 @@ func (store *store) GetAllOrgPreferences(ctx context.Context, orgID string) ([]*
|
||||
return orgPreferences, nil
|
||||
}
|
||||
|
||||
func (store *store) UpsertOrgPreference(ctx context.Context, orgPreference *preferencetypes.StorableOrgPreference) error {
|
||||
func (store *store) UpsertOrg(ctx context.Context, orgPreference *preferencetypes.StorableOrgPreference) error {
|
||||
_, err := store.
|
||||
store.
|
||||
BunDB().
|
||||
@@ -65,7 +65,7 @@ func (store *store) UpsertOrgPreference(ctx context.Context, orgPreference *pref
|
||||
return nil
|
||||
}
|
||||
|
||||
func (store *store) GetUserPreference(ctx context.Context, userID string, preferenceID string) (*preferencetypes.StorableUserPreference, error) {
|
||||
func (store *store) GetUser(ctx context.Context, userID string, preferenceID string) (*preferencetypes.StorableUserPreference, error) {
|
||||
userPreference := new(preferencetypes.StorableUserPreference)
|
||||
err := store.
|
||||
store.
|
||||
@@ -83,7 +83,7 @@ func (store *store) GetUserPreference(ctx context.Context, userID string, prefer
|
||||
return userPreference, nil
|
||||
}
|
||||
|
||||
func (store *store) GetAllUserPreferences(ctx context.Context, userID string) ([]*preferencetypes.StorableUserPreference, error) {
|
||||
func (store *store) GetAllUser(ctx context.Context, userID string) ([]*preferencetypes.StorableUserPreference, error) {
|
||||
userPreferences := make([]*preferencetypes.StorableUserPreference, 0)
|
||||
err := store.
|
||||
store.
|
||||
@@ -100,7 +100,7 @@ func (store *store) GetAllUserPreferences(ctx context.Context, userID string) ([
|
||||
return userPreferences, nil
|
||||
}
|
||||
|
||||
func (store *store) UpsertUserPreference(ctx context.Context, userPreference *preferencetypes.StorableUserPreference) error {
|
||||
func (store *store) UpsertUser(ctx context.Context, userPreference *preferencetypes.StorableUserPreference) error {
|
||||
_, err := store.
|
||||
store.
|
||||
BunDB().
|
||||
48
pkg/modules/preference/preference.go
Normal file
48
pkg/modules/preference/preference.go
Normal file
@@ -0,0 +1,48 @@
|
||||
package preference
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/types/preferencetypes"
|
||||
)
|
||||
|
||||
type Module interface {
|
||||
// Returns the preference for the given organization
|
||||
GetOrg(ctx context.Context, preferenceId string, orgId string) (*preferencetypes.GettablePreference, error)
|
||||
|
||||
// Returns the preference for the given user
|
||||
GetUser(ctx context.Context, preferenceId string, orgId string, userId string) (*preferencetypes.GettablePreference, error)
|
||||
|
||||
// Returns all preferences for the given organization
|
||||
GetAllOrg(ctx context.Context, orgId string) ([]*preferencetypes.PreferenceWithValue, error)
|
||||
|
||||
// Returns all preferences for the given user
|
||||
GetAllUser(ctx context.Context, orgId string, userId string) ([]*preferencetypes.PreferenceWithValue, error)
|
||||
|
||||
// Updates the preference for the given organization
|
||||
UpdateOrg(ctx context.Context, preferenceId string, preferenceValue interface{}, orgId string) error
|
||||
|
||||
// Updates the preference for the given user
|
||||
UpdateUser(ctx context.Context, preferenceId string, preferenceValue interface{}, userId string) error
|
||||
}
|
||||
|
||||
type Handler interface {
|
||||
// Returns the preference for the given organization
|
||||
GetOrg(http.ResponseWriter, *http.Request)
|
||||
|
||||
// Updates the preference for the given organization
|
||||
UpdateOrg(http.ResponseWriter, *http.Request)
|
||||
|
||||
// Returns all preferences for the given organization
|
||||
GetAllOrg(http.ResponseWriter, *http.Request)
|
||||
|
||||
// Returns the preference for the given user
|
||||
GetUser(http.ResponseWriter, *http.Request)
|
||||
|
||||
// Updates the preference for the given user
|
||||
UpdateUser(http.ResponseWriter, *http.Request)
|
||||
|
||||
// Returns all preferences for the given user
|
||||
GetAllUser(http.ResponseWriter, *http.Request)
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
package preference
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/types/preferencetypes"
|
||||
)
|
||||
|
||||
type Usecase interface {
|
||||
GetOrgPreference(ctx context.Context, preferenceId string, orgId string) (*preferencetypes.GettablePreference, error)
|
||||
UpdateOrgPreference(ctx context.Context, preferenceId string, preferenceValue interface{}, orgId string) error
|
||||
GetAllOrgPreferences(ctx context.Context, orgId string) ([]*preferencetypes.PreferenceWithValue, error)
|
||||
GetUserPreference(ctx context.Context, preferenceId string, orgId string, userId string) (*preferencetypes.GettablePreference, error)
|
||||
UpdateUserPreference(ctx context.Context, preferenceId string, preferenceValue interface{}, userId string) error
|
||||
GetAllUserPreferences(ctx context.Context, orgId string, userId string) ([]*preferencetypes.PreferenceWithValue, error)
|
||||
}
|
||||
450
pkg/modules/tracefunnel/impltracefunnel/handler.go
Normal file
450
pkg/modules/tracefunnel/impltracefunnel/handler.go
Normal file
@@ -0,0 +1,450 @@
|
||||
package impltracefunnel
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/http/render"
|
||||
"github.com/SigNoz/signoz/pkg/modules/tracefunnel"
|
||||
"github.com/SigNoz/signoz/pkg/types/authtypes"
|
||||
tf "github.com/SigNoz/signoz/pkg/types/tracefunnel"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
"github.com/gorilla/mux"
|
||||
)
|
||||
|
||||
type handler struct {
|
||||
module tracefunnel.Module
|
||||
}
|
||||
|
||||
func NewHandler(module tracefunnel.Module) tracefunnel.Handler {
|
||||
return &handler{module: module}
|
||||
}
|
||||
|
||||
func (handler *handler) New(rw http.ResponseWriter, r *http.Request) {
|
||||
var req tf.FunnelRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
claims, err := authtypes.ClaimsFromContext(r.Context())
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
userID := claims.UserID
|
||||
orgID := claims.OrgID
|
||||
|
||||
funnels, err := handler.module.List(r.Context(), orgID)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
for _, f := range funnels {
|
||||
if f.Name == req.Name {
|
||||
render.Error(rw, errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "a funnel with name '%s' already exists in this organization", req.Name))
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
funnel, err := handler.module.Create(r.Context(), req.Timestamp, req.Name, userID, orgID)
|
||||
if err != nil {
|
||||
render.Error(rw, errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "failed to create funnel"))
|
||||
return
|
||||
}
|
||||
|
||||
response := tf.FunnelResponse{
|
||||
FunnelID: funnel.ID.String(),
|
||||
FunnelName: funnel.Name,
|
||||
CreatedAt: req.Timestamp,
|
||||
UserEmail: claims.Email,
|
||||
OrgID: orgID,
|
||||
}
|
||||
|
||||
render.Success(rw, http.StatusOK, response)
|
||||
}
|
||||
|
||||
func (handler *handler) Update(rw http.ResponseWriter, r *http.Request) {
|
||||
var req tf.FunnelRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
claims, err := authtypes.ClaimsFromContext(r.Context())
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
userID := claims.UserID
|
||||
orgID := claims.OrgID
|
||||
|
||||
if err := tracefunnel.ValidateTimestamp(req.Timestamp, "timestamp"); err != nil {
|
||||
render.Error(rw, errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "timestamp is invalid: %v", err))
|
||||
return
|
||||
}
|
||||
|
||||
funnel, err := handler.module.Get(r.Context(), req.FunnelID.String())
|
||||
if err != nil {
|
||||
render.Error(rw, errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "funnel not found: %v", err))
|
||||
return
|
||||
}
|
||||
|
||||
// Check if name is being updated and if it already exists
|
||||
if req.Name != "" && req.Name != funnel.Name {
|
||||
funnels, err := handler.module.List(r.Context(), orgID)
|
||||
if err != nil {
|
||||
render.Error(rw, errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "failed to list funnels: %v", err))
|
||||
return
|
||||
}
|
||||
|
||||
for _, f := range funnels {
|
||||
if f.Name == req.Name {
|
||||
render.Error(rw, errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "a funnel with name '%s' already exists in this organization", req.Name))
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Process each step in the request
|
||||
for i := range req.Steps {
|
||||
if req.Steps[i].Order < 1 {
|
||||
req.Steps[i].Order = int64(i + 1) // Default to sequential ordering if not specified
|
||||
}
|
||||
// Generate a new UUID for the step if it doesn't have one
|
||||
if req.Steps[i].Id.IsZero() {
|
||||
newUUID := valuer.GenerateUUID()
|
||||
req.Steps[i].Id = newUUID
|
||||
}
|
||||
}
|
||||
|
||||
if err := tracefunnel.ValidateFunnelSteps(req.Steps); err != nil {
|
||||
render.Error(rw, errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "invalid funnel steps: %v", err))
|
||||
return
|
||||
}
|
||||
|
||||
// Normalize step orders
|
||||
req.Steps = tracefunnel.NormalizeFunnelSteps(req.Steps)
|
||||
|
||||
// Update the funnel with new steps
|
||||
funnel.Steps = req.Steps
|
||||
funnel.UpdatedAt = time.Unix(0, req.Timestamp*1000000) // Convert to nanoseconds
|
||||
funnel.UpdatedBy = userID
|
||||
|
||||
if req.Name != "" {
|
||||
funnel.Name = req.Name
|
||||
}
|
||||
if req.Description != "" {
|
||||
funnel.Description = req.Description
|
||||
}
|
||||
|
||||
// Update funnel in database
|
||||
err = handler.module.Update(r.Context(), funnel, userID)
|
||||
if err != nil {
|
||||
render.Error(rw, errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "failed to update funnel in database: %v", err))
|
||||
return
|
||||
}
|
||||
|
||||
//// Update name and description if provided
|
||||
//if req.Name != "" || req.Description != "" {
|
||||
// name := req.Name
|
||||
//
|
||||
// description := req.Description
|
||||
//
|
||||
// err = handler.module.UpdateMetadata(r.Context(), funnel.ID, name, description, userID)
|
||||
// if err != nil {
|
||||
// render.Error(rw, errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "failed to update funnel metadata: %v", err))
|
||||
// return
|
||||
// }
|
||||
//}
|
||||
|
||||
// Get the updated funnel to return in response
|
||||
updatedFunnel, err := handler.module.Get(r.Context(), funnel.ID.String())
|
||||
if err != nil {
|
||||
render.Error(rw, errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "failed to get updated funnel: %v", err))
|
||||
return
|
||||
}
|
||||
|
||||
response := tf.FunnelResponse{
|
||||
FunnelName: updatedFunnel.Name,
|
||||
FunnelID: updatedFunnel.ID.String(),
|
||||
Steps: updatedFunnel.Steps,
|
||||
CreatedAt: updatedFunnel.CreatedAt.UnixNano() / 1000000,
|
||||
CreatedBy: updatedFunnel.CreatedBy,
|
||||
OrgID: updatedFunnel.OrgID.String(),
|
||||
UpdatedBy: userID,
|
||||
UpdatedAt: updatedFunnel.UpdatedAt.UnixNano() / 1000000,
|
||||
Description: updatedFunnel.Description,
|
||||
}
|
||||
|
||||
render.Success(rw, http.StatusOK, response)
|
||||
}
|
||||
|
||||
func (handler *handler) List(rw http.ResponseWriter, r *http.Request) {
|
||||
claims, err := authtypes.ClaimsFromContext(r.Context())
|
||||
if err != nil {
|
||||
render.Error(rw, errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "unauthenticated"))
|
||||
return
|
||||
}
|
||||
|
||||
orgID := claims.OrgID
|
||||
funnels, err := handler.module.List(r.Context(), orgID)
|
||||
if err != nil {
|
||||
render.Error(rw, errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "failed to list funnels: %v", err))
|
||||
return
|
||||
}
|
||||
|
||||
var response []tf.FunnelResponse
|
||||
for _, f := range funnels {
|
||||
funnelResp := tf.FunnelResponse{
|
||||
FunnelName: f.Name,
|
||||
FunnelID: f.ID.String(),
|
||||
CreatedAt: f.CreatedAt.UnixNano() / 1000000,
|
||||
CreatedBy: f.CreatedBy,
|
||||
OrgID: f.OrgID.String(),
|
||||
UpdatedAt: f.UpdatedAt.UnixNano() / 1000000,
|
||||
UpdatedBy: f.UpdatedBy,
|
||||
Description: f.Description,
|
||||
}
|
||||
|
||||
// Get user email if available
|
||||
if f.CreatedByUser != nil {
|
||||
funnelResp.UserEmail = f.CreatedByUser.Email
|
||||
}
|
||||
|
||||
response = append(response, funnelResp)
|
||||
}
|
||||
|
||||
render.Success(rw, http.StatusOK, response)
|
||||
}
|
||||
|
||||
func (handler *handler) Get(rw http.ResponseWriter, r *http.Request) {
|
||||
vars := mux.Vars(r)
|
||||
funnelID := vars["funnel_id"]
|
||||
|
||||
funnel, err := handler.module.Get(r.Context(), funnelID)
|
||||
if err != nil {
|
||||
render.Error(rw, errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "funnel not found: %v", err))
|
||||
return
|
||||
}
|
||||
|
||||
// Create a response with all funnel details including step IDs
|
||||
response := tf.FunnelResponse{
|
||||
FunnelID: funnel.ID.String(),
|
||||
FunnelName: funnel.Name,
|
||||
Description: funnel.Description,
|
||||
CreatedAt: funnel.CreatedAt.UnixNano() / 1000000,
|
||||
UpdatedAt: funnel.UpdatedAt.UnixNano() / 1000000,
|
||||
CreatedBy: funnel.CreatedBy,
|
||||
UpdatedBy: funnel.UpdatedBy,
|
||||
OrgID: funnel.OrgID.String(),
|
||||
Steps: funnel.Steps,
|
||||
}
|
||||
|
||||
// Add user email if available
|
||||
if funnel.CreatedByUser != nil {
|
||||
response.UserEmail = funnel.CreatedByUser.Email
|
||||
}
|
||||
|
||||
render.Success(rw, http.StatusOK, response)
|
||||
}
|
||||
|
||||
func (handler *handler) Delete(rw http.ResponseWriter, r *http.Request) {
|
||||
vars := mux.Vars(r)
|
||||
funnelID := vars["funnel_id"]
|
||||
|
||||
err := handler.module.Delete(r.Context(), funnelID)
|
||||
if err != nil {
|
||||
render.Error(rw, errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "failed to delete funnel: %v", err))
|
||||
return
|
||||
}
|
||||
|
||||
render.Success(rw, http.StatusOK, nil)
|
||||
}
|
||||
|
||||
func (handler *handler) Save(rw http.ResponseWriter, r *http.Request) {
|
||||
var req tf.FunnelRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
render.Error(rw, errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "invalid request: %v", err))
|
||||
return
|
||||
}
|
||||
|
||||
claims, err := authtypes.ClaimsFromContext(r.Context())
|
||||
if err != nil {
|
||||
render.Error(rw, errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "unauthenticated"))
|
||||
return
|
||||
}
|
||||
orgID := claims.OrgID
|
||||
usrID := claims.UserID
|
||||
|
||||
funnel, err := handler.module.Get(r.Context(), req.FunnelID.String())
|
||||
if err != nil {
|
||||
render.Error(rw, errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "funnel not found: %v", err))
|
||||
return
|
||||
}
|
||||
|
||||
updateTimestamp := req.Timestamp
|
||||
if updateTimestamp == 0 {
|
||||
updateTimestamp = time.Now().UnixMilli()
|
||||
} else if !tracefunnel.ValidateTimestampIsMilliseconds(updateTimestamp) {
|
||||
render.Error(rw, errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "timestamp must be in milliseconds format (13 digits)"))
|
||||
return
|
||||
}
|
||||
funnel.UpdatedAt = time.Unix(0, updateTimestamp*1000000) // Convert to nanoseconds
|
||||
|
||||
if req.UserID != "" {
|
||||
funnel.UpdatedBy = usrID
|
||||
}
|
||||
|
||||
funnel.Description = req.Description
|
||||
|
||||
if err := handler.module.Save(r.Context(), funnel, funnel.UpdatedBy, orgID); err != nil {
|
||||
render.Error(rw, errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "failed to save funnel: %v", err))
|
||||
return
|
||||
}
|
||||
|
||||
// Try to fetch metadata from DB
|
||||
createdAt, updatedAt, extraDataFromDB, err := handler.module.GetFunnelMetadata(r.Context(), funnel.ID.String())
|
||||
if err != nil {
|
||||
render.Error(rw, errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "failed to get funnel metadata: %v", err))
|
||||
return
|
||||
}
|
||||
|
||||
resp := tf.FunnelResponse{
|
||||
FunnelName: funnel.Name,
|
||||
CreatedAt: createdAt,
|
||||
UpdatedAt: updatedAt,
|
||||
CreatedBy: funnel.CreatedBy,
|
||||
UpdatedBy: funnel.UpdatedBy,
|
||||
OrgID: funnel.OrgID.String(),
|
||||
Description: extraDataFromDB,
|
||||
}
|
||||
|
||||
render.Success(rw, http.StatusOK, resp)
|
||||
}
|
||||
|
||||
//func (handler *handler) ValidateTraces(rw http.ResponseWriter, r *http.Request) {
|
||||
// vars := mux.Vars(r)
|
||||
// funnelID := vars["funnel_id"]
|
||||
//
|
||||
// funnel, err := handler.module.Get(r.Context(), funnelID)
|
||||
// if err != nil {
|
||||
// render.Error(rw, errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "funnel not found: %v", err))
|
||||
// return
|
||||
// }
|
||||
//
|
||||
// var timeRange tf.TimeRange
|
||||
// if err := json.NewDecoder(r.Body).Decode(&timeRange); err != nil {
|
||||
// render.Error(rw, errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "error decoding time range: %v", err))
|
||||
// return
|
||||
// }
|
||||
//
|
||||
// response, err := handler.module.ValidateTraces(r.Context(), funnel, timeRange)
|
||||
// if err != nil {
|
||||
// render.Error(rw, errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "error validating traces: %v", err))
|
||||
// return
|
||||
// }
|
||||
//
|
||||
// render.Success(rw, http.StatusOK, response)
|
||||
//}
|
||||
//
|
||||
//func (handler *handler) FunnelAnalytics(rw http.ResponseWriter, r *http.Request) {
|
||||
// vars := mux.Vars(r)
|
||||
// funnelID := vars["funnel_id"]
|
||||
//
|
||||
// funnel, err := handler.module.Get(r.Context(), funnelID)
|
||||
// if err != nil {
|
||||
// render.Error(rw, errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "funnel not found: %v", err))
|
||||
// return
|
||||
// }
|
||||
//
|
||||
// var timeRange tf.TimeRange
|
||||
// if err := json.NewDecoder(r.Body).Decode(&timeRange); err != nil {
|
||||
// render.Error(rw, errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "error decoding time range: %v", err))
|
||||
// return
|
||||
// }
|
||||
//
|
||||
// response, err := handler.module.GetFunnelAnalytics(r.Context(), funnel, timeRange)
|
||||
// if err != nil {
|
||||
// render.Error(rw, errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "error getting funnel analytics: %v", err))
|
||||
// return
|
||||
// }
|
||||
//
|
||||
// render.Success(rw, http.StatusOK, response)
|
||||
//}
|
||||
//
|
||||
//func (handler *handler) StepAnalytics(rw http.ResponseWriter, r *http.Request) {
|
||||
// vars := mux.Vars(r)
|
||||
// funnelID := vars["funnel_id"]
|
||||
//
|
||||
// funnel, err := handler.module.Get(r.Context(), funnelID)
|
||||
// if err != nil {
|
||||
// render.Error(rw, errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "funnel not found: %v", err))
|
||||
// return
|
||||
// }
|
||||
//
|
||||
// var timeRange tf.TimeRange
|
||||
// if err := json.NewDecoder(r.Body).Decode(&timeRange); err != nil {
|
||||
// render.Error(rw, errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "error decoding time range: %v", err))
|
||||
// return
|
||||
// }
|
||||
//
|
||||
// response, err := handler.module.GetStepAnalytics(r.Context(), funnel, timeRange)
|
||||
// if err != nil {
|
||||
// render.Error(rw, errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "error getting step analytics: %v", err))
|
||||
// return
|
||||
// }
|
||||
//
|
||||
// render.Success(rw, http.StatusOK, response)
|
||||
//}
|
||||
//
|
||||
//func (handler *handler) SlowestTraces(rw http.ResponseWriter, r *http.Request) {
|
||||
// handler.handleTracesWithLatency(rw, r, false)
|
||||
//}
|
||||
//
|
||||
//func (handler *handler) ErrorTraces(rw http.ResponseWriter, r *http.Request) {
|
||||
// handler.handleTracesWithLatency(rw, r, true)
|
||||
//}
|
||||
//
|
||||
//// handleTracesWithLatency handles both slow and error traces with common logic
|
||||
//func (handler *handler) handleTracesWithLatency(rw http.ResponseWriter, r *http.Request, isError bool) {
|
||||
// funnel, req, err := handler.validateTracesRequest(r)
|
||||
// if err != nil {
|
||||
// render.Error(rw, errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "%v", err))
|
||||
// return
|
||||
// }
|
||||
//
|
||||
// if err := tracefunnel.ValidateSteps(funnel, req.StepAOrder, req.StepBOrder); err != nil {
|
||||
// render.Error(rw, errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "%v", err))
|
||||
// return
|
||||
// }
|
||||
//
|
||||
// response, err := handler.module.GetSlowestTraces(r.Context(), funnel, req.StepAOrder, req.StepBOrder, req.TimeRange, isError)
|
||||
// if err != nil {
|
||||
// render.Error(rw, errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "error getting traces: %v", err))
|
||||
// return
|
||||
// }
|
||||
//
|
||||
// render.Success(rw, http.StatusOK, response)
|
||||
//}
|
||||
//
|
||||
//// validateTracesRequest validates and extracts the request parameters
|
||||
//func (handler *handler) validateTracesRequest(r *http.Request) (*tf.Funnel, *tf.StepTransitionRequest, error) {
|
||||
// vars := mux.Vars(r)
|
||||
// funnelID := vars["funnel_id"]
|
||||
//
|
||||
// funnel, err := handler.module.Get(r.Context(), funnelID)
|
||||
// if err != nil {
|
||||
// return nil, nil, fmt.Errorf("funnel not found: %v", err)
|
||||
// }
|
||||
//
|
||||
// var req tf.StepTransitionRequest
|
||||
// if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
// return nil, nil, fmt.Errorf("invalid request body: %v", err)
|
||||
// }
|
||||
//
|
||||
// return funnel, &req, nil
|
||||
//}
|
||||
220
pkg/modules/tracefunnel/impltracefunnel/module.go
Normal file
220
pkg/modules/tracefunnel/impltracefunnel/module.go
Normal file
@@ -0,0 +1,220 @@
|
||||
package impltracefunnel
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/modules/tracefunnel"
|
||||
"github.com/SigNoz/signoz/pkg/types"
|
||||
traceFunnels "github.com/SigNoz/signoz/pkg/types/tracefunnel"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
)
|
||||
|
||||
type module struct {
|
||||
store traceFunnels.TraceFunnelStore
|
||||
}
|
||||
|
||||
func NewModule(store traceFunnels.TraceFunnelStore) tracefunnel.Module {
|
||||
return &module{
|
||||
store: store,
|
||||
}
|
||||
}
|
||||
|
||||
func (module *module) Create(ctx context.Context, timestamp int64, name string, userID string, orgID string) (*traceFunnels.Funnel, error) {
|
||||
orgUUID, err := valuer.NewUUID(orgID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid org ID: %v", err)
|
||||
}
|
||||
|
||||
funnel := &traceFunnels.Funnel{
|
||||
BaseMetadata: traceFunnels.BaseMetadata{
|
||||
Name: name,
|
||||
OrgID: orgUUID,
|
||||
},
|
||||
}
|
||||
funnel.CreatedAt = time.Unix(0, timestamp*1000000) // Convert to nanoseconds
|
||||
funnel.CreatedBy = userID
|
||||
|
||||
// Set up the user relationship
|
||||
funnel.CreatedByUser = &types.User{
|
||||
ID: userID,
|
||||
}
|
||||
|
||||
if err := module.store.Create(ctx, funnel); err != nil {
|
||||
return nil, fmt.Errorf("failed to create funnel: %v", err)
|
||||
}
|
||||
|
||||
return funnel, nil
|
||||
}
|
||||
|
||||
// Get gets a funnel by ID
|
||||
func (module *module) Get(ctx context.Context, funnelID string) (*traceFunnels.Funnel, error) {
|
||||
uuid, err := valuer.NewUUID(funnelID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid funnel ID: %v", err)
|
||||
}
|
||||
return module.store.Get(ctx, uuid)
|
||||
}
|
||||
|
||||
// Update updates a funnel
|
||||
func (module *module) Update(ctx context.Context, funnel *traceFunnels.Funnel, userID string) error {
|
||||
funnel.UpdatedBy = userID
|
||||
return module.store.Update(ctx, funnel)
|
||||
}
|
||||
|
||||
// List lists all funnels for an organization
|
||||
func (module *module) List(ctx context.Context, orgID string) ([]*traceFunnels.Funnel, error) {
|
||||
orgUUID, err := valuer.NewUUID(orgID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid org ID: %v", err)
|
||||
}
|
||||
|
||||
funnels, err := module.store.List(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Filter by orgID
|
||||
var orgFunnels []*traceFunnels.Funnel
|
||||
for _, f := range funnels {
|
||||
if f.OrgID == orgUUID {
|
||||
orgFunnels = append(orgFunnels, f)
|
||||
}
|
||||
}
|
||||
|
||||
return orgFunnels, nil
|
||||
}
|
||||
|
||||
// Delete deletes a funnel
|
||||
func (module *module) Delete(ctx context.Context, funnelID string) error {
|
||||
uuid, err := valuer.NewUUID(funnelID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid funnel ID: %v", err)
|
||||
}
|
||||
return module.store.Delete(ctx, uuid)
|
||||
}
|
||||
|
||||
// Save saves a funnel
|
||||
func (module *module) Save(ctx context.Context, funnel *traceFunnels.Funnel, userID string, orgID string) error {
|
||||
orgUUID, err := valuer.NewUUID(orgID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid org ID: %v", err)
|
||||
}
|
||||
|
||||
funnel.UpdatedBy = userID
|
||||
funnel.OrgID = orgUUID
|
||||
return module.store.Update(ctx, funnel)
|
||||
}
|
||||
|
||||
// GetFunnelMetadata gets metadata for a funnel
|
||||
func (module *module) GetFunnelMetadata(ctx context.Context, funnelID string) (int64, int64, string, error) {
|
||||
uuid, err := valuer.NewUUID(funnelID)
|
||||
if err != nil {
|
||||
return 0, 0, "", fmt.Errorf("invalid funnel ID: %v", err)
|
||||
}
|
||||
|
||||
funnel, err := module.store.Get(ctx, uuid)
|
||||
if err != nil {
|
||||
return 0, 0, "", err
|
||||
}
|
||||
|
||||
return funnel.CreatedAt.UnixNano() / 1000000, funnel.UpdatedAt.UnixNano() / 1000000, funnel.Description, nil
|
||||
}
|
||||
|
||||
// ValidateTraces validates traces in a funnel
|
||||
//func (module *module) ValidateTraces(ctx context.Context, funnel *traceFunnels.Funnel, timeRange traceFunnels.TimeRange) ([]*v3.Row, error) {
|
||||
// chq, err := tracefunnel.ValidateTraces(funnel, timeRange)
|
||||
// if err != nil {
|
||||
// RespondError(w, &model.ApiError{Typ: model.ErrorInternal, Err: fmt.Errorf("error building clickhouse query: %v", err)}, nil)
|
||||
// return
|
||||
// }
|
||||
//
|
||||
// results, err := aH.reader. GetListResultV3(r.Context(), chq.Query)
|
||||
// if err != nil {
|
||||
// RespondError(w, &model.ApiError{Typ: model.ErrorInternal, Err: fmt.Errorf("error converting clickhouse results to list: %v", err)}, nil)
|
||||
// return
|
||||
// }
|
||||
//
|
||||
//}
|
||||
|
||||
// GetFunnelAnalytics gets analytics for a funnel
|
||||
//func (module *module) GetFunnelAnalytics(ctx context.Context, funnel *traceFunnels.Funnel, timeRange traceFunnels.TimeRange) (*traceFunnels.FunnelAnalytics, error) {
|
||||
// if err := tracefunnel.ValidateFunnel(funnel); err != nil {
|
||||
// return nil, fmt.Errorf("invalid funnel: %v", err)
|
||||
// }
|
||||
//
|
||||
// if err := tracefunnel.ValidateTimeRange(timeRange); err != nil {
|
||||
// return nil, fmt.Errorf("invalid time range: %v", err)
|
||||
// }
|
||||
//
|
||||
// _, err := tracefunnel.ValidateTracesWithLatency(funnel, timeRange)
|
||||
// if err != nil {
|
||||
// return nil, fmt.Errorf("error building clickhouse query: %v", err)
|
||||
// }
|
||||
//
|
||||
// // TODO: Execute query and return results
|
||||
// // For now, return empty analytics
|
||||
// return &traceFunnels.FunnelAnalytics{
|
||||
// TotalStart: 0,
|
||||
// TotalComplete: 0,
|
||||
// ErrorCount: 0,
|
||||
// AvgDurationMs: 0,
|
||||
// P99LatencyMs: 0,
|
||||
// ConversionRate: 0,
|
||||
// }, nil
|
||||
//}
|
||||
|
||||
// GetStepAnalytics gets analytics for each step
|
||||
//func (module *module) GetStepAnalytics(ctx context.Context, funnel *traceFunnels.Funnel, timeRange traceFunnels.TimeRange) (*traceFunnels.FunnelAnalytics, error) {
|
||||
// if err := tracefunnel.ValidateFunnel(funnel); err != nil {
|
||||
// return nil, fmt.Errorf("invalid funnel: %v", err)
|
||||
// }
|
||||
//
|
||||
// if err := tracefunnel.ValidateTimeRange(timeRange); err != nil {
|
||||
// return nil, fmt.Errorf("invalid time range: %v", err)
|
||||
// }
|
||||
//
|
||||
// _, err := tracefunnel.GetStepAnalytics(funnel, timeRange)
|
||||
// if err != nil {
|
||||
// return nil, fmt.Errorf("error building clickhouse query: %v", err)
|
||||
// }
|
||||
//
|
||||
// // TODO: Execute query and return results
|
||||
// // For now, return empty analytics
|
||||
// return &traceFunnels.FunnelAnalytics{
|
||||
// TotalStart: 0,
|
||||
// TotalComplete: 0,
|
||||
// ErrorCount: 0,
|
||||
// AvgDurationMs: 0,
|
||||
// P99LatencyMs: 0,
|
||||
// ConversionRate: 0,
|
||||
// }, nil
|
||||
//}
|
||||
|
||||
// GetSlowestTraces gets the slowest traces between two steps
|
||||
//func (module *module) GetSlowestTraces(ctx context.Context, funnel *traceFunnels.Funnel, stepAOrder, stepBOrder int64, timeRange traceFunnels.TimeRange, isError bool) (*traceFunnels.ValidTracesResponse, error) {
|
||||
// if err := tracefunnel.ValidateFunnel(funnel); err != nil {
|
||||
// return nil, fmt.Errorf("invalid funnel: %v", err)
|
||||
// }
|
||||
//
|
||||
// if err := tracefunnel.ValidateTimeRange(timeRange); err != nil {
|
||||
// return nil, fmt.Errorf("invalid time range: %v", err)
|
||||
// }
|
||||
//
|
||||
// _, err := tracefunnel.GetSlowestTraces(funnel, stepAOrder, stepBOrder, timeRange, isError)
|
||||
// if err != nil {
|
||||
// return nil, fmt.Errorf("error building clickhouse query: %v", err)
|
||||
// }
|
||||
//
|
||||
// // TODO: Execute query and return results
|
||||
// // For now, return empty response
|
||||
// return &traceFunnels.ValidTracesResponse{
|
||||
// TraceIDs: []string{},
|
||||
// }, nil
|
||||
//}
|
||||
|
||||
//UpdateMetadata updates the metadata of a funnel
|
||||
//func (module *module) UpdateMetadata(ctx context.Context, funnelID valuer.UUID, name, description string, userID string) error {
|
||||
// return module.store.UpdateMetadata(ctx, funnelID, name, description, userID)
|
||||
//}
|
||||
220
pkg/modules/tracefunnel/impltracefunnel/store.go
Normal file
220
pkg/modules/tracefunnel/impltracefunnel/store.go
Normal file
@@ -0,0 +1,220 @@
|
||||
package impltracefunnel
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/sqlstore"
|
||||
traceFunnels "github.com/SigNoz/signoz/pkg/types/tracefunnel"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
)
|
||||
|
||||
type store struct {
|
||||
sqlstore sqlstore.SQLStore
|
||||
}
|
||||
|
||||
func NewStore(sqlstore sqlstore.SQLStore) traceFunnels.TraceFunnelStore {
|
||||
return &store{sqlstore: sqlstore}
|
||||
}
|
||||
|
||||
func (store *store) Create(ctx context.Context, funnel *traceFunnels.Funnel) error {
|
||||
if funnel.ID.IsZero() {
|
||||
funnel.ID = valuer.GenerateUUID()
|
||||
}
|
||||
|
||||
if funnel.CreatedAt.IsZero() {
|
||||
funnel.CreatedAt = time.Now()
|
||||
}
|
||||
if funnel.UpdatedAt.IsZero() {
|
||||
funnel.UpdatedAt = time.Now()
|
||||
}
|
||||
|
||||
_, err := store.
|
||||
sqlstore.
|
||||
BunDB().
|
||||
NewInsert().
|
||||
Model(funnel).
|
||||
Exec(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create funnel: %v", err)
|
||||
}
|
||||
|
||||
if funnel.CreatedByUser != nil {
|
||||
_, err = store.sqlstore.BunDB().NewUpdate().
|
||||
Model(funnel).
|
||||
Set("created_by = ?", funnel.CreatedByUser.ID).
|
||||
Where("id = ?", funnel.ID).
|
||||
Exec(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to update funnel user relationship: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Get retrieves a funnel by ID
|
||||
func (store *store) Get(ctx context.Context, uuid valuer.UUID) (*traceFunnels.Funnel, error) {
|
||||
funnel := &traceFunnels.Funnel{}
|
||||
err := store.
|
||||
sqlstore.
|
||||
BunDB().
|
||||
NewSelect().
|
||||
Model(funnel).
|
||||
Relation("CreatedByUser").
|
||||
Where("?TableAlias.id = ?", uuid).
|
||||
Scan(ctx)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get funnel: %v", err)
|
||||
}
|
||||
return funnel, nil
|
||||
}
|
||||
|
||||
// Update updates an existing funnel
|
||||
func (store *store) Update(ctx context.Context, funnel *traceFunnels.Funnel) error {
|
||||
// Update the updated_at timestamp
|
||||
funnel.UpdatedAt = time.Now()
|
||||
|
||||
_, err := store.
|
||||
sqlstore.
|
||||
BunDB().
|
||||
NewUpdate().
|
||||
Model(funnel).
|
||||
WherePK().
|
||||
Exec(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to update funnel: %v", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// List retrieves all funnels
|
||||
func (store *store) List(ctx context.Context) ([]*traceFunnels.Funnel, error) {
|
||||
var funnels []*traceFunnels.Funnel
|
||||
err := store.
|
||||
sqlstore.
|
||||
BunDB().
|
||||
NewSelect().
|
||||
Model(&funnels).
|
||||
Relation("CreatedByUser").
|
||||
Scan(ctx)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to list funnels: %v", err)
|
||||
}
|
||||
return funnels, nil
|
||||
}
|
||||
|
||||
// Delete removes a funnel by ID
|
||||
func (store *store) Delete(ctx context.Context, uuid valuer.UUID) error {
|
||||
_, err := store.
|
||||
sqlstore.
|
||||
BunDB().
|
||||
NewDelete().
|
||||
Model((*traceFunnels.Funnel)(nil)).
|
||||
Where("id = ?", uuid).Exec(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to delete funnel: %v", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ListByOrg retrieves all funnels for a specific organization
|
||||
//func (store *store) ListByOrg(ctx context.Context, orgID valuer.UUID) ([]*traceFunnels.Funnel, error) {
|
||||
// var funnels []*traceFunnels.Funnel
|
||||
// err := store.
|
||||
// sqlstore.
|
||||
// BunDB().
|
||||
// NewSelect().
|
||||
// Model(&funnels).
|
||||
// Relation("CreatedByUser").
|
||||
// Where("org_id = ?", orgID).
|
||||
// Scan(ctx)
|
||||
// if err != nil {
|
||||
// return nil, fmt.Errorf("failed to list funnels by org: %v", err)
|
||||
// }
|
||||
// return funnels, nil
|
||||
//}
|
||||
|
||||
// GetByIDAndOrg retrieves a funnel by ID and organization ID
|
||||
//func (store *store) GetByIDAndOrg(ctx context.Context, id, orgID valuer.UUID) (*traceFunnels.Funnel, error) {
|
||||
// funnel := &traceFunnels.Funnel{}
|
||||
// err := store.
|
||||
// sqlstore.
|
||||
// BunDB().
|
||||
// NewSelect().
|
||||
// Model(funnel).
|
||||
// Relation("CreatedByUser").
|
||||
// Where("?TableAlias.id = ? AND ?TableAlias.org_id = ?", id, orgID).
|
||||
// Scan(ctx)
|
||||
// if err != nil {
|
||||
// return nil, fmt.Errorf("failed to get funnel by ID and org: %v", err)
|
||||
// }
|
||||
// return funnel, nil
|
||||
//}
|
||||
|
||||
// UpdateSteps updates the steps of a funnel
|
||||
//func (store *store) UpdateSteps(ctx context.Context, funnelID valuer.UUID, steps []traceFunnels.FunnelStep) error {
|
||||
// _, err := store.
|
||||
// sqlstore.
|
||||
// BunDB().
|
||||
// NewUpdate().
|
||||
// Model((*traceFunnels.Funnel)(nil)).
|
||||
// Set("steps = ?", steps).
|
||||
// Where("id = ?", funnelID).
|
||||
// Exec(ctx)
|
||||
// if err != nil {
|
||||
// return fmt.Errorf("failed to update funnel steps: %v", err)
|
||||
// }
|
||||
// return nil
|
||||
//}
|
||||
|
||||
// UpdateMetadata updates the metadata of a funnel
|
||||
//func (store *store) UpdateMetadata(ctx context.Context, funnelID valuer.UUID, name, description string, userID string) error {
|
||||
//
|
||||
// // First get the current funnel to preserve other fields
|
||||
// funnel := &traceFunnels.Funnel{}
|
||||
// err := store.
|
||||
// sqlstore.
|
||||
// BunDB().
|
||||
// NewSelect().
|
||||
// Model(funnel).
|
||||
// Where("id = ?", funnelID).
|
||||
// Scan(ctx)
|
||||
// if err != nil {
|
||||
// return fmt.Errorf("failed to get funnel: %v", err)
|
||||
// }
|
||||
//
|
||||
// // Update the fields
|
||||
// funnel.Name = name
|
||||
// funnel.Description = description
|
||||
// funnel.UpdatedAt = time.Now()
|
||||
// funnel.UpdatedBy = userID
|
||||
//
|
||||
// // Save the updated funnel
|
||||
// _, err = store.
|
||||
// sqlstore.
|
||||
// BunDB().
|
||||
// NewUpdate().
|
||||
// Model(funnel).
|
||||
// WherePK().
|
||||
// Exec(ctx)
|
||||
// if err != nil {
|
||||
// return fmt.Errorf("failed to update funnel metadata: %v", err)
|
||||
// }
|
||||
//
|
||||
// // Verify the update
|
||||
// updatedFunnel := &traceFunnels.Funnel{}
|
||||
// err = store.
|
||||
// sqlstore.
|
||||
// BunDB().
|
||||
// NewSelect().
|
||||
// Model(updatedFunnel).
|
||||
// Where("id = ?", funnelID).
|
||||
// Scan(ctx)
|
||||
// if err != nil {
|
||||
// return fmt.Errorf("failed to verify update: %v", err)
|
||||
// }
|
||||
//
|
||||
// return nil
|
||||
//}
|
||||
442
pkg/modules/tracefunnel/query.go
Normal file
442
pkg/modules/tracefunnel/query.go
Normal file
@@ -0,0 +1,442 @@
|
||||
package tracefunnel
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
v3 "github.com/SigNoz/signoz/pkg/query-service/model/v3"
|
||||
tracefunnel "github.com/SigNoz/signoz/pkg/types/tracefunnel"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// GetSlowestTraces builds a ClickHouse query to get the slowest traces between two steps
|
||||
func GetSlowestTraces(funnel *tracefunnel.Funnel, stepAOrder, stepBOrder int64, timeRange tracefunnel.TimeRange, withErrors bool) (*v3.ClickHouseQuery, error) {
|
||||
// Find steps by order
|
||||
var stepA, stepB *tracefunnel.FunnelStep
|
||||
for i := range funnel.Steps {
|
||||
if funnel.Steps[i].Order == stepAOrder {
|
||||
stepA = &funnel.Steps[i]
|
||||
}
|
||||
if funnel.Steps[i].Order == stepBOrder {
|
||||
stepB = &funnel.Steps[i]
|
||||
}
|
||||
}
|
||||
|
||||
if stepA == nil || stepB == nil {
|
||||
return nil, fmt.Errorf("step not found")
|
||||
}
|
||||
|
||||
// Build having clause based on withErrors flag
|
||||
havingClause := ""
|
||||
if withErrors {
|
||||
havingClause = "HAVING has_error = 1"
|
||||
}
|
||||
|
||||
// Build filter strings for each step
|
||||
stepAFilters := ""
|
||||
if stepA.Filters != nil && len(stepA.Filters.Items) > 0 {
|
||||
// ToDO: need to implement where clause filtering with minimal code duplication
|
||||
stepAFilters = "/* Custom filters for step A would be applied here */"
|
||||
}
|
||||
|
||||
stepBFilters := ""
|
||||
if stepB.Filters != nil && len(stepB.Filters.Items) > 0 {
|
||||
// ToDO: need to implement where clause filtering with minimal code duplication
|
||||
stepBFilters = "/* Custom filters for step B would be applied here */"
|
||||
}
|
||||
|
||||
query := fmt.Sprintf(`
|
||||
WITH
|
||||
toUInt64(%d) AS start_time,
|
||||
toUInt64(%d) AS end_time,
|
||||
toString(intDiv(start_time, 1000000000) - 1800) AS tsBucketStart,
|
||||
toString(intDiv(end_time, 1000000000)) AS tsBucketEnd
|
||||
SELECT
|
||||
trace_id,
|
||||
concat(toString((max_end_time_ns - min_start_time_ns) / 1e6), ' ms') AS duration_ms,
|
||||
COUNT(*) AS span_count
|
||||
FROM (
|
||||
SELECT
|
||||
s1.trace_id,
|
||||
MIN(toUnixTimestamp64Nano(s1.timestamp)) AS min_start_time_ns,
|
||||
MAX(toUnixTimestamp64Nano(s2.timestamp) + s2.duration_nano) AS max_end_time_ns,
|
||||
MAX(s1.has_error OR s2.has_error) AS has_error
|
||||
FROM %s AS s1
|
||||
JOIN %s AS s2
|
||||
ON s1.trace_id = s2.trace_id
|
||||
WHERE s1.resource_string_service$$name = '%s'
|
||||
AND s1.name = '%s'
|
||||
AND s2.resource_string_service$$name = '%s'
|
||||
AND s2.name = '%s'
|
||||
AND s1.timestamp BETWEEN toString(start_time) AND toString(end_time)
|
||||
AND s1.ts_bucket_start BETWEEN tsBucketStart AND tsBucketEnd
|
||||
AND s2.timestamp BETWEEN toString(start_time) AND toString(end_time)
|
||||
AND s2.ts_bucket_start BETWEEN tsBucketStart AND tsBucketEnd
|
||||
%s
|
||||
%s
|
||||
GROUP BY s1.trace_id
|
||||
%s
|
||||
) AS trace_durations
|
||||
JOIN %s AS spans
|
||||
ON spans.trace_id = trace_durations.trace_id
|
||||
WHERE spans.timestamp BETWEEN toString(start_time) AND toString(end_time)
|
||||
AND spans.ts_bucket_start BETWEEN tsBucketStart AND tsBucketEnd
|
||||
GROUP BY trace_id, duration_ms
|
||||
ORDER BY CAST(replaceRegexpAll(duration_ms, ' ms$', '') AS Float64) DESC
|
||||
LIMIT 5`,
|
||||
timeRange.StartTime,
|
||||
timeRange.EndTime,
|
||||
TracesTable,
|
||||
TracesTable,
|
||||
escapeString(stepA.ServiceName),
|
||||
escapeString(stepA.SpanName),
|
||||
escapeString(stepB.ServiceName),
|
||||
escapeString(stepB.SpanName),
|
||||
stepAFilters,
|
||||
stepBFilters,
|
||||
havingClause,
|
||||
TracesTable,
|
||||
)
|
||||
|
||||
return &v3.ClickHouseQuery{
|
||||
Query: query,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// GetStepAnalytics builds a ClickHouse query to get analytics for each step
|
||||
func GetStepAnalytics(funnel *tracefunnel.Funnel, timeRange tracefunnel.TimeRange) (*v3.ClickHouseQuery, error) {
|
||||
if len(funnel.Steps) == 0 {
|
||||
return nil, fmt.Errorf("funnel has no steps")
|
||||
}
|
||||
|
||||
// Build funnel steps array
|
||||
var steps []string
|
||||
for _, step := range funnel.Steps {
|
||||
steps = append(steps, fmt.Sprintf("('%s', '%s')",
|
||||
escapeString(step.ServiceName), escapeString(step.SpanName)))
|
||||
}
|
||||
stepsArray := fmt.Sprintf("array(%s)", strings.Join(steps, ","))
|
||||
|
||||
// Build step CTEs
|
||||
var stepCTEs []string
|
||||
for i, step := range funnel.Steps {
|
||||
filterStr := ""
|
||||
if step.Filters != nil && len(step.Filters.Items) > 0 {
|
||||
// ToDO: need to implement where clause filtering with minimal code duplication
|
||||
filterStr = "/* Custom filters would be applied here */"
|
||||
}
|
||||
|
||||
cte := fmt.Sprintf(`
|
||||
step%d_traces AS (
|
||||
SELECT DISTINCT trace_id
|
||||
FROM %s
|
||||
WHERE resource_string_service$$name = '%s'
|
||||
AND name = '%s'
|
||||
AND timestamp BETWEEN toString(start_time) AND toString(end_time)
|
||||
AND ts_bucket_start BETWEEN tsBucketStart AND tsBucketEnd
|
||||
%s
|
||||
)`,
|
||||
i+1,
|
||||
TracesTable,
|
||||
escapeString(step.ServiceName),
|
||||
escapeString(step.SpanName),
|
||||
filterStr,
|
||||
)
|
||||
stepCTEs = append(stepCTEs, cte)
|
||||
}
|
||||
|
||||
// Build intersecting traces CTE
|
||||
var intersections []string
|
||||
for i := 1; i <= len(funnel.Steps); i++ {
|
||||
intersections = append(intersections, fmt.Sprintf("SELECT trace_id FROM step%d_traces", i))
|
||||
}
|
||||
intersectingTracesCTE := fmt.Sprintf(`
|
||||
intersecting_traces AS (
|
||||
%s
|
||||
)`,
|
||||
strings.Join(intersections, "\nINTERSECT\n"),
|
||||
)
|
||||
|
||||
// Build CASE expressions for each step
|
||||
var caseExpressions []string
|
||||
for i, step := range funnel.Steps {
|
||||
totalSpansExpr := fmt.Sprintf(`
|
||||
COUNT(CASE WHEN resource_string_service$$name = '%s'
|
||||
AND name = '%s'
|
||||
THEN trace_id END) AS total_s%d_spans`,
|
||||
escapeString(step.ServiceName), escapeString(step.SpanName), i+1)
|
||||
|
||||
erroredSpansExpr := fmt.Sprintf(`
|
||||
COUNT(CASE WHEN resource_string_service$$name = '%s'
|
||||
AND name = '%s'
|
||||
AND has_error = true
|
||||
THEN trace_id END) AS total_s%d_errored_spans`,
|
||||
escapeString(step.ServiceName), escapeString(step.SpanName), i+1)
|
||||
|
||||
caseExpressions = append(caseExpressions, totalSpansExpr, erroredSpansExpr)
|
||||
}
|
||||
|
||||
query := fmt.Sprintf(`
|
||||
WITH
|
||||
toUInt64(%d) AS start_time,
|
||||
toUInt64(%d) AS end_time,
|
||||
toString(intDiv(start_time, 1000000000) - 1800) AS tsBucketStart,
|
||||
toString(intDiv(end_time, 1000000000)) AS tsBucketEnd,
|
||||
%s AS funnel_steps,
|
||||
%s,
|
||||
%s
|
||||
SELECT
|
||||
%s
|
||||
FROM %s
|
||||
WHERE trace_id IN (SELECT trace_id FROM intersecting_traces)
|
||||
AND timestamp BETWEEN toString(start_time) AND toString(end_time)
|
||||
AND ts_bucket_start BETWEEN tsBucketStart AND tsBucketEnd`,
|
||||
timeRange.StartTime,
|
||||
timeRange.EndTime,
|
||||
stepsArray,
|
||||
strings.Join(stepCTEs, ",\n"),
|
||||
intersectingTracesCTE,
|
||||
strings.Join(caseExpressions, ",\n "),
|
||||
TracesTable,
|
||||
)
|
||||
|
||||
return &v3.ClickHouseQuery{
|
||||
Query: query,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// ValidateTracesWithLatency builds a ClickHouse query to validate traces with latency information
|
||||
func ValidateTracesWithLatency(funnel *tracefunnel.Funnel, timeRange tracefunnel.TimeRange) (*v3.ClickHouseQuery, error) {
|
||||
filters, err := buildFunnelFiltersWithLatency(funnel)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error building funnel filters with latency: %w", err)
|
||||
}
|
||||
|
||||
query := generateFunnelSQLWithLatency(timeRange.StartTime, timeRange.EndTime, filters)
|
||||
|
||||
return &v3.ClickHouseQuery{
|
||||
Query: query,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func generateFunnelSQLWithLatency(start, end int64, filters []tracefunnel.FunnelStepFilter) string {
|
||||
var expressions []string
|
||||
|
||||
// Convert timestamps to nanoseconds
|
||||
startTime := fmt.Sprintf("toUInt64(%d)", start)
|
||||
endTime := fmt.Sprintf("toUInt64(%d)", end)
|
||||
|
||||
expressions = append(expressions, fmt.Sprintf("%s AS start_time", startTime))
|
||||
expressions = append(expressions, fmt.Sprintf("%s AS end_time", endTime))
|
||||
expressions = append(expressions, "toString(intDiv(start_time, 1000000000) - 1800) AS tsBucketStart")
|
||||
expressions = append(expressions, "toString(intDiv(end_time, 1000000000)) AS tsBucketEnd")
|
||||
expressions = append(expressions, "(end_time - start_time) / 1e9 AS total_time_seconds")
|
||||
|
||||
// Define step configurations dynamically
|
||||
for _, f := range filters {
|
||||
expressions = append(expressions, fmt.Sprintf("('%s', '%s') AS s%d_config",
|
||||
escapeString(f.ServiceName),
|
||||
escapeString(f.SpanName),
|
||||
f.StepNumber))
|
||||
}
|
||||
|
||||
withClause := "WITH \n" + strings.Join(expressions, ",\n") + "\n"
|
||||
|
||||
// Build step raw expressions and cumulative logic
|
||||
var stepRaws []string
|
||||
var cumulativeLogic []string
|
||||
var filterConditions []string
|
||||
|
||||
stepCount := len(filters)
|
||||
|
||||
// Build raw step detection
|
||||
for i := 1; i <= stepCount; i++ {
|
||||
stepRaws = append(stepRaws, fmt.Sprintf(
|
||||
"MAX(CASE WHEN (resource_string_service$$name, name) = s%d_config THEN 1 ELSE 0 END) AS has_s%d_raw", i, i))
|
||||
filterConditions = append(filterConditions, fmt.Sprintf("s%d_config", i))
|
||||
}
|
||||
|
||||
// Build cumulative IF logic
|
||||
for i := 1; i <= stepCount; i++ {
|
||||
if i == 1 {
|
||||
cumulativeLogic = append(cumulativeLogic, fmt.Sprintf(`
|
||||
IF(MAX(CASE WHEN (resource_string_service$$name, name) = s1_config THEN 1 ELSE 0 END) = 1, 1, 0) AS has_s1`))
|
||||
} else {
|
||||
innerIf := "IF(MAX(CASE WHEN (resource_string_service$$name, name) = s1_config THEN 1 ELSE 0 END) = 1, 1, 0)"
|
||||
for j := 2; j < i; j++ {
|
||||
innerIf = fmt.Sprintf(`IF(%s = 1 AND MAX(CASE WHEN (resource_string_service$$name, name) = s%d_config THEN 1 ELSE 0 END) = 1, 1, 0)`, innerIf, j)
|
||||
}
|
||||
cumulativeLogic = append(cumulativeLogic, fmt.Sprintf(`
|
||||
IF(
|
||||
%s = 1 AND MAX(CASE WHEN (resource_string_service$$name, name) = s%d_config THEN 1 ELSE 0 END) = 1,
|
||||
1, 0
|
||||
) AS has_s%d`, innerIf, i, i))
|
||||
}
|
||||
}
|
||||
|
||||
// Final SELECT counts using FILTER clauses
|
||||
var stepCounts []string
|
||||
for i := 1; i <= stepCount; i++ {
|
||||
stepCounts = append(stepCounts, fmt.Sprintf("COUNT(DISTINCT trace_id) FILTER (WHERE has_s%d = 1) AS step%d_count", i, i))
|
||||
}
|
||||
|
||||
// Final query assembly
|
||||
lastStep := fmt.Sprint(stepCount)
|
||||
query := withClause + `
|
||||
SELECT
|
||||
` + strings.Join(stepCounts, ",\n ") + `,
|
||||
|
||||
IF(total_time_seconds = 0 OR COUNT(DISTINCT trace_id) FILTER (WHERE has_s` + lastStep + ` = 1) = 0, 0,
|
||||
COUNT(DISTINCT trace_id) FILTER (WHERE has_s` + lastStep + ` = 1) / total_time_seconds
|
||||
) AS avg_rate,
|
||||
|
||||
COUNT(DISTINCT trace_id) FILTER (WHERE has_s` + lastStep + ` = 1 AND has_error = true) AS errors,
|
||||
|
||||
IF(COUNT(*) = 0, 0, avg(trace_duration)) AS avg_duration,
|
||||
|
||||
IF(COUNT(*) = 0, 0, quantile(0.99)(trace_duration)) AS p99_latency,
|
||||
|
||||
IF(COUNT(DISTINCT trace_id) FILTER (WHERE has_s1 = 1) = 0, 0,
|
||||
100.0 * COUNT(DISTINCT trace_id) FILTER (WHERE has_s` + lastStep + ` = 1) /
|
||||
COUNT(DISTINCT trace_id) FILTER (WHERE has_s1 = 1)
|
||||
) AS conversion_rate
|
||||
|
||||
FROM (
|
||||
SELECT
|
||||
trace_id,
|
||||
MAX(has_error) AS has_error,
|
||||
` + strings.Join(stepRaws, ",\n ") + `,
|
||||
MAX(toUnixTimestamp64Nano(timestamp) + duration_nano) - MIN(toUnixTimestamp64Nano(timestamp)) AS trace_duration,
|
||||
` + strings.Join(cumulativeLogic, ",\n ") + `
|
||||
FROM ` + TracesTable + `
|
||||
WHERE
|
||||
timestamp BETWEEN toString(start_time) AND toString(end_time)
|
||||
AND ts_bucket_start BETWEEN tsBucketStart AND tsBucketEnd
|
||||
AND (resource_string_service$$name, name) IN (` + strings.Join(filterConditions, ", ") + `)
|
||||
GROUP BY trace_id
|
||||
) AS funnel_data;`
|
||||
|
||||
return query
|
||||
}
|
||||
|
||||
func buildFunnelFiltersWithLatency(funnel *tracefunnel.Funnel) ([]tracefunnel.FunnelStepFilter, error) {
|
||||
if funnel == nil {
|
||||
return nil, fmt.Errorf("funnel cannot be nil")
|
||||
}
|
||||
|
||||
if len(funnel.Steps) == 0 {
|
||||
return nil, fmt.Errorf("funnel must have at least one step")
|
||||
}
|
||||
|
||||
filters := make([]tracefunnel.FunnelStepFilter, len(funnel.Steps))
|
||||
|
||||
for i, step := range funnel.Steps {
|
||||
latencyPointer := "start" // Default value
|
||||
if step.LatencyPointer != "" {
|
||||
latencyPointer = step.LatencyPointer
|
||||
}
|
||||
|
||||
filters[i] = tracefunnel.FunnelStepFilter{
|
||||
StepNumber: i + 1,
|
||||
ServiceName: step.ServiceName,
|
||||
SpanName: step.SpanName,
|
||||
LatencyPointer: latencyPointer,
|
||||
CustomFilters: step.Filters,
|
||||
}
|
||||
}
|
||||
|
||||
return filters, nil
|
||||
}
|
||||
|
||||
func buildFunnelFilters(funnel *tracefunnel.Funnel) ([]tracefunnel.FunnelStepFilter, error) {
|
||||
if funnel == nil {
|
||||
return nil, fmt.Errorf("funnel cannot be nil")
|
||||
}
|
||||
|
||||
if len(funnel.Steps) == 0 {
|
||||
return nil, fmt.Errorf("funnel must have at least one step")
|
||||
}
|
||||
|
||||
filters := make([]tracefunnel.FunnelStepFilter, len(funnel.Steps))
|
||||
|
||||
for i, step := range funnel.Steps {
|
||||
filters[i] = tracefunnel.FunnelStepFilter{
|
||||
StepNumber: i + 1,
|
||||
ServiceName: step.ServiceName,
|
||||
SpanName: step.SpanName,
|
||||
CustomFilters: step.Filters,
|
||||
}
|
||||
}
|
||||
|
||||
return filters, nil
|
||||
}
|
||||
|
||||
func escapeString(s string) string {
|
||||
// Replace single quotes with double single quotes to escape them in SQL
|
||||
return strings.ReplaceAll(s, "'", "''")
|
||||
}
|
||||
|
||||
const TracesTable = "signoz_traces.signoz_index_v3"
|
||||
|
||||
func generateFunnelSQL(start, end int64, filters []tracefunnel.FunnelStepFilter) string {
|
||||
var expressions []string
|
||||
|
||||
// Basic time expressions.
|
||||
expressions = append(expressions, fmt.Sprintf("toUInt64(%d) AS start_time", start))
|
||||
expressions = append(expressions, fmt.Sprintf("toUInt64(%d) AS end_time", end))
|
||||
expressions = append(expressions, "toString(intDiv(start_time, 1000000000) - 1800) AS tsBucketStart")
|
||||
expressions = append(expressions, "toString(intDiv(end_time, 1000000000)) AS tsBucketEnd")
|
||||
|
||||
// Add service and span alias definitions from each filter.
|
||||
for _, f := range filters {
|
||||
expressions = append(expressions, fmt.Sprintf("'%s' AS service_%d", escapeString(f.ServiceName), f.StepNumber))
|
||||
expressions = append(expressions, fmt.Sprintf("'%s' AS span_%d", escapeString(f.SpanName), f.StepNumber))
|
||||
}
|
||||
|
||||
// Add the CTE for each step.
|
||||
for _, f := range filters {
|
||||
cte := fmt.Sprintf(`step%d_traces AS (
|
||||
SELECT DISTINCT trace_id
|
||||
FROM %s
|
||||
WHERE serviceName = service_%d
|
||||
AND name = span_%d
|
||||
AND timestamp BETWEEN toString(start_time) AND toString(end_time)
|
||||
AND ts_bucket_start BETWEEN tsBucketStart AND tsBucketEnd
|
||||
)`, f.StepNumber, TracesTable, f.StepNumber, f.StepNumber)
|
||||
expressions = append(expressions, cte)
|
||||
}
|
||||
|
||||
withClause := "WITH \n" + strings.Join(expressions, ",\n") + "\n"
|
||||
|
||||
// Build the intersect clause for each step.
|
||||
var intersectQueries []string
|
||||
for _, f := range filters {
|
||||
intersectQueries = append(intersectQueries, fmt.Sprintf("SELECT trace_id FROM step%d_traces", f.StepNumber))
|
||||
}
|
||||
intersectClause := strings.Join(intersectQueries, "\nINTERSECT\n")
|
||||
|
||||
query := withClause + `
|
||||
SELECT trace_id
|
||||
FROM ` + TracesTable + `
|
||||
WHERE trace_id IN (
|
||||
` + intersectClause + `
|
||||
)
|
||||
AND timestamp BETWEEN toString(start_time) AND toString(end_time)
|
||||
AND ts_bucket_start BETWEEN tsBucketStart AND tsBucketEnd
|
||||
GROUP BY trace_id
|
||||
LIMIT 5
|
||||
`
|
||||
return query
|
||||
}
|
||||
|
||||
// ValidateTraces builds a ClickHouse query to validate traces in a funnel
|
||||
func ValidateTraces(funnel *tracefunnel.Funnel, timeRange tracefunnel.TimeRange) (*v3.ClickHouseQuery, error) {
|
||||
filters, err := buildFunnelFilters(funnel)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error building funnel filters: %w", err)
|
||||
}
|
||||
|
||||
query := generateFunnelSQL(timeRange.StartTime, timeRange.EndTime, filters)
|
||||
|
||||
return &v3.ClickHouseQuery{
|
||||
Query: query,
|
||||
}, nil
|
||||
}
|
||||
65
pkg/modules/tracefunnel/tracefunnel.go
Normal file
65
pkg/modules/tracefunnel/tracefunnel.go
Normal file
@@ -0,0 +1,65 @@
|
||||
package tracefunnel
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
|
||||
traceFunnels "github.com/SigNoz/signoz/pkg/types/tracefunnel"
|
||||
)
|
||||
|
||||
// Module defines the interface for trace funnel operations
|
||||
type Module interface {
|
||||
// operations on funnel
|
||||
Create(ctx context.Context, timestamp int64, name string, userID string, orgID string) (*traceFunnels.Funnel, error)
|
||||
|
||||
Get(ctx context.Context, funnelID string) (*traceFunnels.Funnel, error)
|
||||
|
||||
Update(ctx context.Context, funnel *traceFunnels.Funnel, userID string) error
|
||||
|
||||
List(ctx context.Context, orgID string) ([]*traceFunnels.Funnel, error)
|
||||
|
||||
Delete(ctx context.Context, funnelID string) error
|
||||
|
||||
Save(ctx context.Context, funnel *traceFunnels.Funnel, userID string, orgID string) error
|
||||
|
||||
GetFunnelMetadata(ctx context.Context, funnelID string) (int64, int64, string, error)
|
||||
//
|
||||
//GetFunnelAnalytics(ctx context.Context, funnel *traceFunnels.Funnel, timeRange traceFunnels.TimeRange) (*traceFunnels.FunnelAnalytics, error)
|
||||
//
|
||||
//GetStepAnalytics(ctx context.Context, funnel *traceFunnels.Funnel, timeRange traceFunnels.TimeRange) (*traceFunnels.FunnelAnalytics, error)
|
||||
//
|
||||
//GetSlowestTraces(ctx context.Context, funnel *traceFunnels.Funnel, stepAOrder, stepBOrder int64, timeRange traceFunnels.TimeRange, isError bool) (*traceFunnels.ValidTracesResponse, error)
|
||||
|
||||
// updates funnel metadata
|
||||
//UpdateMetadata(ctx context.Context, funnelID valuer.UUID, name, description string, userID string) error
|
||||
|
||||
// validates funnel
|
||||
//ValidateTraces(ctx context.Context, funnel *traceFunnels.Funnel, timeRange traceFunnels.TimeRange) ([]*v3.Row, error)
|
||||
}
|
||||
|
||||
type Handler interface {
|
||||
// CRUD on funnel
|
||||
New(http.ResponseWriter, *http.Request)
|
||||
|
||||
Update(http.ResponseWriter, *http.Request)
|
||||
|
||||
List(http.ResponseWriter, *http.Request)
|
||||
|
||||
Get(http.ResponseWriter, *http.Request)
|
||||
|
||||
Delete(http.ResponseWriter, *http.Request)
|
||||
|
||||
Save(http.ResponseWriter, *http.Request)
|
||||
|
||||
// validator handlers
|
||||
//ValidateTraces(http.ResponseWriter, *http.Request)
|
||||
//
|
||||
//// Analytics handlers
|
||||
//FunnelAnalytics(http.ResponseWriter, *http.Request)
|
||||
//
|
||||
//StepAnalytics(http.ResponseWriter, *http.Request)
|
||||
//
|
||||
//SlowestTraces(http.ResponseWriter, *http.Request)
|
||||
//
|
||||
//ErrorTraces(http.ResponseWriter, *http.Request)
|
||||
}
|
||||
171
pkg/modules/tracefunnel/utils.go
Normal file
171
pkg/modules/tracefunnel/utils.go
Normal file
@@ -0,0 +1,171 @@
|
||||
package tracefunnel
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
tracefunnel "github.com/SigNoz/signoz/pkg/types/tracefunnel"
|
||||
"sort"
|
||||
)
|
||||
|
||||
// ValidateTimestamp validates a timestamp
|
||||
func ValidateTimestamp(timestamp int64, fieldName string) error {
|
||||
if timestamp == 0 {
|
||||
return fmt.Errorf("%s is required", fieldName)
|
||||
}
|
||||
if timestamp < 0 {
|
||||
return fmt.Errorf("%s must be positive", fieldName)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ValidateTimestampIsMilliseconds validates that a timestamp is in milliseconds
|
||||
func ValidateTimestampIsMilliseconds(timestamp int64) bool {
|
||||
// Check if timestamp is in milliseconds (13 digits)
|
||||
return timestamp >= 1000000000000 && timestamp <= 9999999999999
|
||||
}
|
||||
|
||||
// ValidateFunnelSteps validates funnel steps
|
||||
func ValidateFunnelSteps(steps []tracefunnel.FunnelStep) error {
|
||||
if len(steps) < 2 {
|
||||
return fmt.Errorf("funnel must have at least 2 steps")
|
||||
}
|
||||
|
||||
for i, step := range steps {
|
||||
if step.ServiceName == "" {
|
||||
return fmt.Errorf("step %d: service name is required", i+1)
|
||||
}
|
||||
if step.SpanName == "" {
|
||||
return fmt.Errorf("step %d: span name is required", i+1)
|
||||
}
|
||||
if step.Order < 0 {
|
||||
return fmt.Errorf("step %d: order must be non-negative", i+1)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// NormalizeFunnelSteps normalizes step orders to be sequential
|
||||
func NormalizeFunnelSteps(steps []tracefunnel.FunnelStep) []tracefunnel.FunnelStep {
|
||||
// Sort steps by order
|
||||
sort.Slice(steps, func(i, j int) bool {
|
||||
return steps[i].Order < steps[j].Order
|
||||
})
|
||||
|
||||
// Normalize orders to be sequential
|
||||
for i := range steps {
|
||||
steps[i].Order = int64(i + 1)
|
||||
}
|
||||
|
||||
return steps
|
||||
}
|
||||
|
||||
//// ValidateSteps checks if the requested steps exist in the funnel
|
||||
//func ValidateSteps(funnel *tracefunnel.Funnel, stepAOrder, stepBOrder int64) error {
|
||||
// stepAExists, stepBExists := false, false
|
||||
// for _, step := range funnel.Steps {
|
||||
// if step.Order == stepAOrder {
|
||||
// stepAExists = true
|
||||
// }
|
||||
// if step.Order == stepBOrder {
|
||||
// stepBExists = true
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// if !stepAExists || !stepBExists {
|
||||
// return fmt.Errorf("one or both steps not found. Step A Order: %d, Step B Order: %d", stepAOrder, stepBOrder)
|
||||
// }
|
||||
//
|
||||
// return nil
|
||||
//}
|
||||
|
||||
//// ValidateFunnel validates a funnel's data
|
||||
//func ValidateFunnel(funnel *tracefunnel.Funnel) error {
|
||||
// if funnel == nil {
|
||||
// return fmt.Errorf("funnel cannot be nil")
|
||||
// }
|
||||
//
|
||||
// if len(funnel.Steps) < 2 {
|
||||
// return fmt.Errorf("funnel must have at least 2 steps")
|
||||
// }
|
||||
//
|
||||
// // Validate each step
|
||||
// for i, step := range funnel.Steps {
|
||||
// if err := ValidateStep(step, i+1); err != nil {
|
||||
// return err
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// return nil
|
||||
//}
|
||||
|
||||
// ValidateStep validates a single funnel step
|
||||
//func ValidateStep(step tracefunnel.FunnelStep, stepNum int) error {
|
||||
// if step.ServiceName == "" {
|
||||
// return fmt.Errorf("step %d: service name is required", stepNum)
|
||||
// }
|
||||
//
|
||||
// if step.SpanName == "" {
|
||||
// return fmt.Errorf("step %d: span name is required", stepNum)
|
||||
// }
|
||||
//
|
||||
// if step.Order < 0 {
|
||||
// return fmt.Errorf("step %d: order must be non-negative", stepNum)
|
||||
// }
|
||||
//
|
||||
// return nil
|
||||
//}
|
||||
//
|
||||
//// ValidateTimeRange validates a time range
|
||||
//func ValidateTimeRange(timeRange tracefunnel.TimeRange) error {
|
||||
// if timeRange.StartTime <= 0 {
|
||||
// return fmt.Errorf("start time must be positive")
|
||||
// }
|
||||
//
|
||||
// if timeRange.EndTime <= 0 {
|
||||
// return fmt.Errorf("end time must be positive")
|
||||
// }
|
||||
//
|
||||
// if timeRange.EndTime < timeRange.StartTime {
|
||||
// return fmt.Errorf("end time must be after start time")
|
||||
// }
|
||||
//
|
||||
// // Check if the time range is not too far in the future
|
||||
// now := time.Now().UnixNano() / 1000000 // Convert to milliseconds
|
||||
// if timeRange.EndTime > now {
|
||||
// return fmt.Errorf("end time cannot be in the future")
|
||||
// }
|
||||
//
|
||||
// // Check if the time range is not too old (e.g., more than 30 days)
|
||||
// maxAge := int64(30 * 24 * 60 * 60 * 1000) // 30 days in milliseconds
|
||||
// if now-timeRange.StartTime > maxAge {
|
||||
// return fmt.Errorf("time range cannot be older than 30 days")
|
||||
// }
|
||||
//
|
||||
// return nil
|
||||
//}
|
||||
//
|
||||
//// ValidateStepOrder validates that step orders are sequential
|
||||
//func ValidateStepOrder(steps []tracefunnel.FunnelStep) error {
|
||||
// if len(steps) < 2 {
|
||||
// return nil
|
||||
// }
|
||||
//
|
||||
// // Create a map to track used orders
|
||||
// usedOrders := make(map[int64]bool)
|
||||
//
|
||||
// for i, step := range steps {
|
||||
// if usedOrders[step.Order] {
|
||||
// return fmt.Errorf("duplicate step order %d at step %d", step.Order, i+1)
|
||||
// }
|
||||
// usedOrders[step.Order] = true
|
||||
// }
|
||||
//
|
||||
// // Check if orders are sequential
|
||||
// for i := 0; i < len(steps)-1; i++ {
|
||||
// if steps[i+1].Order != steps[i].Order+1 {
|
||||
// return fmt.Errorf("step orders must be sequential")
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// return nil
|
||||
//}
|
||||
@@ -316,8 +316,7 @@ func (r *ClickHouseReader) GetServicesList(ctx context.Context) (*[]string, erro
|
||||
return &services, nil
|
||||
}
|
||||
|
||||
func (r *ClickHouseReader) GetTopLevelOperations(ctx context.Context, skipConfig *model.SkipConfig, start, end time.Time, services []string) (*map[string][]string, *model.ApiError) {
|
||||
|
||||
func (r *ClickHouseReader) GetTopLevelOperations(ctx context.Context, start, end time.Time, services []string) (*map[string][]string, *model.ApiError) {
|
||||
start = start.In(time.UTC)
|
||||
|
||||
// The `top_level_operations` that have `time` >= start
|
||||
@@ -347,9 +346,6 @@ func (r *ClickHouseReader) GetTopLevelOperations(ctx context.Context, skipConfig
|
||||
if _, ok := operations[serviceName]; !ok {
|
||||
operations[serviceName] = []string{"overflow_operation"}
|
||||
}
|
||||
if skipConfig.ShouldSkip(serviceName, name) {
|
||||
continue
|
||||
}
|
||||
operations[serviceName] = append(operations[serviceName], name)
|
||||
}
|
||||
return &operations, nil
|
||||
@@ -414,13 +410,13 @@ func (r *ClickHouseReader) buildResourceSubQuery(tags []model.TagQueryParam, svc
|
||||
return resourceSubQuery, nil
|
||||
}
|
||||
|
||||
func (r *ClickHouseReader) GetServicesV2(ctx context.Context, queryParams *model.GetServicesParams, skipConfig *model.SkipConfig) (*[]model.ServiceItem, *model.ApiError) {
|
||||
func (r *ClickHouseReader) GetServicesV2(ctx context.Context, queryParams *model.GetServicesParams) (*[]model.ServiceItem, *model.ApiError) {
|
||||
|
||||
if r.indexTable == "" {
|
||||
return nil, &model.ApiError{Typ: model.ErrorExec, Err: ErrNoIndexTable}
|
||||
}
|
||||
|
||||
topLevelOps, apiErr := r.GetTopLevelOperations(ctx, skipConfig, *queryParams.Start, *queryParams.End, nil)
|
||||
topLevelOps, apiErr := r.GetTopLevelOperations(ctx, *queryParams.Start, *queryParams.End, nil)
|
||||
if apiErr != nil {
|
||||
return nil, apiErr
|
||||
}
|
||||
@@ -539,16 +535,16 @@ func (r *ClickHouseReader) GetServicesV2(ctx context.Context, queryParams *model
|
||||
return &serviceItems, nil
|
||||
}
|
||||
|
||||
func (r *ClickHouseReader) GetServices(ctx context.Context, queryParams *model.GetServicesParams, skipConfig *model.SkipConfig) (*[]model.ServiceItem, *model.ApiError) {
|
||||
func (r *ClickHouseReader) GetServices(ctx context.Context, queryParams *model.GetServicesParams) (*[]model.ServiceItem, *model.ApiError) {
|
||||
if r.useTraceNewSchema {
|
||||
return r.GetServicesV2(ctx, queryParams, skipConfig)
|
||||
return r.GetServicesV2(ctx, queryParams)
|
||||
}
|
||||
|
||||
if r.indexTable == "" {
|
||||
return nil, &model.ApiError{Typ: model.ErrorExec, Err: ErrNoIndexTable}
|
||||
}
|
||||
|
||||
topLevelOps, apiErr := r.GetTopLevelOperations(ctx, skipConfig, *queryParams.Start, *queryParams.End, nil)
|
||||
topLevelOps, apiErr := r.GetTopLevelOperations(ctx, *queryParams.Start, *queryParams.End, nil)
|
||||
if apiErr != nil {
|
||||
return nil, apiErr
|
||||
}
|
||||
|
||||
@@ -23,11 +23,11 @@ import (
|
||||
errorsV2 "github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/http/middleware"
|
||||
"github.com/SigNoz/signoz/pkg/http/render"
|
||||
"github.com/SigNoz/signoz/pkg/modules/organization"
|
||||
"github.com/SigNoz/signoz/pkg/modules/preference"
|
||||
tracefunnels "github.com/SigNoz/signoz/pkg/modules/tracefunnel"
|
||||
"github.com/SigNoz/signoz/pkg/query-service/app/integrations"
|
||||
"github.com/SigNoz/signoz/pkg/query-service/app/metricsexplorer"
|
||||
"github.com/SigNoz/signoz/pkg/signoz"
|
||||
traceFunnels "github.com/SigNoz/signoz/pkg/types/tracefunnel"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
"github.com/prometheus/prometheus/promql"
|
||||
|
||||
@@ -91,7 +91,6 @@ func NewRouter() *mux.Router {
|
||||
// APIHandler implements the query service public API
|
||||
type APIHandler struct {
|
||||
reader interfaces.Reader
|
||||
skipConfig *model.SkipConfig
|
||||
appDao dao.ModelDao
|
||||
ruleManager *rules.Manager
|
||||
featureFlags interfaces.FeatureLookup
|
||||
@@ -147,11 +146,6 @@ type APIHandler struct {
|
||||
FieldsAPI *fields.API
|
||||
|
||||
Signoz *signoz.SigNoz
|
||||
|
||||
Preference preference.API
|
||||
|
||||
OrganizationAPI organization.API
|
||||
OrganizationModule organization.Module
|
||||
}
|
||||
|
||||
type APIHandlerOpts struct {
|
||||
@@ -159,8 +153,6 @@ type APIHandlerOpts struct {
|
||||
// business data reader e.g. clickhouse
|
||||
Reader interfaces.Reader
|
||||
|
||||
SkipConfig *model.SkipConfig
|
||||
|
||||
PreferSpanMetrics bool
|
||||
|
||||
// dao layer to perform crud on app objects like dashboard, alerts etc
|
||||
@@ -199,10 +191,6 @@ type APIHandlerOpts struct {
|
||||
FieldsAPI *fields.API
|
||||
|
||||
Signoz *signoz.SigNoz
|
||||
|
||||
Preference preference.API
|
||||
OrganizationAPI organization.API
|
||||
OrganizationModule organization.Module
|
||||
}
|
||||
|
||||
// NewAPIHandler returns an APIHandler
|
||||
@@ -244,7 +232,6 @@ func NewAPIHandler(opts APIHandlerOpts) (*APIHandler, error) {
|
||||
aH := &APIHandler{
|
||||
reader: opts.Reader,
|
||||
appDao: opts.AppDao,
|
||||
skipConfig: opts.SkipConfig,
|
||||
preferSpanMetrics: opts.PreferSpanMetrics,
|
||||
temporalityMap: make(map[string]map[v3.Temporality]bool),
|
||||
ruleManager: opts.RuleManager,
|
||||
@@ -271,10 +258,7 @@ func NewAPIHandler(opts APIHandlerOpts) (*APIHandler, error) {
|
||||
SummaryService: summaryService,
|
||||
AlertmanagerAPI: opts.AlertmanagerAPI,
|
||||
Signoz: opts.Signoz,
|
||||
Preference: opts.Preference,
|
||||
FieldsAPI: opts.FieldsAPI,
|
||||
OrganizationAPI: opts.OrganizationAPI,
|
||||
OrganizationModule: opts.OrganizationModule,
|
||||
}
|
||||
|
||||
logsQueryBuilder := logsv3.PrepareLogsQuery
|
||||
@@ -596,23 +580,13 @@ func (aH *APIHandler) RegisterRoutes(router *mux.Router, am *middleware.AuthZ) {
|
||||
|
||||
router.HandleFunc("/api/v1/disks", am.ViewAccess(aH.getDisks)).Methods(http.MethodGet)
|
||||
|
||||
// === Preference APIs ===
|
||||
router.HandleFunc("/api/v1/user/preferences", am.ViewAccess(aH.Signoz.Handlers.Preference.GetAllUser)).Methods(http.MethodGet)
|
||||
router.HandleFunc("/api/v1/user/preferences/{preferenceId}", am.ViewAccess(aH.Signoz.Handlers.Preference.GetUser)).Methods(http.MethodGet)
|
||||
router.HandleFunc("/api/v1/user/preferences/{preferenceId}", am.ViewAccess(aH.Signoz.Handlers.Preference.UpdateUser)).Methods(http.MethodPut)
|
||||
router.HandleFunc("/api/v1/org/preferences", am.AdminAccess(aH.Signoz.Handlers.Preference.GetAllOrg)).Methods(http.MethodGet)
|
||||
router.HandleFunc("/api/v1/org/preferences/{preferenceId}", am.AdminAccess(aH.Signoz.Handlers.Preference.GetOrg)).Methods(http.MethodGet)
|
||||
router.HandleFunc("/api/v1/org/preferences/{preferenceId}", am.AdminAccess(aH.Signoz.Handlers.Preference.UpdateOrg)).Methods(http.MethodPut)
|
||||
|
||||
// user actions
|
||||
router.HandleFunc("/api/v1/user/preferences", am.ViewAccess(aH.getAllUserPreferences)).Methods(http.MethodGet)
|
||||
|
||||
router.HandleFunc("/api/v1/user/preferences/{preferenceId}", am.ViewAccess(aH.getUserPreference)).Methods(http.MethodGet)
|
||||
|
||||
router.HandleFunc("/api/v1/user/preferences/{preferenceId}", am.ViewAccess(aH.updateUserPreference)).Methods(http.MethodPut)
|
||||
|
||||
// org actions
|
||||
router.HandleFunc("/api/v1/org/preferences", am.AdminAccess(aH.getAllOrgPreferences)).Methods(http.MethodGet)
|
||||
|
||||
router.HandleFunc("/api/v1/org/preferences/{preferenceId}", am.AdminAccess(aH.getOrgPreference)).Methods(http.MethodGet)
|
||||
|
||||
router.HandleFunc("/api/v1/org/preferences/{preferenceId}", am.AdminAccess(aH.updateOrgPreference)).Methods(http.MethodPut)
|
||||
|
||||
// === Authentication APIs ===
|
||||
router.HandleFunc("/api/v1/invite", am.AdminAccess(aH.inviteUser)).Methods(http.MethodPost)
|
||||
router.HandleFunc("/api/v1/invite/bulk", am.AdminAccess(aH.inviteUsers)).Methods(http.MethodPost)
|
||||
router.HandleFunc("/api/v1/invite/{token}", am.OpenAccess(aH.getInvite)).Methods(http.MethodGet)
|
||||
@@ -633,9 +607,8 @@ func (aH *APIHandler) RegisterRoutes(router *mux.Router, am *middleware.AuthZ) {
|
||||
|
||||
router.HandleFunc("/api/v1/orgUsers/{id}", am.AdminAccess(aH.getOrgUsers)).Methods(http.MethodGet)
|
||||
|
||||
router.HandleFunc("/api/v2/orgs", am.AdminAccess(aH.getOrgs)).Methods(http.MethodGet)
|
||||
router.HandleFunc("/api/v2/orgs/me", am.AdminAccess(aH.getOrg)).Methods(http.MethodGet)
|
||||
router.HandleFunc("/api/v2/orgs/me", am.AdminAccess(aH.updateOrg)).Methods(http.MethodPut)
|
||||
router.HandleFunc("/api/v2/orgs/me", am.AdminAccess(aH.Signoz.Handlers.Organization.Get)).Methods(http.MethodGet)
|
||||
router.HandleFunc("/api/v2/orgs/me", am.AdminAccess(aH.Signoz.Handlers.Organization.Update)).Methods(http.MethodPut)
|
||||
|
||||
router.HandleFunc("/api/v1/getResetPasswordToken/{id}", am.AdminAccess(aH.getResetPasswordToken)).Methods(http.MethodGet)
|
||||
router.HandleFunc("/api/v1/resetPassword", am.OpenAccess(aH.resetPassword)).Methods(http.MethodPost)
|
||||
@@ -1709,7 +1682,7 @@ func (aH *APIHandler) getServicesTopLevelOps(w http.ResponseWriter, r *http.Requ
|
||||
end = time.Unix(0, endEpochInt)
|
||||
}
|
||||
|
||||
result, apiErr := aH.reader.GetTopLevelOperations(r.Context(), aH.skipConfig, start, end, services)
|
||||
result, apiErr := aH.reader.GetTopLevelOperations(r.Context(), start, end, services)
|
||||
if apiErr != nil {
|
||||
RespondError(w, apiErr, nil)
|
||||
return
|
||||
@@ -1725,7 +1698,7 @@ func (aH *APIHandler) getServices(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
result, apiErr := aH.reader.GetServices(r.Context(), query, aH.skipConfig)
|
||||
result, apiErr := aH.reader.GetServices(r.Context(), query)
|
||||
if apiErr != nil && aH.HandleError(w, apiErr.Err, http.StatusInternalServerError) {
|
||||
return
|
||||
}
|
||||
@@ -2064,7 +2037,7 @@ func (aH *APIHandler) inviteUsers(w http.ResponseWriter, r *http.Request) {
|
||||
func (aH *APIHandler) getInvite(w http.ResponseWriter, r *http.Request) {
|
||||
token := mux.Vars(r)["token"]
|
||||
|
||||
resp, err := auth.GetInvite(context.Background(), token, aH.OrganizationModule)
|
||||
resp, err := auth.GetInvite(context.Background(), token, aH.Signoz.Modules.Organization)
|
||||
if err != nil {
|
||||
RespondError(w, &model.ApiError{Err: err, Typ: model.ErrorNotFound}, nil)
|
||||
return
|
||||
@@ -2105,7 +2078,7 @@ func (aH *APIHandler) listPendingInvites(w http.ResponseWriter, r *http.Request)
|
||||
if err != nil {
|
||||
render.Error(w, errorsV2.Newf(errorsV2.TypeInvalidInput, errorsV2.CodeInvalidInput, "invalid org_id in the invite"))
|
||||
}
|
||||
org, err := aH.OrganizationModule.Get(ctx, orgID)
|
||||
org, err := aH.Signoz.Modules.Organization.Get(ctx, orgID)
|
||||
if err != nil {
|
||||
render.Error(w, errorsV2.Newf(errorsV2.TypeInternal, errorsV2.CodeInternal, err.Error()))
|
||||
}
|
||||
@@ -2132,7 +2105,7 @@ func (aH *APIHandler) registerUser(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
_, apiErr := auth.Register(context.Background(), req, aH.Signoz.Alertmanager, aH.OrganizationModule)
|
||||
_, apiErr := auth.Register(context.Background(), req, aH.Signoz.Alertmanager, aH.Signoz.Modules.Organization)
|
||||
if apiErr != nil {
|
||||
RespondError(w, apiErr, nil)
|
||||
return
|
||||
@@ -2391,18 +2364,6 @@ func (aH *APIHandler) editRole(w http.ResponseWriter, r *http.Request) {
|
||||
aH.WriteJSON(w, r, map[string]string{"data": "user group updated successfully"})
|
||||
}
|
||||
|
||||
func (aH *APIHandler) getOrgs(w http.ResponseWriter, r *http.Request) {
|
||||
aH.OrganizationAPI.GetAll(w, r)
|
||||
}
|
||||
|
||||
func (aH *APIHandler) getOrg(w http.ResponseWriter, r *http.Request) {
|
||||
aH.OrganizationAPI.Get(w, r)
|
||||
}
|
||||
|
||||
func (aH *APIHandler) updateOrg(w http.ResponseWriter, r *http.Request) {
|
||||
aH.OrganizationAPI.Update(w, r)
|
||||
}
|
||||
|
||||
func (aH *APIHandler) getOrgUsers(w http.ResponseWriter, r *http.Request) {
|
||||
id := mux.Vars(r)["id"]
|
||||
users, apiErr := dao.DB().GetUsersByOrg(context.Background(), id)
|
||||
@@ -3437,44 +3398,6 @@ func (aH *APIHandler) getProducerConsumerEval(
|
||||
aH.Respond(w, resp)
|
||||
}
|
||||
|
||||
// Preferences
|
||||
|
||||
func (aH *APIHandler) getUserPreference(
|
||||
w http.ResponseWriter, r *http.Request,
|
||||
) {
|
||||
aH.Preference.GetUserPreference(w, r)
|
||||
}
|
||||
|
||||
func (aH *APIHandler) updateUserPreference(
|
||||
w http.ResponseWriter, r *http.Request,
|
||||
) {
|
||||
aH.Preference.UpdateUserPreference(w, r)
|
||||
}
|
||||
|
||||
func (aH *APIHandler) getAllUserPreferences(
|
||||
w http.ResponseWriter, r *http.Request,
|
||||
) {
|
||||
aH.Preference.GetAllUserPreferences(w, r)
|
||||
}
|
||||
|
||||
func (aH *APIHandler) getOrgPreference(
|
||||
w http.ResponseWriter, r *http.Request,
|
||||
) {
|
||||
aH.Preference.GetOrgPreference(w, r)
|
||||
}
|
||||
|
||||
func (aH *APIHandler) updateOrgPreference(
|
||||
w http.ResponseWriter, r *http.Request,
|
||||
) {
|
||||
aH.Preference.UpdateOrgPreference(w, r)
|
||||
}
|
||||
|
||||
func (aH *APIHandler) getAllOrgPreferences(
|
||||
w http.ResponseWriter, r *http.Request,
|
||||
) {
|
||||
aH.Preference.GetAllOrgPreferences(w, r)
|
||||
}
|
||||
|
||||
// RegisterIntegrationRoutes Registers all Integrations
|
||||
func (aH *APIHandler) RegisterIntegrationRoutes(router *mux.Router, am *middleware.AuthZ) {
|
||||
subRouter := router.PathPrefix("/api/v1/integrations").Subrouter()
|
||||
@@ -4700,7 +4623,6 @@ func (aH *APIHandler) updateSavedView(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
func (aH *APIHandler) deleteSavedView(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
viewID := mux.Vars(r)["viewId"]
|
||||
viewUUID, err := valuer.NewUUID(viewID)
|
||||
if err != nil {
|
||||
@@ -5615,3 +5537,207 @@ func (aH *APIHandler) getDomainInfo(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
aH.Respond(w, resp)
|
||||
}
|
||||
|
||||
// RegisterTraceFunnelsRoutes adds trace funnels routes
|
||||
func (aH *APIHandler) RegisterTraceFunnelsRoutes(router *mux.Router, am *middleware.AuthZ) {
|
||||
// Main trace funnels router
|
||||
traceFunnelsRouter := router.PathPrefix("/api/v1/trace-funnels").Subrouter()
|
||||
|
||||
// API endpoints
|
||||
traceFunnelsRouter.HandleFunc("/new",
|
||||
am.ViewAccess(aH.Signoz.Handlers.TraceFunnel.New)).
|
||||
Methods(http.MethodPost)
|
||||
traceFunnelsRouter.HandleFunc("/list",
|
||||
am.ViewAccess(aH.Signoz.Handlers.TraceFunnel.List)).
|
||||
Methods(http.MethodGet)
|
||||
traceFunnelsRouter.HandleFunc("/steps/update",
|
||||
am.ViewAccess(aH.Signoz.Handlers.TraceFunnel.Update)).
|
||||
Methods(http.MethodPut)
|
||||
|
||||
traceFunnelsRouter.HandleFunc("/{funnel_id}",
|
||||
am.ViewAccess(aH.Signoz.Handlers.TraceFunnel.Get)).
|
||||
Methods(http.MethodGet)
|
||||
traceFunnelsRouter.HandleFunc("/{funnel_id}",
|
||||
am.ViewAccess(aH.Signoz.Handlers.TraceFunnel.Delete)).
|
||||
Methods(http.MethodDelete)
|
||||
traceFunnelsRouter.HandleFunc("/save",
|
||||
am.ViewAccess(aH.Signoz.Handlers.TraceFunnel.Save)).
|
||||
Methods(http.MethodPost)
|
||||
|
||||
// Analytics endpoints
|
||||
traceFunnelsRouter.HandleFunc("/{funnel_id}/analytics/validate", aH.handleValidateTraces).Methods("POST")
|
||||
traceFunnelsRouter.HandleFunc("/{funnel_id}/analytics/overview", aH.handleFunnelAnalytics).Methods("POST")
|
||||
traceFunnelsRouter.HandleFunc("/{funnel_id}/analytics/steps", aH.handleStepAnalytics).Methods("POST")
|
||||
traceFunnelsRouter.HandleFunc("/{funnel_id}/analytics/slow-traces", aH.handleFunnelSlowTraces).Methods("POST")
|
||||
traceFunnelsRouter.HandleFunc("/{funnel_id}/analytics/error-traces", aH.handleFunnelErrorTraces).Methods("POST")
|
||||
|
||||
}
|
||||
|
||||
func (aH *APIHandler) handleValidateTraces(w http.ResponseWriter, r *http.Request) {
|
||||
vars := mux.Vars(r)
|
||||
funnelID := vars["funnel_id"]
|
||||
|
||||
funnel, err := aH.Signoz.Modules.TraceFunnel.Get(r.Context(), funnelID)
|
||||
if err != nil {
|
||||
RespondError(w, &model.ApiError{Typ: model.ErrorNotFound, Err: fmt.Errorf("funnel not found: %v", err)}, nil)
|
||||
return
|
||||
}
|
||||
|
||||
var timeRange traceFunnels.TimeRange
|
||||
if err := json.NewDecoder(r.Body).Decode(&timeRange); err != nil {
|
||||
RespondError(w, &model.ApiError{Typ: model.ErrorBadData, Err: fmt.Errorf("error decoding time range: %v", err)}, nil)
|
||||
return
|
||||
}
|
||||
|
||||
if len(funnel.Steps) < 2 {
|
||||
RespondError(w, &model.ApiError{Typ: model.ErrorBadData, Err: fmt.Errorf("funnel must have at least 2 steps")}, nil)
|
||||
return
|
||||
}
|
||||
|
||||
chq, err := tracefunnels.ValidateTraces(funnel, timeRange)
|
||||
if err != nil {
|
||||
RespondError(w, &model.ApiError{Typ: model.ErrorInternal, Err: fmt.Errorf("error building clickhouse query: %v", err)}, nil)
|
||||
return
|
||||
}
|
||||
|
||||
results, err := aH.reader.GetListResultV3(r.Context(), chq.Query)
|
||||
if err != nil {
|
||||
RespondError(w, &model.ApiError{Typ: model.ErrorInternal, Err: fmt.Errorf("error converting clickhouse results to list: %v", err)}, nil)
|
||||
return
|
||||
}
|
||||
aH.Respond(w, results)
|
||||
}
|
||||
|
||||
func (aH *APIHandler) handleFunnelAnalytics(w http.ResponseWriter, r *http.Request) {
|
||||
vars := mux.Vars(r)
|
||||
funnelID := vars["funnel_id"]
|
||||
|
||||
funnel, err := aH.Signoz.Modules.TraceFunnel.Get(r.Context(), funnelID)
|
||||
if err != nil {
|
||||
RespondError(w, &model.ApiError{Typ: model.ErrorNotFound, Err: fmt.Errorf("funnel not found: %v", err)}, nil)
|
||||
return
|
||||
}
|
||||
|
||||
var timeRange traceFunnels.TimeRange
|
||||
if err := json.NewDecoder(r.Body).Decode(&timeRange); err != nil {
|
||||
RespondError(w, &model.ApiError{Typ: model.ErrorBadData, Err: fmt.Errorf("error decoding time range: %v", err)}, nil)
|
||||
return
|
||||
}
|
||||
|
||||
chq, err := tracefunnels.ValidateTracesWithLatency(funnel, timeRange)
|
||||
if err != nil {
|
||||
RespondError(w, &model.ApiError{Typ: model.ErrorInternal, Err: fmt.Errorf("error building clickhouse query: %v", err)}, nil)
|
||||
return
|
||||
}
|
||||
|
||||
results, err := aH.reader.GetListResultV3(r.Context(), chq.Query)
|
||||
if err != nil {
|
||||
RespondError(w, &model.ApiError{Typ: model.ErrorInternal, Err: fmt.Errorf("error converting clickhouse results to list: %v", err)}, nil)
|
||||
return
|
||||
}
|
||||
aH.Respond(w, results)
|
||||
}
|
||||
|
||||
func (aH *APIHandler) handleStepAnalytics(w http.ResponseWriter, r *http.Request) {
|
||||
vars := mux.Vars(r)
|
||||
funnelID := vars["funnel_id"]
|
||||
|
||||
funnel, err := aH.Signoz.Modules.TraceFunnel.Get(r.Context(), funnelID)
|
||||
if err != nil {
|
||||
RespondError(w, &model.ApiError{Typ: model.ErrorNotFound, Err: fmt.Errorf("funnel not found: %v", err)}, nil)
|
||||
return
|
||||
}
|
||||
|
||||
var timeRange traceFunnels.TimeRange
|
||||
if err := json.NewDecoder(r.Body).Decode(&timeRange); err != nil {
|
||||
RespondError(w, &model.ApiError{Typ: model.ErrorBadData, Err: fmt.Errorf("error decoding time range: %v", err)}, nil)
|
||||
return
|
||||
}
|
||||
|
||||
chq, err := tracefunnels.GetStepAnalytics(funnel, timeRange)
|
||||
if err != nil {
|
||||
RespondError(w, &model.ApiError{Typ: model.ErrorInternal, Err: fmt.Errorf("error building clickhouse query: %v", err)}, nil)
|
||||
return
|
||||
}
|
||||
|
||||
results, err := aH.reader.GetListResultV3(r.Context(), chq.Query)
|
||||
if err != nil {
|
||||
RespondError(w, &model.ApiError{Typ: model.ErrorInternal, Err: fmt.Errorf("error converting clickhouse results to list: %v", err)}, nil)
|
||||
return
|
||||
}
|
||||
aH.Respond(w, results)
|
||||
}
|
||||
|
||||
// handleFunnelSlowTraces handles requests for slow traces in a funnel
|
||||
func (aH *APIHandler) handleFunnelSlowTraces(w http.ResponseWriter, r *http.Request) {
|
||||
aH.handleTracesWithLatency(w, r, false)
|
||||
}
|
||||
|
||||
// handleFunnelErrorTraces handles requests for error traces in a funnel
|
||||
func (aH *APIHandler) handleFunnelErrorTraces(w http.ResponseWriter, r *http.Request) {
|
||||
aH.handleTracesWithLatency(w, r, true)
|
||||
}
|
||||
|
||||
// handleTracesWithLatency handles both slow and error traces with common logic
|
||||
func (aH *APIHandler) handleTracesWithLatency(w http.ResponseWriter, r *http.Request, isError bool) {
|
||||
funnel, req, err := aH.validateTracesRequest(r)
|
||||
if err != nil {
|
||||
RespondError(w, &model.ApiError{Typ: model.ErrorBadData, Err: err}, nil)
|
||||
return
|
||||
}
|
||||
|
||||
if err := aH.validateSteps(funnel, req.StepAOrder, req.StepBOrder); err != nil {
|
||||
RespondError(w, &model.ApiError{Typ: model.ErrorBadData, Err: err}, nil)
|
||||
return
|
||||
}
|
||||
|
||||
chq, err := tracefunnels.GetSlowestTraces(funnel, req.StepAOrder, req.StepBOrder, req.TimeRange, isError)
|
||||
if err != nil {
|
||||
RespondError(w, &model.ApiError{Typ: model.ErrorInternal, Err: fmt.Errorf("error building clickhouse query: %v", err)}, nil)
|
||||
return
|
||||
}
|
||||
|
||||
results, err := aH.reader.GetListResultV3(r.Context(), chq.Query)
|
||||
if err != nil {
|
||||
RespondError(w, &model.ApiError{Typ: model.ErrorInternal, Err: fmt.Errorf("error converting clickhouse results to list: %v", err)}, nil)
|
||||
return
|
||||
}
|
||||
aH.Respond(w, results)
|
||||
}
|
||||
|
||||
// validateTracesRequest validates and extracts the request parameters
|
||||
func (aH *APIHandler) validateTracesRequest(r *http.Request) (*traceFunnels.Funnel, *traceFunnels.StepTransitionRequest, error) {
|
||||
vars := mux.Vars(r)
|
||||
funnelID := vars["funnel_id"]
|
||||
|
||||
funnel, err := aH.Signoz.Modules.TraceFunnel.Get(r.Context(), funnelID)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("funnel not found: %v", err)
|
||||
}
|
||||
|
||||
var req traceFunnels.StepTransitionRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
return nil, nil, fmt.Errorf("invalid request body: %v", err)
|
||||
}
|
||||
|
||||
return funnel, &req, nil
|
||||
}
|
||||
|
||||
// validateSteps checks if the requested steps exist in the funnel
|
||||
func (aH *APIHandler) validateSteps(funnel *traceFunnels.Funnel, stepAOrder, stepBOrder int64) error {
|
||||
stepAExists, stepBExists := false, false
|
||||
for _, step := range funnel.Steps {
|
||||
if step.Order == stepAOrder {
|
||||
stepAExists = true
|
||||
}
|
||||
if step.Order == stepBOrder {
|
||||
stepBExists = true
|
||||
}
|
||||
}
|
||||
|
||||
if !stepAExists || !stepBExists {
|
||||
return fmt.Errorf("one or both steps not found. Step A Order: %d, Step B Order: %d", stepAOrder, stepBOrder)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -14,9 +14,6 @@ import (
|
||||
"github.com/SigNoz/signoz/pkg/alertmanager"
|
||||
"github.com/SigNoz/signoz/pkg/apis/fields"
|
||||
"github.com/SigNoz/signoz/pkg/http/middleware"
|
||||
"github.com/SigNoz/signoz/pkg/modules/organization/implorganization"
|
||||
"github.com/SigNoz/signoz/pkg/modules/preference"
|
||||
preferencecore "github.com/SigNoz/signoz/pkg/modules/preference/core"
|
||||
"github.com/SigNoz/signoz/pkg/prometheus"
|
||||
"github.com/SigNoz/signoz/pkg/query-service/agentConf"
|
||||
"github.com/SigNoz/signoz/pkg/query-service/app/clickhouseReader"
|
||||
@@ -30,7 +27,6 @@ import (
|
||||
"github.com/SigNoz/signoz/pkg/sqlstore"
|
||||
"github.com/SigNoz/signoz/pkg/telemetrystore"
|
||||
"github.com/SigNoz/signoz/pkg/types/authtypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/preferencetypes"
|
||||
"github.com/SigNoz/signoz/pkg/web"
|
||||
"github.com/rs/cors"
|
||||
"github.com/soheilhy/cmux"
|
||||
@@ -42,7 +38,6 @@ import (
|
||||
"github.com/SigNoz/signoz/pkg/query-service/featureManager"
|
||||
"github.com/SigNoz/signoz/pkg/query-service/healthcheck"
|
||||
"github.com/SigNoz/signoz/pkg/query-service/interfaces"
|
||||
"github.com/SigNoz/signoz/pkg/query-service/model"
|
||||
"github.com/SigNoz/signoz/pkg/query-service/rules"
|
||||
"github.com/SigNoz/signoz/pkg/query-service/telemetry"
|
||||
"github.com/SigNoz/signoz/pkg/query-service/utils"
|
||||
@@ -50,14 +45,9 @@ import (
|
||||
)
|
||||
|
||||
type ServerOptions struct {
|
||||
Config signoz.Config
|
||||
PromConfigPath string
|
||||
SkipTopLvlOpsPath string
|
||||
HTTPHostPort string
|
||||
PrivateHostPort string
|
||||
// alert specific params
|
||||
DisableRules bool
|
||||
RuleRepoURL string
|
||||
Config signoz.Config
|
||||
HTTPHostPort string
|
||||
PrivateHostPort string
|
||||
PreferSpanMetrics bool
|
||||
CacheConfigPath string
|
||||
FluxInterval string
|
||||
@@ -126,15 +116,6 @@ func NewServer(serverOptions *ServerOptions) (*Server, error) {
|
||||
serverOptions.SigNoz.Cache,
|
||||
)
|
||||
|
||||
skipConfig := &model.SkipConfig{}
|
||||
if serverOptions.SkipTopLvlOpsPath != "" {
|
||||
// read skip config
|
||||
skipConfig, err = model.ReadSkipConfig(serverOptions.SkipTopLvlOpsPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
var c cache.Cache
|
||||
if serverOptions.CacheConfigPath != "" {
|
||||
cacheOpts, err := cache.LoadFromYAMLCacheConfigFile(serverOptions.CacheConfigPath)
|
||||
@@ -145,11 +126,9 @@ func NewServer(serverOptions *ServerOptions) (*Server, error) {
|
||||
}
|
||||
|
||||
rm, err := makeRulesManager(
|
||||
serverOptions.RuleRepoURL,
|
||||
serverOptions.SigNoz.SQLStore.SQLxDB(),
|
||||
reader,
|
||||
c,
|
||||
serverOptions.DisableRules,
|
||||
serverOptions.UseLogsNewSchema,
|
||||
serverOptions.UseTraceNewSchema,
|
||||
serverOptions.SigNoz.SQLStore,
|
||||
@@ -183,12 +162,8 @@ func NewServer(serverOptions *ServerOptions) (*Server, error) {
|
||||
}
|
||||
|
||||
telemetry.GetInstance().SetReader(reader)
|
||||
preferenceAPI := preference.NewAPI(preferencecore.NewPreference(preferencecore.NewStore(serverOptions.SigNoz.SQLStore), preferencetypes.NewDefaultPreferenceMap()))
|
||||
organizationAPI := implorganization.NewAPI(implorganization.NewModule(implorganization.NewStore(serverOptions.SigNoz.SQLStore)))
|
||||
organizationModule := implorganization.NewModule(implorganization.NewStore(serverOptions.SigNoz.SQLStore))
|
||||
apiHandler, err := NewAPIHandler(APIHandlerOpts{
|
||||
Reader: reader,
|
||||
SkipConfig: skipConfig,
|
||||
PreferSpanMetrics: serverOptions.PreferSpanMetrics,
|
||||
AppDao: dao.DB(),
|
||||
RuleManager: rm,
|
||||
@@ -204,17 +179,12 @@ func NewServer(serverOptions *ServerOptions) (*Server, error) {
|
||||
AlertmanagerAPI: alertmanager.NewAPI(serverOptions.SigNoz.Alertmanager),
|
||||
FieldsAPI: fields.NewAPI(serverOptions.SigNoz.TelemetryStore),
|
||||
Signoz: serverOptions.SigNoz,
|
||||
Preference: preferenceAPI,
|
||||
OrganizationAPI: organizationAPI,
|
||||
OrganizationModule: organizationModule,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
s := &Server{
|
||||
// logger: logger,
|
||||
// tracer: tracer,
|
||||
ruleManager: rm,
|
||||
serverOptions: serverOptions,
|
||||
unavailableChannel: make(chan healthcheck.Status),
|
||||
@@ -319,6 +289,7 @@ func (s *Server) createPublicServer(api *APIHandler, web web.Web) (*http.Server,
|
||||
api.RegisterMessagingQueuesRoutes(r, am)
|
||||
api.RegisterThirdPartyApiRoutes(r, am)
|
||||
api.MetricExplorerRoutes(r, am)
|
||||
api.RegisterTraceFunnelsRoutes(r, am)
|
||||
|
||||
c := cors.New(cors.Options{
|
||||
AllowedOrigins: []string{"*"},
|
||||
@@ -374,13 +345,7 @@ func (s *Server) initListeners() error {
|
||||
|
||||
// Start listening on http and private http port concurrently
|
||||
func (s *Server) Start(ctx context.Context) error {
|
||||
|
||||
// initiate rule manager first
|
||||
if !s.serverOptions.DisableRules {
|
||||
s.ruleManager.Start(ctx)
|
||||
} else {
|
||||
zap.L().Info("msg: Rules disabled as rules.disable is set to TRUE")
|
||||
}
|
||||
s.ruleManager.Start(ctx)
|
||||
|
||||
err := s.initListeners()
|
||||
if err != nil {
|
||||
@@ -468,11 +433,9 @@ func (s *Server) Stop(ctx context.Context) error {
|
||||
}
|
||||
|
||||
func makeRulesManager(
|
||||
ruleRepoURL string,
|
||||
db *sqlx.DB,
|
||||
ch interfaces.Reader,
|
||||
cache cache.Cache,
|
||||
disableRules bool,
|
||||
useLogsNewSchema bool,
|
||||
useTraceNewSchema bool,
|
||||
sqlstore sqlstore.SQLStore,
|
||||
@@ -483,11 +446,9 @@ func makeRulesManager(
|
||||
managerOpts := &rules.ManagerOptions{
|
||||
TelemetryStore: telemetryStore,
|
||||
Prometheus: prometheus,
|
||||
RepoURL: ruleRepoURL,
|
||||
DBConn: db,
|
||||
Context: context.Background(),
|
||||
Logger: zap.L(),
|
||||
DisableRules: disableRules,
|
||||
Reader: ch,
|
||||
Cache: cache,
|
||||
EvalDelay: constants.GetEvalDelay(),
|
||||
|
||||
@@ -18,10 +18,6 @@ const (
|
||||
OpAmpWsEndpoint = "0.0.0.0:4320" // address for opamp websocket
|
||||
)
|
||||
|
||||
type ContextKey string
|
||||
|
||||
const ContextUserKey ContextKey = "user"
|
||||
|
||||
var DEFAULT_TELEMETRY_ANONYMOUS = false
|
||||
|
||||
func IsOSSTelemetryEnabled() bool {
|
||||
@@ -57,9 +53,6 @@ var TELEMETRY_ACTIVE_USER_DURATION_MINUTES = GetOrDefaultEnvInt("TELEMETRY_ACTIV
|
||||
|
||||
var InviteEmailTemplate = GetOrDefaultEnv("INVITE_EMAIL_TEMPLATE", "/root/templates/invitation_email_template.html")
|
||||
|
||||
// [Deprecated] SIGNOZ_LOCAL_DB_PATH is deprecated and scheduled for removal. Please use SIGNOZ_SQLSTORE_SQLITE_PATH instead.
|
||||
var RELATIONAL_DATASOURCE_PATH = GetOrDefaultEnv("SIGNOZ_LOCAL_DB_PATH", "/var/lib/signoz/signoz.db")
|
||||
|
||||
var MetricsExplorerClickhouseThreads = GetOrDefaultEnvInt("METRICS_EXPLORER_CLICKHOUSE_THREADS", 8)
|
||||
var UpdatedMetricsMetadataCachePrefix = GetOrDefaultEnv("METRICS_UPDATED_METADATA_CACHE_KEY", "UPDATED_METRICS_METADATA")
|
||||
|
||||
|
||||
@@ -208,7 +208,7 @@ func (mds *ModelDaoSqlite) GetUser(ctx context.Context,
|
||||
query := mds.bundb.NewSelect().
|
||||
Table("users").
|
||||
Column("users.id", "users.name", "users.email", "users.password", "users.created_at", "users.profile_picture_url", "users.org_id", "users.role").
|
||||
ColumnExpr("o.name as organization").
|
||||
ColumnExpr("o.display_name as organization").
|
||||
Join("JOIN organizations o ON o.id = users.org_id").
|
||||
Where("users.id = ?", id)
|
||||
|
||||
@@ -243,7 +243,7 @@ func (mds *ModelDaoSqlite) GetUserByEmail(ctx context.Context,
|
||||
query := mds.bundb.NewSelect().
|
||||
Table("users").
|
||||
Column("users.id", "users.name", "users.email", "users.password", "users.created_at", "users.profile_picture_url", "users.org_id", "users.role").
|
||||
ColumnExpr("o.name as organization").
|
||||
ColumnExpr("o.display_name as organization").
|
||||
Join("JOIN organizations o ON o.id = users.org_id").
|
||||
Where("users.email = ?", email)
|
||||
|
||||
@@ -277,7 +277,7 @@ func (mds *ModelDaoSqlite) GetUsersWithOpts(ctx context.Context, limit int) ([]t
|
||||
Table("users").
|
||||
Column("users.id", "users.name", "users.email", "users.password", "users.created_at", "users.profile_picture_url", "users.org_id", "users.role").
|
||||
ColumnExpr("users.role as role").
|
||||
ColumnExpr("o.name as organization").
|
||||
ColumnExpr("o.display_name as organization").
|
||||
Join("JOIN organizations o ON o.id = users.org_id")
|
||||
|
||||
if limit > 0 {
|
||||
@@ -300,7 +300,7 @@ func (mds *ModelDaoSqlite) GetUsersByOrg(ctx context.Context,
|
||||
Table("users").
|
||||
Column("users.id", "users.name", "users.email", "users.password", "users.created_at", "users.profile_picture_url", "users.org_id", "users.role").
|
||||
ColumnExpr("users.role as role").
|
||||
ColumnExpr("o.name as organization").
|
||||
ColumnExpr("o.display_name as organization").
|
||||
Join("JOIN organizations o ON o.id = users.org_id").
|
||||
Where("users.org_id = ?", orgId)
|
||||
|
||||
@@ -318,7 +318,7 @@ func (mds *ModelDaoSqlite) GetUsersByRole(ctx context.Context, role authtypes.Ro
|
||||
Table("users").
|
||||
Column("users.id", "users.name", "users.email", "users.password", "users.created_at", "users.profile_picture_url", "users.org_id", "users.role").
|
||||
ColumnExpr("users.role as role").
|
||||
ColumnExpr("o.name as organization").
|
||||
ColumnExpr("o.display_name as organization").
|
||||
Join("JOIN organizations o ON o.id = users.org_id").
|
||||
Where("users.role = ?", role)
|
||||
|
||||
|
||||
@@ -15,8 +15,8 @@ import (
|
||||
type Reader interface {
|
||||
GetInstantQueryMetricsResult(ctx context.Context, query *model.InstantQueryMetricsParams) (*promql.Result, *stats.QueryStats, *model.ApiError)
|
||||
GetQueryRangeResult(ctx context.Context, query *model.QueryRangeParams) (*promql.Result, *stats.QueryStats, *model.ApiError)
|
||||
GetTopLevelOperations(ctx context.Context, skipConfig *model.SkipConfig, start, end time.Time, services []string) (*map[string][]string, *model.ApiError)
|
||||
GetServices(ctx context.Context, query *model.GetServicesParams, skipConfig *model.SkipConfig) (*[]model.ServiceItem, *model.ApiError)
|
||||
GetTopLevelOperations(ctx context.Context, start, end time.Time, services []string) (*map[string][]string, *model.ApiError)
|
||||
GetServices(ctx context.Context, query *model.GetServicesParams) (*[]model.ServiceItem, *model.ApiError)
|
||||
GetTopOperations(ctx context.Context, query *model.GetTopOperationsParams) (*[]model.TopOperationsItem, *model.ApiError)
|
||||
GetUsage(ctx context.Context, query *model.GetUsageParams) (*[]model.UsageItem, error)
|
||||
GetServicesList(ctx context.Context) (*[]string, error)
|
||||
|
||||
@@ -47,10 +47,14 @@ func main() {
|
||||
|
||||
flag.BoolVar(&useLogsNewSchema, "use-logs-new-schema", false, "use logs_v2 schema for logs")
|
||||
flag.BoolVar(&useTraceNewSchema, "use-trace-new-schema", false, "use new schema for traces")
|
||||
// Deprecated
|
||||
flag.StringVar(&promConfigPath, "config", "./config/prometheus.yml", "(prometheus config to read metrics)")
|
||||
// Deprecated
|
||||
flag.StringVar(&skipTopLvlOpsPath, "skip-top-level-ops", "", "(config file to skip top level operations)")
|
||||
// Deprecated
|
||||
flag.BoolVar(&disableRules, "rules.disable", false, "(disable rule evaluation)")
|
||||
flag.BoolVar(&preferSpanMetrics, "prefer-span-metrics", false, "(prefer span metrics for service level metrics)")
|
||||
// Deprecated
|
||||
flag.StringVar(&ruleRepoURL, "rules.repo-url", constants.AlertHelpPage, "(host address used to build rule link in alert messages)")
|
||||
flag.StringVar(&cacheConfigPath, "experimental.cache-config", "", "(cache config to use)")
|
||||
flag.StringVar(&fluxInterval, "flux-interval", "5m", "(the interval to exclude data from being cached to avoid incorrect cache for data in motion)")
|
||||
@@ -58,8 +62,11 @@ func main() {
|
||||
flag.StringVar(&cluster, "cluster", "cluster", "(cluster name - defaults to 'cluster')")
|
||||
// Allow using the consistent naming with the signoz collector
|
||||
flag.StringVar(&cluster, "cluster-name", "cluster", "(cluster name - defaults to 'cluster')")
|
||||
// Deprecated
|
||||
flag.IntVar(&maxIdleConns, "max-idle-conns", 50, "(number of connections to maintain in the pool, only used with clickhouse if not set in ClickHouseUrl env var DSN.)")
|
||||
// Deprecated
|
||||
flag.IntVar(&maxOpenConns, "max-open-conns", 100, "(max connections for use at any time, only used with clickhouse if not set in ClickHouseUrl env var DSN.)")
|
||||
// Deprecated
|
||||
flag.DurationVar(&dialTimeout, "dial-timeout", 5*time.Second, "(the maximum time to establish a connection, only used with clickhouse if not set in ClickHouseUrl env var DSN.)")
|
||||
flag.Parse()
|
||||
|
||||
@@ -113,12 +120,8 @@ func main() {
|
||||
serverOptions := &app.ServerOptions{
|
||||
Config: config,
|
||||
HTTPHostPort: constants.HTTPHostPort,
|
||||
PromConfigPath: promConfigPath,
|
||||
SkipTopLvlOpsPath: skipTopLvlOpsPath,
|
||||
PreferSpanMetrics: preferSpanMetrics,
|
||||
PrivateHostPort: constants.PrivateHostPort,
|
||||
DisableRules: disableRules,
|
||||
RuleRepoURL: ruleRepoURL,
|
||||
CacheConfigPath: cacheConfigPath,
|
||||
FluxInterval: fluxInterval,
|
||||
FluxIntervalForTraceDetail: fluxIntervalForTraceDetail,
|
||||
|
||||
@@ -88,6 +88,11 @@ type ChangePasswordRequest struct {
|
||||
NewPassword string `json:"newPassword"`
|
||||
}
|
||||
|
||||
type ResetPasswordRequest struct {
|
||||
Password string `json:"password"`
|
||||
Token string `json:"token"`
|
||||
}
|
||||
|
||||
type UserRole struct {
|
||||
UserId string `json:"user_id"`
|
||||
GroupName string `json:"group_name"`
|
||||
|
||||
@@ -1,57 +0,0 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
"os"
|
||||
|
||||
"gopkg.in/yaml.v2"
|
||||
)
|
||||
|
||||
type SkipConfig struct {
|
||||
Services []ServiceSkipConfig `yaml:"services"`
|
||||
}
|
||||
|
||||
type ServiceSkipConfig struct {
|
||||
Name string `yaml:"name"`
|
||||
Operations []string `yaml:"operations"`
|
||||
}
|
||||
|
||||
func (s *SkipConfig) ShouldSkip(serviceName, name string) bool {
|
||||
for _, service := range s.Services {
|
||||
if service.Name == serviceName {
|
||||
for _, operation := range service.Operations {
|
||||
if name == operation {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func ReadYaml(path string, v interface{}) error {
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
decoder := yaml.NewDecoder(f)
|
||||
err = decoder.Decode(v)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func ReadSkipConfig(path string) (*SkipConfig, error) {
|
||||
if path == "" {
|
||||
return &SkipConfig{}, nil
|
||||
}
|
||||
|
||||
skipConfig := &SkipConfig{}
|
||||
err := ReadYaml(path, skipConfig)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return skipConfig, nil
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
package model
|
||||
|
||||
import "time"
|
||||
|
||||
type ResetPasswordRequest struct {
|
||||
Password string `json:"password"`
|
||||
Token string `json:"token"`
|
||||
}
|
||||
|
||||
type IngestionKey struct {
|
||||
KeyId string `json:"keyId" db:"key_id"`
|
||||
Name string `json:"name" db:"name"`
|
||||
CreatedAt time.Time `json:"createdAt" db:"created_at"`
|
||||
IngestionKey string `json:"ingestionKey" db:"ingestion_key"`
|
||||
IngestionURL string `json:"ingestionURL" db:"ingestion_url"`
|
||||
DataRegion string `json:"dataRegion" db:"data_region"`
|
||||
}
|
||||
@@ -84,18 +84,14 @@ func prepareTaskName(ruleId interface{}) string {
|
||||
type ManagerOptions struct {
|
||||
TelemetryStore telemetrystore.TelemetryStore
|
||||
Prometheus prometheus.Prometheus
|
||||
// RepoURL is used to generate a backlink in sent alert messages
|
||||
RepoURL string
|
||||
|
||||
// rule db conn
|
||||
DBConn *sqlx.DB
|
||||
|
||||
Context context.Context
|
||||
Logger *zap.Logger
|
||||
ResendDelay time.Duration
|
||||
DisableRules bool
|
||||
Reader interfaces.Reader
|
||||
Cache cache.Cache
|
||||
Context context.Context
|
||||
Logger *zap.Logger
|
||||
ResendDelay time.Duration
|
||||
Reader interfaces.Reader
|
||||
Cache cache.Cache
|
||||
|
||||
EvalDelay time.Duration
|
||||
|
||||
@@ -395,11 +391,9 @@ func (m *Manager) EditRule(ctx context.Context, ruleStr string, idStr string) er
|
||||
return err
|
||||
}
|
||||
|
||||
if !m.opts.DisableRules {
|
||||
err = m.syncRuleStateWithTask(ctx, claims.OrgID, prepareTaskName(existingRule.ID.StringValue()), parsedRule)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
err = m.syncRuleStateWithTask(ctx, claims.OrgID, prepareTaskName(existingRule.ID.StringValue()), parsedRule)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
@@ -496,9 +490,7 @@ func (m *Manager) DeleteRule(ctx context.Context, idStr string) error {
|
||||
}
|
||||
|
||||
taskName := prepareTaskName(id.StringValue())
|
||||
if !m.opts.DisableRules {
|
||||
m.deleteTask(taskName)
|
||||
}
|
||||
m.deleteTask(taskName)
|
||||
|
||||
return nil
|
||||
})
|
||||
@@ -581,10 +573,8 @@ func (m *Manager) CreateRule(ctx context.Context, ruleStr string) (*ruletypes.Ge
|
||||
}
|
||||
|
||||
taskName := prepareTaskName(id.StringValue())
|
||||
if !m.opts.DisableRules {
|
||||
if err := m.addTask(ctx, claims.OrgID, parsedRule, taskName); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := m.addTask(ctx, claims.OrgID, parsedRule, taskName); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
@@ -724,9 +714,6 @@ func (m *Manager) prepareNotifyFunc() NotifyFunc {
|
||||
|
||||
for _, alert := range alerts {
|
||||
generatorURL := alert.GeneratorURL
|
||||
if generatorURL == "" {
|
||||
generatorURL = m.opts.RepoURL
|
||||
}
|
||||
|
||||
a := &alertmanagertypes.PostableAlert{
|
||||
Annotations: alert.Annotations.Map(),
|
||||
@@ -759,9 +746,6 @@ func (m *Manager) prepareTestNotifyFunc() NotifyFunc {
|
||||
|
||||
alert := alerts[0]
|
||||
generatorURL := alert.GeneratorURL
|
||||
if generatorURL == "" {
|
||||
generatorURL = m.opts.RepoURL
|
||||
}
|
||||
|
||||
a := &alertmanagertypes.PostableAlert{
|
||||
Annotations: alert.Annotations.Map(),
|
||||
|
||||
@@ -18,6 +18,7 @@ import (
|
||||
"github.com/SigNoz/signoz/pkg/query-service/featureManager"
|
||||
v3 "github.com/SigNoz/signoz/pkg/query-service/model/v3"
|
||||
"github.com/SigNoz/signoz/pkg/query-service/utils"
|
||||
"github.com/SigNoz/signoz/pkg/signoz"
|
||||
"github.com/SigNoz/signoz/pkg/types"
|
||||
mockhouse "github.com/srikanthccv/ClickHouse-go-mock"
|
||||
"github.com/stretchr/testify/require"
|
||||
@@ -297,11 +298,17 @@ func NewFilterSuggestionsTestBed(t *testing.T) *FilterSuggestionsTestBed {
|
||||
reader, mockClickhouse := NewMockClickhouseReader(t, testDB)
|
||||
mockClickhouse.MatchExpectationsInOrder(false)
|
||||
|
||||
modules := signoz.NewModules(testDB)
|
||||
|
||||
apiHandler, err := app.NewAPIHandler(app.APIHandlerOpts{
|
||||
Reader: reader,
|
||||
AppDao: dao.DB(),
|
||||
FeatureFlags: fm,
|
||||
JWT: jwt,
|
||||
Signoz: &signoz.SigNoz{
|
||||
Modules: modules,
|
||||
Handlers: signoz.NewHandlers(modules),
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("could not create a new ApiHandler: %v", err)
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/http/middleware"
|
||||
"github.com/SigNoz/signoz/pkg/modules/organization/implorganization"
|
||||
"github.com/SigNoz/signoz/pkg/signoz"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/instrumentation/instrumentationtest"
|
||||
"github.com/SigNoz/signoz/pkg/query-service/app"
|
||||
@@ -360,12 +361,19 @@ func NewCloudIntegrationsTestBed(t *testing.T, testDB sqlstore.SQLStore) *CloudI
|
||||
reader, mockClickhouse := NewMockClickhouseReader(t, testDB)
|
||||
mockClickhouse.MatchExpectationsInOrder(false)
|
||||
|
||||
modules := signoz.NewModules(testDB)
|
||||
handlers := signoz.NewHandlers(modules)
|
||||
|
||||
apiHandler, err := app.NewAPIHandler(app.APIHandlerOpts{
|
||||
Reader: reader,
|
||||
AppDao: dao.DB(),
|
||||
CloudIntegrationsController: controller,
|
||||
FeatureFlags: fm,
|
||||
JWT: jwt,
|
||||
Signoz: &signoz.SigNoz{
|
||||
Modules: modules,
|
||||
Handlers: handlers,
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("could not create a new ApiHandler: %v", err)
|
||||
|
||||
@@ -19,6 +19,7 @@ import (
|
||||
"github.com/SigNoz/signoz/pkg/query-service/model"
|
||||
v3 "github.com/SigNoz/signoz/pkg/query-service/model/v3"
|
||||
"github.com/SigNoz/signoz/pkg/query-service/utils"
|
||||
"github.com/SigNoz/signoz/pkg/signoz"
|
||||
"github.com/SigNoz/signoz/pkg/sqlstore"
|
||||
"github.com/SigNoz/signoz/pkg/types"
|
||||
"github.com/SigNoz/signoz/pkg/types/pipelinetypes"
|
||||
@@ -566,6 +567,9 @@ func NewIntegrationsTestBed(t *testing.T, testDB sqlstore.SQLStore) *Integration
|
||||
t.Fatalf("could not create cloud integrations controller: %v", err)
|
||||
}
|
||||
|
||||
modules := signoz.NewModules(testDB)
|
||||
handlers := signoz.NewHandlers(modules)
|
||||
|
||||
apiHandler, err := app.NewAPIHandler(app.APIHandlerOpts{
|
||||
Reader: reader,
|
||||
AppDao: dao.DB(),
|
||||
@@ -573,6 +577,10 @@ func NewIntegrationsTestBed(t *testing.T, testDB sqlstore.SQLStore) *Integration
|
||||
FeatureFlags: fm,
|
||||
JWT: jwt,
|
||||
CloudIntegrationsController: cloudIntegrationsController,
|
||||
Signoz: &signoz.SigNoz{
|
||||
Modules: modules,
|
||||
Handlers: handlers,
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("could not create a new ApiHandler: %v", err)
|
||||
|
||||
24
pkg/signoz/handler.go
Normal file
24
pkg/signoz/handler.go
Normal file
@@ -0,0 +1,24 @@
|
||||
package signoz
|
||||
|
||||
import (
|
||||
"github.com/SigNoz/signoz/pkg/modules/organization"
|
||||
"github.com/SigNoz/signoz/pkg/modules/organization/implorganization"
|
||||
"github.com/SigNoz/signoz/pkg/modules/preference"
|
||||
"github.com/SigNoz/signoz/pkg/modules/preference/implpreference"
|
||||
"github.com/SigNoz/signoz/pkg/modules/tracefunnel"
|
||||
"github.com/SigNoz/signoz/pkg/modules/tracefunnel/impltracefunnel"
|
||||
)
|
||||
|
||||
type Handlers struct {
|
||||
Organization organization.Handler
|
||||
Preference preference.Handler
|
||||
TraceFunnel tracefunnel.Handler
|
||||
}
|
||||
|
||||
func NewHandlers(modules Modules) Handlers {
|
||||
return Handlers{
|
||||
Organization: implorganization.NewHandler(modules.Organization),
|
||||
Preference: implpreference.NewHandler(modules.Preference),
|
||||
TraceFunnel: impltracefunnel.NewHandler(modules.TraceFunnel),
|
||||
}
|
||||
}
|
||||
26
pkg/signoz/module.go
Normal file
26
pkg/signoz/module.go
Normal file
@@ -0,0 +1,26 @@
|
||||
package signoz
|
||||
|
||||
import (
|
||||
"github.com/SigNoz/signoz/pkg/modules/organization"
|
||||
"github.com/SigNoz/signoz/pkg/modules/organization/implorganization"
|
||||
"github.com/SigNoz/signoz/pkg/modules/preference"
|
||||
"github.com/SigNoz/signoz/pkg/modules/preference/implpreference"
|
||||
"github.com/SigNoz/signoz/pkg/modules/tracefunnel"
|
||||
"github.com/SigNoz/signoz/pkg/modules/tracefunnel/impltracefunnel"
|
||||
"github.com/SigNoz/signoz/pkg/sqlstore"
|
||||
"github.com/SigNoz/signoz/pkg/types/preferencetypes"
|
||||
)
|
||||
|
||||
type Modules struct {
|
||||
Organization organization.Module
|
||||
Preference preference.Module
|
||||
TraceFunnel tracefunnel.Module
|
||||
}
|
||||
|
||||
func NewModules(sqlstore sqlstore.SQLStore) Modules {
|
||||
return Modules{
|
||||
Organization: implorganization.NewModule(implorganization.NewStore(sqlstore)),
|
||||
Preference: implpreference.NewModule(implpreference.NewStore(sqlstore), preferencetypes.NewDefaultPreferenceMap()),
|
||||
TraceFunnel: impltracefunnel.NewModule(impltracefunnel.NewStore(sqlstore)),
|
||||
}
|
||||
}
|
||||
@@ -74,6 +74,7 @@ func NewSQLMigrationProviderFactories(sqlstore sqlstore.SQLStore) factory.NamedM
|
||||
sqlmigration.NewUpdateIntegrationsFactory(sqlstore),
|
||||
sqlmigration.NewUpdateOrganizationsFactory(sqlstore),
|
||||
sqlmigration.NewDropGroupsFactory(sqlstore),
|
||||
sqlmigration.NewAddTraceFunnelsFactory(sqlstore),
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -26,6 +26,8 @@ type SigNoz struct {
|
||||
TelemetryStore telemetrystore.TelemetryStore
|
||||
Prometheus prometheus.Prometheus
|
||||
Alertmanager alertmanager.Alertmanager
|
||||
Modules Modules
|
||||
Handlers Handlers
|
||||
}
|
||||
|
||||
func New(
|
||||
@@ -124,6 +126,7 @@ func New(
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Initialize alertmanager from the available alertmanager provider factories
|
||||
alertmanager, err := factory.NewProviderFromNamedMap(
|
||||
ctx,
|
||||
providerSettings,
|
||||
@@ -135,6 +138,12 @@ func New(
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Initialize all modules
|
||||
modules := NewModules(sqlstore)
|
||||
|
||||
// Initialize all handlers for the modules
|
||||
handlers := NewHandlers(modules)
|
||||
|
||||
registry, err := factory.NewRegistry(
|
||||
instrumentation.Logger(),
|
||||
factory.NewNamedService(factory.MustNewName("instrumentation"), instrumentation),
|
||||
@@ -153,5 +162,7 @@ func New(
|
||||
TelemetryStore: telemetrystore,
|
||||
Prometheus: prometheus,
|
||||
Alertmanager: alertmanager,
|
||||
Modules: modules,
|
||||
Handlers: handlers,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -38,14 +38,26 @@ func (migration *dropLicensesSites) Up(ctx context.Context, db *bun.DB) error {
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
if _, err := tx.NewDropTable().IfExists().Table("sites").Exec(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := tx.NewDropTable().IfExists().Table("licenses").Exec(ctx); err != nil {
|
||||
if _, err := tx.
|
||||
NewDropTable().
|
||||
IfExists().
|
||||
Table("sites").
|
||||
Exec(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
_, err = migration.store.Dialect().RenameColumn(ctx, tx, "saved_views", "uuid", "id")
|
||||
if _, err := tx.
|
||||
NewDropTable().
|
||||
IfExists().
|
||||
Table("licenses").
|
||||
Exec(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
_, err = migration.
|
||||
store.
|
||||
Dialect().
|
||||
RenameColumn(ctx, tx, "saved_views", "uuid", "id")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -42,12 +42,9 @@ type newInvite struct {
|
||||
}
|
||||
|
||||
func NewUpdateInvitesFactory(sqlstore sqlstore.SQLStore) factory.ProviderFactory[SQLMigration, Config] {
|
||||
return factory.
|
||||
NewProviderFactory(
|
||||
factory.MustNewName("update_invites"),
|
||||
func(ctx context.Context, ps factory.ProviderSettings, c Config) (SQLMigration, error) {
|
||||
return newUpdateInvites(ctx, ps, c, sqlstore)
|
||||
})
|
||||
return factory.NewProviderFactory(factory.MustNewName("update_invites"), func(ctx context.Context, ps factory.ProviderSettings, c Config) (SQLMigration, error) {
|
||||
return newUpdateInvites(ctx, ps, c, sqlstore)
|
||||
})
|
||||
}
|
||||
|
||||
func newUpdateInvites(_ context.Context, _ factory.ProviderSettings, _ Config, store sqlstore.SQLStore) (SQLMigration, error) {
|
||||
@@ -55,8 +52,7 @@ func newUpdateInvites(_ context.Context, _ factory.ProviderSettings, _ Config, s
|
||||
}
|
||||
|
||||
func (migration *updateInvites) Register(migrations *migrate.Migrations) error {
|
||||
if err := migrations.
|
||||
Register(migration.Up, migration.Down); err != nil {
|
||||
if err := migrations.Register(migration.Up, migration.Down); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -64,8 +60,7 @@ func (migration *updateInvites) Register(migrations *migrate.Migrations) error {
|
||||
}
|
||||
|
||||
func (migration *updateInvites) Up(ctx context.Context, db *bun.DB) error {
|
||||
tx, err := db.
|
||||
BeginTx(ctx, nil)
|
||||
tx, err := db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -88,8 +83,7 @@ func (migration *updateInvites) Up(ctx context.Context, db *bun.DB) error {
|
||||
}
|
||||
|
||||
if err == nil && len(existingInvites) > 0 {
|
||||
newInvites := migration.
|
||||
CopyOldInvitesToNewInvites(existingInvites)
|
||||
newInvites := migration.CopyOldInvitesToNewInvites(existingInvites)
|
||||
_, err = tx.
|
||||
NewInsert().
|
||||
Model(&newInvites).
|
||||
|
||||
@@ -20,9 +20,7 @@ func NewUpdatePatFactory(sqlstore sqlstore.SQLStore) factory.ProviderFactory[SQL
|
||||
}
|
||||
|
||||
func newUpdatePat(_ context.Context, _ factory.ProviderSettings, _ Config, store sqlstore.SQLStore) (SQLMigration, error) {
|
||||
return &updatePat{
|
||||
store: store,
|
||||
}, nil
|
||||
return &updatePat{store: store}, nil
|
||||
}
|
||||
|
||||
func (migration *updatePat) Register(migrations *migrate.Migrations) error {
|
||||
@@ -34,25 +32,33 @@ func (migration *updatePat) Register(migrations *migrate.Migrations) error {
|
||||
}
|
||||
|
||||
func (migration *updatePat) Up(ctx context.Context, db *bun.DB) error {
|
||||
|
||||
// begin transaction
|
||||
tx, err := db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
defer tx.Rollback()
|
||||
|
||||
for _, column := range []string{"last_used", "expires_at"} {
|
||||
if err := migration.store.Dialect().AddNotNullDefaultToColumn(ctx, tx, "personal_access_tokens", column, "INTEGER", "0"); err != nil {
|
||||
if err := migration.
|
||||
store.
|
||||
Dialect().
|
||||
AddNotNullDefaultToColumn(ctx, tx, "personal_access_tokens", column, "INTEGER", "0"); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if err := migration.store.Dialect().AddNotNullDefaultToColumn(ctx, tx, "personal_access_tokens", "revoked", "BOOLEAN", "false"); err != nil {
|
||||
if err := migration.
|
||||
store.
|
||||
Dialect().
|
||||
AddNotNullDefaultToColumn(ctx, tx, "personal_access_tokens", "revoked", "BOOLEAN", "false"); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := migration.store.Dialect().AddNotNullDefaultToColumn(ctx, tx, "personal_access_tokens", "updated_by_user_id", "TEXT", "''"); err != nil {
|
||||
if err := migration.
|
||||
store.
|
||||
Dialect().
|
||||
AddNotNullDefaultToColumn(ctx, tx, "personal_access_tokens", "updated_by_user_id", "TEXT", "''"); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
|
||||
@@ -77,12 +77,9 @@ type newAlertmanagerState struct {
|
||||
}
|
||||
|
||||
func NewUpdateAlertmanagerFactory(sqlstore sqlstore.SQLStore) factory.ProviderFactory[SQLMigration, Config] {
|
||||
return factory.
|
||||
NewProviderFactory(
|
||||
factory.MustNewName("update_alertmanager"),
|
||||
func(ctx context.Context, ps factory.ProviderSettings, c Config) (SQLMigration, error) {
|
||||
return newUpdateAlertmanager(ctx, ps, c, sqlstore)
|
||||
})
|
||||
return factory.NewProviderFactory(factory.MustNewName("update_alertmanager"), func(ctx context.Context, ps factory.ProviderSettings, c Config) (SQLMigration, error) {
|
||||
return newUpdateAlertmanager(ctx, ps, c, sqlstore)
|
||||
})
|
||||
}
|
||||
|
||||
func newUpdateAlertmanager(_ context.Context, _ factory.ProviderSettings, _ Config, store sqlstore.SQLStore) (SQLMigration, error) {
|
||||
@@ -90,8 +87,7 @@ func newUpdateAlertmanager(_ context.Context, _ factory.ProviderSettings, _ Conf
|
||||
}
|
||||
|
||||
func (migration *updateAlertmanager) Register(migrations *migrate.Migrations) error {
|
||||
if err := migrations.
|
||||
Register(migration.Up, migration.Down); err != nil {
|
||||
if err := migrations.Register(migration.Up, migration.Down); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -99,8 +95,7 @@ func (migration *updateAlertmanager) Register(migrations *migrate.Migrations) er
|
||||
}
|
||||
|
||||
func (migration *updateAlertmanager) Up(ctx context.Context, db *bun.DB) error {
|
||||
tx, err := db.
|
||||
BeginTx(ctx, nil)
|
||||
tx, err := db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -49,12 +49,9 @@ type newUserPreference struct {
|
||||
}
|
||||
|
||||
func NewUpdatePreferencesFactory(sqlstore sqlstore.SQLStore) factory.ProviderFactory[SQLMigration, Config] {
|
||||
return factory.
|
||||
NewProviderFactory(
|
||||
factory.MustNewName("update_preferences"),
|
||||
func(ctx context.Context, ps factory.ProviderSettings, c Config) (SQLMigration, error) {
|
||||
return newUpdatePreferences(ctx, ps, c, sqlstore)
|
||||
})
|
||||
return factory.NewProviderFactory(factory.MustNewName("update_preferences"), func(ctx context.Context, ps factory.ProviderSettings, c Config) (SQLMigration, error) {
|
||||
return newUpdatePreferences(ctx, ps, c, sqlstore)
|
||||
})
|
||||
}
|
||||
|
||||
func newUpdatePreferences(_ context.Context, _ factory.ProviderSettings, _ Config, store sqlstore.SQLStore) (SQLMigration, error) {
|
||||
@@ -62,8 +59,7 @@ func newUpdatePreferences(_ context.Context, _ factory.ProviderSettings, _ Confi
|
||||
}
|
||||
|
||||
func (migration *updatePreferences) Register(migrations *migrate.Migrations) error {
|
||||
if err := migrations.
|
||||
Register(migration.Up, migration.Down); err != nil {
|
||||
if err := migrations.Register(migration.Up, migration.Down); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -71,8 +67,7 @@ func (migration *updatePreferences) Register(migrations *migrate.Migrations) err
|
||||
}
|
||||
|
||||
func (migration *updatePreferences) Up(ctx context.Context, db *bun.DB) error {
|
||||
tx, err := db.
|
||||
BeginTx(ctx, nil)
|
||||
tx, err := db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -135,8 +130,7 @@ func (migration *updatePreferences) Up(ctx context.Context, db *bun.DB) error {
|
||||
}
|
||||
|
||||
if err == nil && len(existingUserPreferences) > 0 {
|
||||
newUserPreferences := migration.
|
||||
CopyOldUserPreferencesToNewUserPreferences(existingUserPreferences)
|
||||
newUserPreferences := migration.CopyOldUserPreferencesToNewUserPreferences(existingUserPreferences)
|
||||
_, err = tx.
|
||||
NewInsert().
|
||||
Model(&newUserPreferences).
|
||||
|
||||
@@ -61,12 +61,9 @@ type newTTLStatus struct {
|
||||
}
|
||||
|
||||
func NewUpdateApdexTtlFactory(sqlstore sqlstore.SQLStore) factory.ProviderFactory[SQLMigration, Config] {
|
||||
return factory.
|
||||
NewProviderFactory(
|
||||
factory.MustNewName("update_apdex_ttl"),
|
||||
func(ctx context.Context, ps factory.ProviderSettings, c Config) (SQLMigration, error) {
|
||||
return newUpdateApdexTtl(ctx, ps, c, sqlstore)
|
||||
})
|
||||
return factory.NewProviderFactory(factory.MustNewName("update_apdex_ttl"), func(ctx context.Context, ps factory.ProviderSettings, c Config) (SQLMigration, error) {
|
||||
return newUpdateApdexTtl(ctx, ps, c, sqlstore)
|
||||
})
|
||||
}
|
||||
|
||||
func newUpdateApdexTtl(_ context.Context, _ factory.ProviderSettings, _ Config, store sqlstore.SQLStore) (SQLMigration, error) {
|
||||
@@ -74,8 +71,7 @@ func newUpdateApdexTtl(_ context.Context, _ factory.ProviderSettings, _ Config,
|
||||
}
|
||||
|
||||
func (migration *updateApdexTtl) Register(migrations *migrate.Migrations) error {
|
||||
if err := migrations.
|
||||
Register(migration.Up, migration.Down); err != nil {
|
||||
if err := migrations.Register(migration.Up, migration.Down); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -83,8 +79,7 @@ func (migration *updateApdexTtl) Register(migrations *migrate.Migrations) error
|
||||
}
|
||||
|
||||
func (migration *updateApdexTtl) Up(ctx context.Context, db *bun.DB) error {
|
||||
tx, err := db.
|
||||
BeginTx(ctx, nil)
|
||||
tx, err := db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -161,8 +156,7 @@ func (migration *updateApdexTtl) Up(ctx context.Context, db *bun.DB) error {
|
||||
}
|
||||
|
||||
if err == nil {
|
||||
newTTLStatus := migration.
|
||||
CopyExistingTTLStatusToNewTTLStatus(existingTTLStatus, orgID)
|
||||
newTTLStatus := migration.CopyExistingTTLStatusToNewTTLStatus(existingTTLStatus, orgID)
|
||||
_, err = tx.
|
||||
NewInsert().
|
||||
Model(&newTTLStatus).
|
||||
|
||||
@@ -61,12 +61,9 @@ type newPersonalAccessToken struct {
|
||||
}
|
||||
|
||||
func NewUpdateResetPasswordFactory(sqlstore sqlstore.SQLStore) factory.ProviderFactory[SQLMigration, Config] {
|
||||
return factory.
|
||||
NewProviderFactory(
|
||||
factory.MustNewName("update_reset_password"),
|
||||
func(ctx context.Context, ps factory.ProviderSettings, c Config) (SQLMigration, error) {
|
||||
return newUpdateResetPassword(ctx, ps, c, sqlstore)
|
||||
})
|
||||
return factory.NewProviderFactory(factory.MustNewName("update_reset_password"), func(ctx context.Context, ps factory.ProviderSettings, c Config) (SQLMigration, error) {
|
||||
return newUpdateResetPassword(ctx, ps, c, sqlstore)
|
||||
})
|
||||
}
|
||||
|
||||
func newUpdateResetPassword(_ context.Context, _ factory.ProviderSettings, _ Config, store sqlstore.SQLStore) (SQLMigration, error) {
|
||||
@@ -74,8 +71,7 @@ func newUpdateResetPassword(_ context.Context, _ factory.ProviderSettings, _ Con
|
||||
}
|
||||
|
||||
func (migration *updateResetPassword) Register(migrations *migrate.Migrations) error {
|
||||
if err := migrations.
|
||||
Register(migration.Up, migration.Down); err != nil {
|
||||
if err := migrations.Register(migration.Up, migration.Down); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -83,8 +79,7 @@ func (migration *updateResetPassword) Register(migrations *migrate.Migrations) e
|
||||
}
|
||||
|
||||
func (migration *updateResetPassword) Up(ctx context.Context, db *bun.DB) error {
|
||||
tx, err := db.
|
||||
BeginTx(ctx, nil)
|
||||
tx, err := db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -104,8 +99,7 @@ func (migration *updateResetPassword) Up(ctx context.Context, db *bun.DB) error
|
||||
}
|
||||
|
||||
if err == nil && len(existingResetPasswordRequests) > 0 {
|
||||
newResetPasswordRequests := migration.
|
||||
CopyExistingResetPasswordRequestsToNewResetPasswordRequests(existingResetPasswordRequests)
|
||||
newResetPasswordRequests := migration.CopyExistingResetPasswordRequestsToNewResetPasswordRequests(existingResetPasswordRequests)
|
||||
_, err = tx.
|
||||
NewInsert().
|
||||
Model(&newResetPasswordRequests).
|
||||
@@ -134,8 +128,7 @@ func (migration *updateResetPassword) Up(ctx context.Context, db *bun.DB) error
|
||||
}
|
||||
|
||||
if err == nil && len(existingPersonalAccessTokens) > 0 {
|
||||
newPersonalAccessTokens := migration.
|
||||
CopyExistingPATsToNewPATs(existingPersonalAccessTokens)
|
||||
newPersonalAccessTokens := migration.CopyExistingPATsToNewPATs(existingPersonalAccessTokens)
|
||||
_, err = tx.NewInsert().Model(&newPersonalAccessTokens).Exec(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
|
||||
@@ -26,9 +26,7 @@ func NewUpdateIntegrationsFactory(sqlstore sqlstore.SQLStore) factory.ProviderFa
|
||||
}
|
||||
|
||||
func newUpdateIntegrations(_ context.Context, _ factory.ProviderSettings, _ Config, store sqlstore.SQLStore) (SQLMigration, error) {
|
||||
return &updateIntegrations{
|
||||
store: store,
|
||||
}, nil
|
||||
return &updateIntegrations{store: store}, nil
|
||||
}
|
||||
|
||||
func (migration *updateIntegrations) Register(migrations *migrate.Migrations) error {
|
||||
@@ -136,9 +134,7 @@ func (migration *updateIntegrations) Up(ctx context.Context, db *bun.DB) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// ---
|
||||
// installed integrations
|
||||
// ---
|
||||
err = migration.
|
||||
store.
|
||||
Dialect().
|
||||
@@ -171,9 +167,7 @@ func (migration *updateIntegrations) Up(ctx context.Context, db *bun.DB) error {
|
||||
return err
|
||||
}
|
||||
|
||||
// ---
|
||||
// cloud integrations
|
||||
// ---
|
||||
err = migration.
|
||||
store.
|
||||
Dialect().
|
||||
@@ -213,9 +207,7 @@ func (migration *updateIntegrations) Up(ctx context.Context, db *bun.DB) error {
|
||||
return err
|
||||
}
|
||||
|
||||
// ---
|
||||
// cloud integration service
|
||||
// ---
|
||||
err = migration.
|
||||
store.
|
||||
Dialect().
|
||||
|
||||
@@ -93,12 +93,9 @@ type ruleHistory struct {
|
||||
}
|
||||
|
||||
func NewUpdateRulesFactory(sqlstore sqlstore.SQLStore) factory.ProviderFactory[SQLMigration, Config] {
|
||||
return factory.
|
||||
NewProviderFactory(
|
||||
factory.MustNewName("update_rules"),
|
||||
func(ctx context.Context, ps factory.ProviderSettings, c Config) (SQLMigration, error) {
|
||||
return newUpdateRules(ctx, ps, c, sqlstore)
|
||||
})
|
||||
return factory.NewProviderFactory(factory.MustNewName("update_rules"), func(ctx context.Context, ps factory.ProviderSettings, c Config) (SQLMigration, error) {
|
||||
return newUpdateRules(ctx, ps, c, sqlstore)
|
||||
})
|
||||
}
|
||||
|
||||
func newUpdateRules(_ context.Context, _ factory.ProviderSettings, _ Config, store sqlstore.SQLStore) (SQLMigration, error) {
|
||||
@@ -106,8 +103,7 @@ func newUpdateRules(_ context.Context, _ factory.ProviderSettings, _ Config, sto
|
||||
}
|
||||
|
||||
func (migration *updateRules) Register(migrations *migrate.Migrations) error {
|
||||
if err := migrations.
|
||||
Register(migration.Up, migration.Down); err != nil {
|
||||
if err := migrations.Register(migration.Up, migration.Down); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -115,8 +111,7 @@ func (migration *updateRules) Register(migrations *migrate.Migrations) error {
|
||||
}
|
||||
|
||||
func (migration *updateRules) Up(ctx context.Context, db *bun.DB) error {
|
||||
tx, err := db.
|
||||
BeginTx(ctx, nil)
|
||||
tx, err := db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -14,11 +14,9 @@ type updateOrganizations struct {
|
||||
}
|
||||
|
||||
func NewUpdateOrganizationsFactory(sqlstore sqlstore.SQLStore) factory.ProviderFactory[SQLMigration, Config] {
|
||||
return factory.NewProviderFactory(
|
||||
factory.MustNewName("update_organizations"),
|
||||
func(ctx context.Context, ps factory.ProviderSettings, c Config) (SQLMigration, error) {
|
||||
return newUpdateOrganizations(ctx, ps, c, sqlstore)
|
||||
})
|
||||
return factory.NewProviderFactory(factory.MustNewName("update_organizations"), func(ctx context.Context, ps factory.ProviderSettings, c Config) (SQLMigration, error) {
|
||||
return newUpdateOrganizations(ctx, ps, c, sqlstore)
|
||||
})
|
||||
}
|
||||
|
||||
func newUpdateOrganizations(_ context.Context, _ factory.ProviderSettings, _ Config, store sqlstore.SQLStore) (SQLMigration, error) {
|
||||
@@ -26,8 +24,7 @@ func newUpdateOrganizations(_ context.Context, _ factory.ProviderSettings, _ Con
|
||||
}
|
||||
|
||||
func (migration *updateOrganizations) Register(migrations *migrate.Migrations) error {
|
||||
if err := migrations.
|
||||
Register(migration.Up, migration.Down); err != nil {
|
||||
if err := migrations.Register(migration.Up, migration.Down); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -35,8 +32,7 @@ func (migration *updateOrganizations) Register(migrations *migrate.Migrations) e
|
||||
}
|
||||
|
||||
func (migration *updateOrganizations) Up(ctx context.Context, db *bun.DB) error {
|
||||
tx, err := db.
|
||||
BeginTx(ctx, nil)
|
||||
tx, err := db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
96
pkg/sqlmigration/030_add_trace_funnels.go
Normal file
96
pkg/sqlmigration/030_add_trace_funnels.go
Normal file
@@ -0,0 +1,96 @@
|
||||
package sqlmigration
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/factory"
|
||||
"github.com/SigNoz/signoz/pkg/sqlstore"
|
||||
traceFunnels "github.com/SigNoz/signoz/pkg/types/tracefunnel"
|
||||
"github.com/uptrace/bun"
|
||||
"github.com/uptrace/bun/migrate"
|
||||
)
|
||||
|
||||
type addTraceFunnels struct {
|
||||
sqlstore sqlstore.SQLStore
|
||||
}
|
||||
|
||||
func NewAddTraceFunnelsFactory(sqlstore sqlstore.SQLStore) factory.ProviderFactory[SQLMigration, Config] {
|
||||
return factory.NewProviderFactory(factory.MustNewName("add_trace_funnels"), func(ctx context.Context, providerSettings factory.ProviderSettings, config Config) (SQLMigration, error) {
|
||||
return newAddTraceFunnels(ctx, providerSettings, config, sqlstore)
|
||||
})
|
||||
}
|
||||
|
||||
func newAddTraceFunnels(_ context.Context, _ factory.ProviderSettings, _ Config, sqlstore sqlstore.SQLStore) (SQLMigration, error) {
|
||||
return &addTraceFunnels{sqlstore: sqlstore}, nil
|
||||
}
|
||||
|
||||
func (migration *addTraceFunnels) Register(migrations *migrate.Migrations) error {
|
||||
if err := migrations.Register(migration.Up, migration.Down); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (migration *addTraceFunnels) Up(ctx context.Context, db *bun.DB) error {
|
||||
tx, err := db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
// Create trace_funnel table with foreign key constraint inline
|
||||
_, err = tx.NewCreateTable().Model((*traceFunnels.Funnel)(nil)).
|
||||
ForeignKey(`("org_id") REFERENCES "organizations" ("id") ON DELETE CASCADE`).
|
||||
IfNotExists().
|
||||
Exec(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create trace_funnel table: %v", err)
|
||||
}
|
||||
|
||||
// Add unique constraint for org_id and name
|
||||
_, err = tx.NewRaw(`
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_trace_funnel_org_id_name
|
||||
ON trace_funnel (org_id, name)
|
||||
`).Exec(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create unique constraint: %v", err)
|
||||
}
|
||||
|
||||
// Create indexes
|
||||
_, err = tx.NewCreateIndex().Model((*traceFunnels.Funnel)(nil)).Index("idx_trace_funnel_org_id").Column("org_id").Exec(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create org_id index: %v", err)
|
||||
}
|
||||
|
||||
_, err = tx.NewCreateIndex().Model((*traceFunnels.Funnel)(nil)).Index("idx_trace_funnel_created_at").Column("created_at").Exec(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create created_at index: %v", err)
|
||||
}
|
||||
|
||||
if err := tx.Commit(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (migration *addTraceFunnels) Down(ctx context.Context, db *bun.DB) error {
|
||||
tx, err := db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
// Drop trace_funnel table
|
||||
_, err = tx.NewDropTable().Model((*traceFunnels.Funnel)(nil)).IfExists().Exec(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to drop trace_funnel table: %v", err)
|
||||
}
|
||||
|
||||
if err := tx.Commit(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -4,10 +4,11 @@ import (
|
||||
"context"
|
||||
"database/sql"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/factory"
|
||||
"github.com/SigNoz/signoz/pkg/sqlstore"
|
||||
"github.com/jmoiron/sqlx"
|
||||
_ "github.com/mattn/go-sqlite3"
|
||||
sqlite3 "github.com/mattn/go-sqlite3"
|
||||
"github.com/uptrace/bun"
|
||||
"github.com/uptrace/bun/dialect/sqlitedialect"
|
||||
)
|
||||
@@ -77,3 +78,21 @@ func (provider *provider) BunDBCtx(ctx context.Context) bun.IDB {
|
||||
func (provider *provider) RunInTxCtx(ctx context.Context, opts *sql.TxOptions, cb func(ctx context.Context) error) error {
|
||||
return provider.bundb.RunInTxCtx(ctx, opts, cb)
|
||||
}
|
||||
|
||||
func (provider *provider) WrapNotFoundErrf(err error, code errors.Code, format string, args ...any) error {
|
||||
if err == sql.ErrNoRows {
|
||||
return errors.Wrapf(err, errors.TypeNotFound, code, format, args...)
|
||||
}
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
func (provider *provider) WrapAlreadyExistsErrf(err error, code errors.Code, format string, args ...any) error {
|
||||
if sqlite3Err, ok := err.(sqlite3.Error); ok {
|
||||
if sqlite3Err.ExtendedCode == sqlite3.ErrConstraintUnique {
|
||||
return errors.Wrapf(err, errors.TypeAlreadyExists, code, format, args...)
|
||||
}
|
||||
}
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"database/sql"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/jmoiron/sqlx"
|
||||
"github.com/uptrace/bun"
|
||||
)
|
||||
@@ -30,6 +31,12 @@ type SQLStore interface {
|
||||
// BunDBCtx returns an instance of bun.IDB for the given context.
|
||||
// If a transaction is present in the context, it will be used. Otherwise, the default will be used.
|
||||
BunDBCtx(ctx context.Context) bun.IDB
|
||||
|
||||
// WrapNotFoundErrf wraps the given error with the given message and returns it.
|
||||
WrapNotFoundErrf(err error, code errors.Code, format string, args ...any) error
|
||||
|
||||
// WrapAlreadyExistsErrf wraps the given error with the given message and returns it.
|
||||
WrapAlreadyExistsErrf(err error, code errors.Code, format string, args ...any) error
|
||||
}
|
||||
|
||||
type SQLStoreHook interface {
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"fmt"
|
||||
|
||||
"github.com/DATA-DOG/go-sqlmock"
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/sqlstore"
|
||||
"github.com/jmoiron/sqlx"
|
||||
"github.com/uptrace/bun"
|
||||
@@ -74,3 +75,11 @@ func (provider *Provider) BunDBCtx(ctx context.Context) bun.IDB {
|
||||
func (provider *Provider) RunInTxCtx(ctx context.Context, opts *sql.TxOptions, cb func(ctx context.Context) error) error {
|
||||
return cb(ctx)
|
||||
}
|
||||
|
||||
func (provider *Provider) WrapNotFoundErrf(err error, code errors.Code, format string, args ...any) error {
|
||||
return fmt.Errorf(format, args...)
|
||||
}
|
||||
|
||||
func (provider *Provider) WrapAlreadyExistsErrf(err error, code errors.Code, format string, args ...any) error {
|
||||
return fmt.Errorf(format, args...)
|
||||
}
|
||||
|
||||
@@ -4,10 +4,16 @@ import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
"github.com/uptrace/bun"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrOrganizationAlreadyExists = errors.MustNewCode("organization_already_exists")
|
||||
ErrOrganizationNotFound = errors.MustNewCode("organization_not_found")
|
||||
)
|
||||
|
||||
type Organization struct {
|
||||
bun.BaseModel `bun:"table:organizations"`
|
||||
TimeAuditable
|
||||
|
||||
@@ -2,7 +2,6 @@ package preferencetypes
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
@@ -133,7 +132,7 @@ func NewDefaultPreferenceMap() map[string]Preference {
|
||||
}
|
||||
|
||||
func (p *Preference) ErrorValueTypeMismatch() error {
|
||||
return errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, fmt.Sprintf("the preference value is not of expected type: %s", p.ValueType))
|
||||
return errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "the preference value is not of expected type: %s", p.ValueType)
|
||||
}
|
||||
|
||||
func (p *Preference) checkIfInAllowedValues(preferenceValue interface{}) (bool, error) {
|
||||
@@ -219,7 +218,7 @@ func (p *Preference) IsValidValue(preferenceValue interface{}) error {
|
||||
}
|
||||
if !p.IsDiscreteValues {
|
||||
if val < p.Range.Min || val > p.Range.Max {
|
||||
return errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, fmt.Sprintf("the preference value is not in the range specified, min: %v , max:%v", p.Range.Min, p.Range.Max))
|
||||
return errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "the preference value is not in the range specified, min: %v , max: %v", p.Range.Min, p.Range.Max)
|
||||
}
|
||||
}
|
||||
case PreferenceValueTypeString:
|
||||
@@ -248,7 +247,7 @@ func (p *Preference) IsValidValue(preferenceValue interface{}) error {
|
||||
return valueMisMatchErr
|
||||
}
|
||||
if !isInAllowedValues {
|
||||
return errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, fmt.Sprintf("the preference value is not in the list of allowedValues: %v", p.AllowedValues))
|
||||
return errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "the preference value is not in the list of allowedValues: %v", p.AllowedValues)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -280,11 +279,11 @@ func (p *Preference) SanitizeValue(preferenceValue interface{}) interface{} {
|
||||
}
|
||||
}
|
||||
|
||||
type PreferenceStore interface {
|
||||
GetOrgPreference(context.Context, string, string) (*StorableOrgPreference, error)
|
||||
GetAllOrgPreferences(context.Context, string) ([]*StorableOrgPreference, error)
|
||||
UpsertOrgPreference(context.Context, *StorableOrgPreference) error
|
||||
GetUserPreference(context.Context, string, string) (*StorableUserPreference, error)
|
||||
GetAllUserPreferences(context.Context, string) ([]*StorableUserPreference, error)
|
||||
UpsertUserPreference(context.Context, *StorableUserPreference) error
|
||||
type Store interface {
|
||||
GetOrg(context.Context, string, string) (*StorableOrgPreference, error)
|
||||
GetAllOrg(context.Context, string) ([]*StorableOrgPreference, error)
|
||||
UpsertOrg(context.Context, *StorableOrgPreference) error
|
||||
GetUser(context.Context, string, string) (*StorableUserPreference, error)
|
||||
GetAllUser(context.Context, string) ([]*StorableUserPreference, error)
|
||||
UpsertUser(context.Context, *StorableUserPreference) error
|
||||
}
|
||||
|
||||
15
pkg/types/tracefunnel/store.go
Normal file
15
pkg/types/tracefunnel/store.go
Normal file
@@ -0,0 +1,15 @@
|
||||
package traceFunnels
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
)
|
||||
|
||||
type TraceFunnelStore interface {
|
||||
Create(context.Context, *Funnel) error
|
||||
Get(context.Context, valuer.UUID) (*Funnel, error)
|
||||
List(context.Context) ([]*Funnel, error)
|
||||
Update(context.Context, *Funnel) error
|
||||
Delete(context.Context, valuer.UUID) error
|
||||
}
|
||||
113
pkg/types/tracefunnel/tracefunnel.go
Normal file
113
pkg/types/tracefunnel/tracefunnel.go
Normal file
@@ -0,0 +1,113 @@
|
||||
package traceFunnels
|
||||
|
||||
import (
|
||||
v3 "github.com/SigNoz/signoz/pkg/query-service/model/v3"
|
||||
"github.com/SigNoz/signoz/pkg/types"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
"github.com/uptrace/bun"
|
||||
)
|
||||
|
||||
// metadata for funnels
|
||||
|
||||
type BaseMetadata struct {
|
||||
types.Identifiable // funnel id
|
||||
types.TimeAuditable
|
||||
types.UserAuditable
|
||||
Name string `json:"funnel_name" bun:"name,type:text,notnull"` // funnel name
|
||||
Description string `json:"description" bun:"description,type:text"` // funnel description
|
||||
OrgID valuer.UUID `json:"org_id" bun:"org_id,type:varchar,notnull"`
|
||||
}
|
||||
|
||||
// Funnel Core Data Structure (Funnel and FunnelStep)
|
||||
type Funnel struct {
|
||||
bun.BaseModel `bun:"table:trace_funnel"`
|
||||
BaseMetadata
|
||||
Steps []FunnelStep `json:"steps" bun:"steps,type:text,notnull"`
|
||||
Tags string `json:"tags" bun:"tags,type:text"`
|
||||
CreatedByUser *types.User `json:"user" bun:"rel:belongs-to,join:created_by=id"`
|
||||
}
|
||||
|
||||
type FunnelStep struct {
|
||||
Id valuer.UUID `json:"id,omitempty"`
|
||||
Name string `json:"name,omitempty"` // step name
|
||||
Description string `json:"description,omitempty"` // step description
|
||||
Order int64 `json:"step_order"`
|
||||
ServiceName string `json:"service_name"`
|
||||
SpanName string `json:"span_name"`
|
||||
Filters *v3.FilterSet `json:"filters,omitempty"`
|
||||
LatencyPointer string `json:"latency_pointer,omitempty"`
|
||||
LatencyType string `json:"latency_type,omitempty"`
|
||||
HasErrors bool `json:"has_errors"`
|
||||
}
|
||||
|
||||
// FunnelRequest represents all possible funnel-related requests
|
||||
type FunnelRequest struct {
|
||||
FunnelID valuer.UUID `json:"funnel_id,omitempty"`
|
||||
Name string `json:"funnel_name,omitempty"`
|
||||
Timestamp int64 `json:"timestamp,omitempty"`
|
||||
Description string `json:"description,omitempty"`
|
||||
Steps []FunnelStep `json:"steps,omitempty"`
|
||||
UserID string `json:"user_id,omitempty"`
|
||||
|
||||
// Analytics specific fields
|
||||
StartTime int64 `json:"start_time,omitempty"`
|
||||
EndTime int64 `json:"end_time,omitempty"`
|
||||
StepAOrder int64 `json:"step_a_order,omitempty"`
|
||||
StepBOrder int64 `json:"step_b_order,omitempty"`
|
||||
}
|
||||
|
||||
// FunnelResponse represents all possible funnel-related responses
|
||||
type FunnelResponse struct {
|
||||
FunnelID string `json:"funnel_id,omitempty"`
|
||||
FunnelName string `json:"funnel_name,omitempty"`
|
||||
Description string `json:"description,omitempty"`
|
||||
CreatedAt int64 `json:"created_at,omitempty"`
|
||||
CreatedBy string `json:"created_by,omitempty"`
|
||||
UpdatedAt int64 `json:"updated_at,omitempty"`
|
||||
UpdatedBy string `json:"updated_by,omitempty"`
|
||||
OrgID string `json:"org_id,omitempty"`
|
||||
UserEmail string `json:"user_email,omitempty"`
|
||||
Funnel *Funnel `json:"funnel,omitempty"`
|
||||
Steps []FunnelStep `json:"steps,omitempty"`
|
||||
}
|
||||
|
||||
// TimeRange represents a time range for analytics
|
||||
type TimeRange struct {
|
||||
StartTime int64 `json:"start_time"`
|
||||
EndTime int64 `json:"end_time"`
|
||||
}
|
||||
|
||||
// StepTransitionRequest represents a request for step transition analytics
|
||||
type StepTransitionRequest struct {
|
||||
TimeRange
|
||||
StepAOrder int64 `json:"step_a_order"`
|
||||
StepBOrder int64 `json:"step_b_order"`
|
||||
}
|
||||
|
||||
// UserInfo represents basic user information
|
||||
type UserInfo struct {
|
||||
ID string `json:"id"`
|
||||
Email string `json:"email"`
|
||||
}
|
||||
|
||||
// Analytics on traces
|
||||
//type FunnelAnalytics struct {
|
||||
// TotalStart int64 `json:"total_start"`
|
||||
// TotalComplete int64 `json:"total_complete"`
|
||||
// ErrorCount int64 `json:"error_count"`
|
||||
// AvgDurationMs float64 `json:"avg_duration_ms"`
|
||||
// P99LatencyMs float64 `json:"p99_latency_ms"`
|
||||
// ConversionRate float64 `json:"conversion_rate"`
|
||||
//}
|
||||
|
||||
//type ValidTracesResponse struct {
|
||||
// TraceIDs []string `json:"trace_ids"`
|
||||
//}
|
||||
|
||||
type FunnelStepFilter struct {
|
||||
StepNumber int
|
||||
ServiceName string
|
||||
SpanName string
|
||||
LatencyPointer string // "start" or "end"
|
||||
CustomFilters *v3.FilterSet
|
||||
}
|
||||
Reference in New Issue
Block a user