Compare commits

..

1 Commits

Author SHA1 Message Date
Shivanshu Raj Shrivastava
749532a038 feat: trace funnels APIs
Signed-off-by: Shivanshu Raj Shrivastava <shivanshu1333@gmail.com>
2025-04-28 13:26:31 +05:30
76 changed files with 2384 additions and 4711 deletions

42
.github/workflows/README.md vendored Normal file
View File

@@ -0,0 +1,42 @@
# 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 Normal file
View File

@@ -0,0 +1,16 @@
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 }}

View File

@@ -103,13 +103,6 @@ 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:

View File

@@ -13,6 +13,9 @@ 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"
@@ -23,12 +26,14 @@ 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
@@ -55,8 +60,13 @@ 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,
@@ -71,6 +81,9 @@ 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 {

View File

@@ -134,7 +134,7 @@ func (ah *APIHandler) registerUser(w http.ResponseWriter, r *http.Request) {
return
}
_, registerError := baseauth.Register(ctx, req, ah.Signoz.Alertmanager, ah.Signoz.Modules.Organization)
_, registerError := baseauth.Register(ctx, req, ah.Signoz.Alertmanager, ah.OrganizationModule)
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.Signoz.Modules.Organization)
inviteObject, err := baseauth.GetInvite(r.Context(), token, ah.OrganizationModule)
if err != nil {
RespondError(w, model.BadRequest(err), nil)
return

View File

@@ -45,17 +45,25 @@ 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
HTTPHostPort string
PrivateHostPort string
Config signoz.Config
SigNoz *signoz.SigNoz
PromConfigPath string
SkipTopLvlOpsPath string
HTTPHostPort string
PrivateHostPort string
// alert specific params
DisableRules bool
RuleRepoURL string
PreferSpanMetrics bool
CacheConfigPath string
FluxInterval string
@@ -138,6 +146,14 @@ 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)
@@ -148,9 +164,11 @@ 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,
@@ -220,6 +238,7 @@ func NewServer(serverOptions *ServerOptions) (*Server, error) {
apiOpts := api.APIHandlerOptions{
DataConnector: reader,
SkipConfig: skipConfig,
PreferSpanMetrics: serverOptions.PreferSpanMetrics,
AppDao: modelDao,
RulesManager: rm,
@@ -337,6 +356,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{"*"},
@@ -392,7 +412,13 @@ func (s *Server) initListeners() error {
// Start listening on http and private http port concurrently
func (s *Server) Start(ctx context.Context) error {
s.ruleManager.Start(ctx)
// 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")
}
err := s.initListeners()
if err != nil {
@@ -483,9 +509,11 @@ 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,
@@ -497,9 +525,11 @@ 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(),

View File

@@ -52,27 +52,19 @@ 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()
@@ -129,8 +121,12 @@ 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,

View File

@@ -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, "old column: %s doesn't exist", oldColumnName)
return false, errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, fmt.Sprintf("old column: %s doesn't exist", oldColumnName))
}
_, err = bun.

View File

@@ -4,10 +4,8 @@ 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"
@@ -89,20 +87,3 @@ 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
}

View File

@@ -43,7 +43,6 @@
"@radix-ui/react-tooltip": "1.0.7",
"@sentry/react": "8.41.0",
"@sentry/webpack-plugin": "2.22.6",
"@signozhq/button": "0.0.0",
"@signozhq/design-tokens": "1.1.4",
"@tanstack/react-table": "8.20.6",
"@tanstack/react-virtual": "3.11.2",
@@ -56,7 +55,7 @@
"ansi-to-html": "0.7.2",
"antd": "5.11.0",
"antd-table-saveas-excel": "2.2.1",
"axios": "1.8.2",
"axios": "1.7.7",
"babel-eslint": "^10.1.0",
"babel-jest": "^29.6.4",
"babel-loader": "9.1.3",

View File

@@ -1,13 +0,0 @@
.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;
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,606 +0,0 @@
/* 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;

View File

@@ -1,263 +0,0 @@
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();
});
});

View File

@@ -1,206 +0,0 @@
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();
});
});

View File

@@ -1,4 +0,0 @@
import CustomMultiSelect from './CustomMultiSelect';
import CustomSelect from './CustomSelect';
export { CustomMultiSelect, CustomSelect };

View File

@@ -1,838 +0,0 @@
// 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);
}
}
}
}

View File

@@ -1,60 +0,0 @@
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;
}

View File

@@ -1,135 +0,0 @@
/* 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[];
};

View File

@@ -1,7 +1,6 @@
/* eslint-disable sonarjs/no-duplicate-string */
import './Home.styles.scss';
import { Button as SignozButton } from '@signozhq/button';
import { Color } from '@signozhq/design-tokens';
import { Alert, Button, Popover } from 'antd';
import logEvent from 'api/common/logEvent';
@@ -306,9 +305,6 @@ export default function Home(): JSX.Element {
}
rightComponent={
<div className="home-header-right">
<SignozButton variant="solid" size="lg" color="primary">
Button
</SignozButton>
{isWelcomeChecklistSkipped && (
<Popover
placement="bottomRight"

View File

@@ -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, Tooltip, Typography } from 'antd';
import { Button, Flex, Form, Input, Typography } from 'antd';
import getAll from 'api/alerts/getAll';
import { useDeleteDowntimeSchedule } from 'api/plannedDowntime/deleteDowntimeSchedule';
import {
@@ -13,10 +13,8 @@ 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';
@@ -35,7 +33,6 @@ 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 }>
@@ -111,27 +108,18 @@ export function PlannedDowntime(): JSX.Element {
value={searchValue}
onChange={handleSearch}
/>
<Tooltip
title={
user?.role === USER_ROLES.VIEWER
? 'You need edit permissions to create a planned downtime'
: ''
}
<Button
icon={<PlusOutlined />}
type="primary"
onClick={(): void => {
setInitialValues({ ...defautlInitialValues, editMode: false });
setIsOpen(true);
setEditMode(false);
form.resetFields();
}}
>
<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>
New downtime
</Button>
</Flex>
<br />
<PlannedDowntimeList

View File

@@ -1,44 +0,0 @@
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');
});
});

View File

@@ -3214,11 +3214,6 @@
dependencies:
"@babel/runtime" "^7.13.10"
"@radix-ui/react-compose-refs@1.1.2":
version "1.1.2"
resolved "https://registry.yarnpkg.com/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.2.tgz#a2c4c47af6337048ee78ff6dc0d090b390d2bb30"
integrity sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg==
"@radix-ui/react-context@1.0.1":
version "1.0.1"
resolved "https://registry.yarnpkg.com/@radix-ui/react-context/-/react-context-1.0.1.tgz#fe46e67c96b240de59187dcb7a1a50ce3e2ec00c"
@@ -3245,11 +3240,6 @@
"@radix-ui/react-use-callback-ref" "1.0.1"
"@radix-ui/react-use-escape-keydown" "1.0.3"
"@radix-ui/react-icons@^1.3.0":
version "1.3.2"
resolved "https://registry.yarnpkg.com/@radix-ui/react-icons/-/react-icons-1.3.2.tgz#09be63d178262181aeca5fb7f7bc944b10a7f441"
integrity sha512-fyQIhGDhzfc9pK2kH6Pl9c4BDJGfMkPqkyIgYDthyNYoNg3wVhoJMMh19WS4Up/1KMPFVpNsT2q3WmXn2N1m6g==
"@radix-ui/react-id@1.0.1":
version "1.0.1"
resolved "https://registry.yarnpkg.com/@radix-ui/react-id/-/react-id-1.0.1.tgz#73cdc181f650e4df24f0b6a5b7aa426b912c88c0"
@@ -3324,13 +3314,6 @@
"@babel/runtime" "^7.13.10"
"@radix-ui/react-compose-refs" "1.0.1"
"@radix-ui/react-slot@^1.1.0":
version "1.2.0"
resolved "https://registry.yarnpkg.com/@radix-ui/react-slot/-/react-slot-1.2.0.tgz#57727fc186ddb40724ccfbe294e1a351d92462ba"
integrity sha512-ujc+V6r0HNDviYqIK3rW4ffgYiZ8g5DEHrGJVk4x7kTlLXRDILnKX9vAUYeIsLOoDpDJ0ujpqMkjH4w2ofuo6w==
dependencies:
"@radix-ui/react-compose-refs" "1.1.2"
"@radix-ui/react-tabs@1.0.4":
version "1.0.4"
resolved "https://registry.yarnpkg.com/@radix-ui/react-tabs/-/react-tabs-1.0.4.tgz#993608eec55a5d1deddd446fa9978d2bc1053da2"
@@ -3671,20 +3654,6 @@
unplugin "1.0.1"
uuid "^9.0.0"
"@signozhq/button@0.0.0":
version "0.0.0"
resolved "https://registry.yarnpkg.com/@signozhq/button/-/button-0.0.0.tgz#802b1287acc2f6a09bf7fb05d9f4908a32a06373"
integrity sha512-U+IYj0eGqCxwofhE7Wn5Am5puGh8GQr8xDJGEy1lPPc58lOsE6w9e/bNW2efvATorTG/1FrizxaVz/x6eBpACQ==
dependencies:
"@radix-ui/react-icons" "^1.3.0"
"@radix-ui/react-slot" "^1.1.0"
class-variance-authority "^0.7.0"
clsx "^2.1.1"
lucide-react "^0.445.0"
react "^18.2.0"
tailwind-merge "^2.5.2"
tailwindcss-animate "^1.0.7"
"@signozhq/design-tokens@1.1.4":
version "1.1.4"
resolved "https://registry.yarnpkg.com/@signozhq/design-tokens/-/design-tokens-1.1.4.tgz#5d5de5bd9d19b6a3631383db015cc4b70c3f7661"
@@ -5553,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.8.2:
version "1.8.2"
resolved "https://registry.yarnpkg.com/axios/-/axios-1.8.2.tgz#fabe06e241dfe83071d4edfbcaa7b1c3a40f7979"
integrity sha512-ls4GYBm5aig9vWx8AWDSGLpnpDQRtWAfrjU+EuytuODrFBkqesN2RkOQCBzrA1RQNHw1SmRMSDDDSwzNAYQ6Rg==
axios@1.7.7:
version "1.7.7"
resolved "https://registry.yarnpkg.com/axios/-/axios-1.7.7.tgz#2f554296f9892a72ac8d8e4c5b79c14a91d0a47f"
integrity sha512-S4kL7XrjgBmvdGut0sN3yJxqYzrDOnivkBiN0OFs6hLiUam3UPvswUo0kqGyhqUZGEOytHyumEdXsAkgCOUf3Q==
dependencies:
follow-redirects "^1.15.6"
form-data "^4.0.0"
@@ -6526,13 +6495,6 @@ cjs-module-lexer@^1.0.0:
resolved "https://registry.npmjs.org/cjs-module-lexer/-/cjs-module-lexer-1.2.2.tgz"
integrity sha512-cOU9usZw8/dXIXKtwa8pM0OTJQuJkxMN6w30csNRUerHfeQ5R6U3kkU/FtJeIf3M202OHfY2U8ccInBG7/xogA==
class-variance-authority@^0.7.0:
version "0.7.1"
resolved "https://registry.yarnpkg.com/class-variance-authority/-/class-variance-authority-0.7.1.tgz#4008a798a0e4553a781a57ac5177c9fb5d043787"
integrity sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==
dependencies:
clsx "^2.1.1"
classnames@2.3.2, classnames@2.x, classnames@^2.2.1, classnames@^2.2.3, classnames@^2.2.5, classnames@^2.2.6, classnames@^2.3.1, classnames@^2.3.2:
version "2.3.2"
resolved "https://registry.npmjs.org/classnames/-/classnames-2.3.2.tgz"
@@ -6627,11 +6589,6 @@ clsx@^1.1.1:
resolved "https://registry.npmjs.org/clsx/-/clsx-1.2.1.tgz"
integrity sha512-EcR6r5a8bj6pu3ycsa/E/cKVGuTgZJZdsyUYHOksG/UHIiKfjxzRxYJpyVBwYaQeOvghal9fcc4PidlgzugAQg==
clsx@^2.1.1:
version "2.1.1"
resolved "https://registry.yarnpkg.com/clsx/-/clsx-2.1.1.tgz#eed397c9fd8bd882bfb18deab7102049a2f32999"
integrity sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==
co@^4.6.0:
version "4.6.0"
resolved "https://registry.npmjs.org/co/-/co-4.6.0.tgz"
@@ -11926,11 +11883,6 @@ lucide-react@0.427.0:
resolved "https://registry.yarnpkg.com/lucide-react/-/lucide-react-0.427.0.tgz#e06974514bbd591049f9d736b3d3ae99d4ede8c9"
integrity sha512-lv9s6c5BDF/ccuA0EgTdskTxIe11qpwBDmzRZHJAKtp8LTewAvDvOM+pTES9IpbBuTqkjiMhOmGpJ/CB+mKjFw==
lucide-react@^0.445.0:
version "0.445.0"
resolved "https://registry.yarnpkg.com/lucide-react/-/lucide-react-0.445.0.tgz#35c42341e98fbf0475b2a6cf74fd25ef7cbfcd62"
integrity sha512-YrLf3aAHvmd4dZ8ot+mMdNFrFpJD7YRwQ2pUcBhgqbmxtrMP4xDzIorcj+8y+6kpuXBF4JB0NOCTUWIYetJjgA==
lz-string@^1.4.4:
version "1.5.0"
resolved "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz"
@@ -15208,13 +15160,6 @@ react@18.2.0:
dependencies:
loose-envify "^1.1.0"
react@^18.2.0:
version "18.3.1"
resolved "https://registry.yarnpkg.com/react/-/react-18.3.1.tgz#49ab892009c53933625bd16b2533fc754cab2891"
integrity sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==
dependencies:
loose-envify "^1.1.0"
read-pkg-up@^7.0.1:
version "7.0.1"
resolved "https://registry.npmjs.org/read-pkg-up/-/read-pkg-up-7.0.1.tgz"
@@ -16710,16 +16655,6 @@ table@^6.0.9:
string-width "^4.2.3"
strip-ansi "^6.0.1"
tailwind-merge@^2.5.2:
version "2.6.0"
resolved "https://registry.yarnpkg.com/tailwind-merge/-/tailwind-merge-2.6.0.tgz#ac5fb7e227910c038d458f396b7400d93a3142d5"
integrity sha512-P+Vu1qXfzediirmHOC3xKGAYeZtPcV9g76X+xg2FD4tYgR71ewMA35Y3sCz3zhiN/dwefRpJX0yBcgwi1fXNQA==
tailwindcss-animate@^1.0.7:
version "1.0.7"
resolved "https://registry.yarnpkg.com/tailwindcss-animate/-/tailwindcss-animate-1.0.7.tgz#318b692c4c42676cc9e67b19b78775742388bef4"
integrity sha512-bl6mpH3T7I3UFxuvDEXLxy/VuFxBk5bbzplh7tXI68mwMokNYd1t9qPBHlnyTwfa4JGC4zP516I1hYYtQ/vspA==
tapable@^2.0.0, tapable@^2.1.1, tapable@^2.2.0:
version "2.2.1"
resolved "https://registry.npmjs.org/tapable/-/tapable-2.2.1.tgz"

View File

@@ -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
}

View File

@@ -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,7 +137,3 @@ 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)
}

View File

@@ -1,10 +1,8 @@
package implorganization
import (
"context"
"encoding/json"
"net/http"
"time"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/http/render"
@@ -14,19 +12,16 @@ import (
"github.com/SigNoz/signoz/pkg/valuer"
)
type handler struct {
type organizationAPI struct {
module organization.Module
}
func NewHandler(module organization.Module) organization.Handler {
return &handler{module: module}
func NewAPI(module organization.Module) organization.API {
return &organizationAPI{module: module}
}
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)
func (api *organizationAPI) Get(rw http.ResponseWriter, r *http.Request) {
claims, err := authtypes.ClaimsFromContext(r.Context())
if err != nil {
render.Error(rw, err)
return
@@ -34,11 +29,11 @@ func (handler *handler) 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, "orgId is invalid"))
render.Error(rw, errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "invalid org id"))
return
}
organization, err := handler.module.Get(ctx, orgID)
organization, err := api.module.Get(r.Context(), orgID)
if err != nil {
render.Error(rw, err)
return
@@ -47,11 +42,18 @@ func (handler *handler) Get(rw http.ResponseWriter, r *http.Request) {
render.Success(rw, http.StatusOK, organization)
}
func (handler *handler) Update(rw http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
defer cancel()
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
}
claims, err := authtypes.ClaimsFromContext(ctx)
render.Success(rw, http.StatusOK, organizations)
}
func (api *organizationAPI) Update(rw http.ResponseWriter, r *http.Request) {
claims, err := authtypes.ClaimsFromContext(r.Context())
if err != nil {
render.Error(rw, err)
return
@@ -70,7 +72,7 @@ func (handler *handler) Update(rw http.ResponseWriter, r *http.Request) {
}
req.ID = orgID
err = handler.module.Update(ctx, req)
err = api.module.Update(r.Context(), req)
if err != nil {
render.Error(rw, err)
return

View File

@@ -8,26 +8,26 @@ import (
"github.com/SigNoz/signoz/pkg/valuer"
)
type module struct {
type organizationModule struct {
store types.OrganizationStore
}
func NewModule(organizationStore types.OrganizationStore) organization.Module {
return &module{store: organizationStore}
return &organizationModule{store: organizationStore}
}
func (module *module) Create(ctx context.Context, organization *types.Organization) error {
return module.store.Create(ctx, organization)
func (o *organizationModule) Create(ctx context.Context, organization *types.Organization) error {
return o.store.Create(ctx, organization)
}
func (module *module) Get(ctx context.Context, id valuer.UUID) (*types.Organization, error) {
return module.store.Get(ctx, id)
func (o *organizationModule) Get(ctx context.Context, id valuer.UUID) (*types.Organization, error) {
return o.store.Get(ctx, id)
}
func (module *module) GetAll(ctx context.Context) ([]*types.Organization, error) {
return module.store.GetAll(ctx)
func (o *organizationModule) GetAll(ctx context.Context) ([]*types.Organization, error) {
return o.store.GetAll(ctx)
}
func (module *module) Update(ctx context.Context, updatedOrganization *types.Organization) error {
return module.store.Update(ctx, updatedOrganization)
func (o *organizationModule) Update(ctx context.Context, updatedOrganization *types.Organization) error {
return o.store.Update(ctx, updatedOrganization)
}

View File

@@ -2,69 +2,77 @@ 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 {
sqlstore sqlstore.SQLStore
store sqlstore.SQLStore
}
func NewStore(sqlstore sqlstore.SQLStore) types.OrganizationStore {
return &store{sqlstore: sqlstore}
func NewStore(db sqlstore.SQLStore) types.OrganizationStore {
return &store{store: db}
}
func (store *store) Create(ctx context.Context, organization *types.Organization) error {
_, err := store.
sqlstore.
func (s *store) Create(ctx context.Context, organization *types.Organization) error {
_, err := s.
store.
BunDB().
NewInsert().
Model(organization).
Exec(ctx)
if err != nil {
return store.sqlstore.WrapAlreadyExistsErrf(err, types.ErrOrganizationAlreadyExists, "organization with name: %s already exists", organization.Name)
return errors.Wrapf(err, errors.TypeInternal, errors.CodeInternal, "failed to create organization")
}
return nil
}
func (store *store) Get(ctx context.Context, id valuer.UUID) (*types.Organization, error) {
func (s *store) Get(ctx context.Context, id valuer.UUID) (*types.Organization, error) {
organization := new(types.Organization)
err := store.
sqlstore.
err := s.
store.
BunDB().
NewSelect().
Model(organization).
Where("id = ?", id.StringValue()).
Scan(ctx)
if err != nil {
return nil, store.sqlstore.WrapNotFoundErrf(err, types.ErrOrganizationNotFound, "organization with id: %s does not exist", id.StringValue())
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 organization, nil
}
func (store *store) GetAll(ctx context.Context) ([]*types.Organization, error) {
func (s *store) GetAll(ctx context.Context) ([]*types.Organization, error) {
organizations := make([]*types.Organization, 0)
err := store.
sqlstore.
err := s.
store.
BunDB().
NewSelect().
Model(&organizations).
Scan(ctx)
if err != nil {
return nil, err
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 organizations, nil
}
func (store *store) Update(ctx context.Context, organization *types.Organization) error {
_, err := store.
sqlstore.
func (s *store) Update(ctx context.Context, organization *types.Organization) error {
_, err := s.
store.
BunDB().
NewUpdate().
Model(organization).
@@ -73,21 +81,21 @@ func (store *store) Update(ctx context.Context, organization *types.Organization
Where("id = ?", organization.ID.StringValue()).
Exec(ctx)
if err != nil {
return store.sqlstore.WrapAlreadyExistsErrf(err, types.ErrOrganizationAlreadyExists, "organization already exists")
return errors.Wrapf(err, errors.TypeInternal, errors.CodeInternal, "failed to update organization with id: %s", organization.ID.StringValue())
}
return nil
}
func (store *store) Delete(ctx context.Context, id valuer.UUID) error {
_, err := store.
sqlstore.
func (s *store) Delete(ctx context.Context, id valuer.UUID) error {
_, err := s.
store.
BunDB().
NewDelete().
Model(new(types.Organization)).
Where("id = ?", id.StringValue()).
Exec(ctx)
if err != nil {
return err
return errors.Wrapf(err, errors.TypeInternal, errors.CodeInternal, "failed to delete organization with id: %s", id.StringValue())
}
return nil

View File

@@ -22,10 +22,13 @@ type Module interface {
Update(context.Context, *types.Organization) error
}
type Handler interface {
type API 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)
}

View File

@@ -0,0 +1,147 @@
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)
}

View File

@@ -1,9 +1,10 @@
package implpreference
package core
import (
"context"
"database/sql"
"encoding/json"
"fmt"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/modules/preference"
@@ -11,28 +12,27 @@ import (
"github.com/SigNoz/signoz/pkg/valuer"
)
// 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
type usecase struct {
store preferencetypes.PreferenceStore
defaultMap map[string]preferencetypes.Preference
}
func NewModule(store preferencetypes.Store, defaultMap map[string]preferencetypes.Preference) preference.Module {
return &module{store: store, defaultMap: defaultMap}
func NewPreference(store preferencetypes.PreferenceStore, defaultMap map[string]preferencetypes.Preference) preference.Usecase {
return &usecase{store: store, defaultMap: defaultMap}
}
func (module *module) GetOrg(ctx context.Context, preferenceID string, orgID string) (*preferencetypes.GettablePreference, error) {
preference, seen := module.defaultMap[preferenceID]
func (usecase *usecase) GetOrgPreference(ctx context.Context, preferenceID string, orgID string) (*preferencetypes.GettablePreference, error) {
preference, seen := usecase.defaultMap[preferenceID]
if !seen {
return nil, errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "cannot find preference with id: %s", preferenceID)
return nil, errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, fmt.Sprintf("no such preferenceID exists: %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)
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))
}
org, err := module.store.GetOrg(ctx, orgID, preferenceID)
orgPreference, err := usecase.store.GetOrgPreference(ctx, orgID, preferenceID)
if err != nil {
if err == sql.ErrNoRows {
return &preferencetypes.GettablePreference{
@@ -40,24 +40,24 @@ func (module *module) GetOrg(ctx context.Context, preferenceID string, orgID str
PreferenceValue: preference.DefaultValue,
}, nil
}
return nil, errors.Wrapf(err, errors.TypeInternal, errors.CodeInternal, "error in fetching the org preference: %s", preferenceID)
return nil, errors.Wrapf(err, errors.TypeInternal, errors.CodeInternal, fmt.Sprintf("error in fetching the org preference: %s", preferenceID))
}
return &preferencetypes.GettablePreference{
PreferenceID: preferenceID,
PreferenceValue: preference.SanitizeValue(org.PreferenceValue),
PreferenceValue: preference.SanitizeValue(orgPreference.PreferenceValue),
}, nil
}
func (module *module) UpdateOrg(ctx context.Context, preferenceID string, preferenceValue interface{}, orgID string) error {
preference, seen := module.defaultMap[preferenceID]
func (usecase *usecase) UpdateOrgPreference(ctx context.Context, preferenceID string, preferenceValue interface{}, orgID string) error {
preference, seen := usecase.defaultMap[preferenceID]
if !seen {
return errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "cannot find preference with id: %s", preferenceID)
return errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, fmt.Sprintf("no such preferenceID exists: %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)
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))
}
err := preference.IsValidValue(preferenceValue)
@@ -65,26 +65,26 @@ func (module *module) UpdateOrg(ctx context.Context, preferenceID string, prefer
return err
}
storableValue, encodeErr := json.Marshal(preferenceValue)
storablePreferenceValue, encodeErr := json.Marshal(preferenceValue)
if encodeErr != nil {
return errors.Wrapf(encodeErr, errors.TypeInvalidInput, errors.CodeInvalidInput, "error in encoding the preference value")
}
org, dberr := module.store.GetOrg(ctx, orgID, preferenceID)
orgPreference, dberr := usecase.store.GetOrgPreference(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 {
org.ID = valuer.GenerateUUID()
org.PreferenceID = preferenceID
org.PreferenceValue = string(storableValue)
org.OrgID = orgID
orgPreference.ID = valuer.GenerateUUID()
orgPreference.PreferenceID = preferenceID
orgPreference.PreferenceValue = string(storablePreferenceValue)
orgPreference.OrgID = orgID
} else {
org.PreferenceValue = string(storableValue)
orgPreference.PreferenceValue = string(storablePreferenceValue)
}
dberr = module.store.UpsertOrg(ctx, org)
dberr = usecase.store.UpsertOrgPreference(ctx, orgPreference)
if dberr != nil {
return errors.Wrapf(dberr, errors.TypeInternal, errors.CodeInternal, "error in setting the preference value")
}
@@ -92,19 +92,19 @@ func (module *module) UpdateOrg(ctx context.Context, preferenceID string, prefer
return nil
}
func (module *module) GetAllOrg(ctx context.Context, orgID string) ([]*preferencetypes.PreferenceWithValue, error) {
allOrgs := []*preferencetypes.PreferenceWithValue{}
orgs, err := module.store.GetAllOrg(ctx, orgID)
func (usecase *usecase) GetAllOrgPreferences(ctx context.Context, orgID string) ([]*preferencetypes.PreferenceWithValue, error) {
allOrgPreferences := []*preferencetypes.PreferenceWithValue{}
orgPreferences, err := usecase.store.GetAllOrgPreferences(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 orgs {
for _, preferenceValue := range orgPreferences {
preferenceValueMap[preferenceValue.PreferenceID] = preferenceValue.PreferenceValue
}
for _, preference := range module.defaultMap {
for _, preference := range usecase.defaultMap {
isEnabledForOrgScope := preference.IsEnabledForScope(preferencetypes.OrgAllowedScope)
if isEnabledForOrgScope {
preferenceWithValue := &preferencetypes.PreferenceWithValue{}
@@ -126,16 +126,16 @@ func (module *module) GetAllOrg(ctx context.Context, orgID string) ([]*preferenc
}
preferenceWithValue.Value = preference.SanitizeValue(preferenceWithValue.Value)
allOrgs = append(allOrgs, preferenceWithValue)
allOrgPreferences = append(allOrgPreferences, preferenceWithValue)
}
}
return allOrgs, nil
return allOrgPreferences, nil
}
func (module *module) GetUser(ctx context.Context, preferenceID string, orgID string, userID string) (*preferencetypes.GettablePreference, error) {
preference, seen := module.defaultMap[preferenceID]
func (usecase *usecase) GetUserPreference(ctx context.Context, preferenceID string, orgID string, userID string) (*preferencetypes.GettablePreference, error) {
preference, seen := usecase.defaultMap[preferenceID]
if !seen {
return nil, errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "cannot find preference with id: %s", preferenceID)
return nil, errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, fmt.Sprintf("no such preferenceID exists: %s", preferenceID))
}
preferenceValue := preferencetypes.GettablePreference{
@@ -143,29 +143,29 @@ func (module *module) GetUser(ctx context.Context, preferenceID string, orgID st
PreferenceValue: preference.DefaultValue,
}
isEnabledAtUserScope := preference.IsEnabledForScope(preferencetypes.UserAllowedScope)
if !isEnabledAtUserScope {
return nil, errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "preference is not enabled at user scope: %s", preferenceID)
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))
}
isEnabledAtOrgScope := preference.IsEnabledForScope(preferencetypes.OrgAllowedScope)
if isEnabledAtOrgScope {
org, err := module.store.GetOrg(ctx, orgID, preferenceID)
isPreferenceEnabledAtOrgScope := preference.IsEnabledForScope(preferencetypes.OrgAllowedScope)
if isPreferenceEnabledAtOrgScope {
orgPreference, err := usecase.store.GetOrgPreference(ctx, orgID, preferenceID)
if err != nil && err != sql.ErrNoRows {
return nil, errors.Wrapf(err, errors.TypeInternal, errors.CodeInternal, "error in fetching the org preference: %s", preferenceID)
return nil, errors.Wrapf(err, errors.TypeInternal, errors.CodeInternal, fmt.Sprintf("error in fetching the org preference: %s", preferenceID))
}
if err == nil {
preferenceValue.PreferenceValue = org.PreferenceValue
preferenceValue.PreferenceValue = orgPreference.PreferenceValue
}
}
user, err := module.store.GetUser(ctx, userID, preferenceID)
userPreference, err := usecase.store.GetUserPreference(ctx, userID, preferenceID)
if err != nil && err != sql.ErrNoRows {
return nil, errors.Wrapf(err, errors.TypeInternal, errors.CodeInternal, "error in fetching the user preference: %s", preferenceID)
return nil, errors.Wrapf(err, errors.TypeInternal, errors.CodeInternal, fmt.Sprintf("error in fetching the user preference: %s", preferenceID))
}
if err == nil {
preferenceValue.PreferenceValue = user.PreferenceValue
preferenceValue.PreferenceValue = userPreference.PreferenceValue
}
return &preferencetypes.GettablePreference{
@@ -174,15 +174,15 @@ func (module *module) GetUser(ctx context.Context, preferenceID string, orgID st
}, nil
}
func (module *module) UpdateUser(ctx context.Context, preferenceID string, preferenceValue interface{}, userID string) error {
preference, seen := module.defaultMap[preferenceID]
func (usecase *usecase) UpdateUserPreference(ctx context.Context, preferenceID string, preferenceValue interface{}, userID string) error {
preference, seen := usecase.defaultMap[preferenceID]
if !seen {
return errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "cannot find preference with id: %s", preferenceID)
return errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, fmt.Sprintf("no such preferenceID exists: %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)
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))
}
err := preference.IsValidValue(preferenceValue)
@@ -190,26 +190,26 @@ func (module *module) UpdateUser(ctx context.Context, preferenceID string, prefe
return err
}
storableValue, encodeErr := json.Marshal(preferenceValue)
storablePreferenceValue, encodeErr := json.Marshal(preferenceValue)
if encodeErr != nil {
return errors.Wrapf(encodeErr, errors.TypeInvalidInput, errors.CodeInvalidInput, "error in encoding the preference value")
}
user, dberr := module.store.GetUser(ctx, userID, preferenceID)
userPreference, dberr := usecase.store.GetUserPreference(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 {
user.ID = valuer.GenerateUUID()
user.PreferenceID = preferenceID
user.PreferenceValue = string(storableValue)
user.UserID = userID
userPreference.ID = valuer.GenerateUUID()
userPreference.PreferenceID = preferenceID
userPreference.PreferenceValue = string(storablePreferenceValue)
userPreference.UserID = userID
} else {
user.PreferenceValue = string(storableValue)
userPreference.PreferenceValue = string(storablePreferenceValue)
}
dberr = module.store.UpsertUser(ctx, user)
dberr = usecase.store.UpsertUserPreference(ctx, userPreference)
if dberr != nil {
return errors.Wrapf(dberr, errors.TypeInternal, errors.CodeInternal, "error in setting the preference value")
}
@@ -217,30 +217,30 @@ func (module *module) UpdateUser(ctx context.Context, preferenceID string, prefe
return nil
}
func (module *module) GetAllUser(ctx context.Context, orgID string, userID string) ([]*preferencetypes.PreferenceWithValue, error) {
allUsers := []*preferencetypes.PreferenceWithValue{}
func (usecase *usecase) GetAllUserPreferences(ctx context.Context, orgID string, userID string) ([]*preferencetypes.PreferenceWithValue, error) {
allUserPreferences := []*preferencetypes.PreferenceWithValue{}
orgs, err := module.store.GetAllOrg(ctx, orgID)
orgPreferences, err := usecase.store.GetAllOrgPreferences(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 orgs {
for _, preferenceValue := range orgPreferences {
preferenceOrgValueMap[preferenceValue.PreferenceID] = preferenceValue.PreferenceValue
}
users, err := module.store.GetAllUser(ctx, userID)
userPreferences, err := usecase.store.GetAllUserPreferences(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 users {
for _, preferenceValue := range userPreferences {
preferenceUserValueMap[preferenceValue.PreferenceID] = preferenceValue.PreferenceValue
}
for _, preference := range module.defaultMap {
for _, preference := range usecase.defaultMap {
isEnabledForUserScope := preference.IsEnabledForScope(preferencetypes.UserAllowedScope)
if isEnabledForUserScope {
@@ -271,8 +271,8 @@ func (module *module) GetAllUser(ctx context.Context, orgID string, userID strin
}
preferenceWithValue.Value = preference.SanitizeValue(preferenceWithValue.Value)
allUsers = append(allUsers, preferenceWithValue)
allUserPreferences = append(allUserPreferences, preferenceWithValue)
}
}
return allUsers, nil
return allUserPreferences, nil
}

View File

@@ -1,4 +1,4 @@
package implpreference
package core
import (
"context"
@@ -11,11 +11,11 @@ type store struct {
store sqlstore.SQLStore
}
func NewStore(db sqlstore.SQLStore) preferencetypes.Store {
func NewStore(db sqlstore.SQLStore) preferencetypes.PreferenceStore {
return &store{store: db}
}
func (store *store) GetOrg(ctx context.Context, orgID string, preferenceID string) (*preferencetypes.StorableOrgPreference, error) {
func (store *store) GetOrgPreference(ctx context.Context, orgID string, preferenceID string) (*preferencetypes.StorableOrgPreference, error) {
orgPreference := new(preferencetypes.StorableOrgPreference)
err := store.
store.
@@ -33,7 +33,7 @@ func (store *store) GetOrg(ctx context.Context, orgID string, preferenceID strin
return orgPreference, nil
}
func (store *store) GetAllOrg(ctx context.Context, orgID string) ([]*preferencetypes.StorableOrgPreference, error) {
func (store *store) GetAllOrgPreferences(ctx context.Context, orgID string) ([]*preferencetypes.StorableOrgPreference, error) {
orgPreferences := make([]*preferencetypes.StorableOrgPreference, 0)
err := store.
store.
@@ -50,7 +50,7 @@ func (store *store) GetAllOrg(ctx context.Context, orgID string) ([]*preferencet
return orgPreferences, nil
}
func (store *store) UpsertOrg(ctx context.Context, orgPreference *preferencetypes.StorableOrgPreference) error {
func (store *store) UpsertOrgPreference(ctx context.Context, orgPreference *preferencetypes.StorableOrgPreference) error {
_, err := store.
store.
BunDB().
@@ -65,7 +65,7 @@ func (store *store) UpsertOrg(ctx context.Context, orgPreference *preferencetype
return nil
}
func (store *store) GetUser(ctx context.Context, userID string, preferenceID string) (*preferencetypes.StorableUserPreference, error) {
func (store *store) GetUserPreference(ctx context.Context, userID string, preferenceID string) (*preferencetypes.StorableUserPreference, error) {
userPreference := new(preferencetypes.StorableUserPreference)
err := store.
store.
@@ -83,7 +83,7 @@ func (store *store) GetUser(ctx context.Context, userID string, preferenceID str
return userPreference, nil
}
func (store *store) GetAllUser(ctx context.Context, userID string) ([]*preferencetypes.StorableUserPreference, error) {
func (store *store) GetAllUserPreferences(ctx context.Context, userID string) ([]*preferencetypes.StorableUserPreference, error) {
userPreferences := make([]*preferencetypes.StorableUserPreference, 0)
err := store.
store.
@@ -100,7 +100,7 @@ func (store *store) GetAllUser(ctx context.Context, userID string) ([]*preferenc
return userPreferences, nil
}
func (store *store) UpsertUser(ctx context.Context, userPreference *preferencetypes.StorableUserPreference) error {
func (store *store) UpsertUserPreference(ctx context.Context, userPreference *preferencetypes.StorableUserPreference) error {
_, err := store.
store.
BunDB().

View File

@@ -1,176 +0,0 @@
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)
}

View File

@@ -1,48 +0,0 @@
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)
}

View File

@@ -0,0 +1,16 @@
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)
}

View File

@@ -0,0 +1,249 @@
package tracefunnel
import (
"context"
"fmt"
"time"
tracefunnels "github.com/SigNoz/signoz/pkg/modules/tracefunnel/core"
"github.com/SigNoz/signoz/pkg/types"
traceFunnels "github.com/SigNoz/signoz/pkg/types/tracefunnel"
"github.com/SigNoz/signoz/pkg/valuer"
)
// Api defines the interface for trace funnel operations
type Api interface {
// Funnel Management
CreateFunnel(ctx context.Context, timestamp int64, name string, userID string, orgID string) (*traceFunnels.Funnel, error)
GetFunnel(ctx context.Context, funnelID string) (*traceFunnels.Funnel, error)
UpdateFunnel(ctx context.Context, funnel *traceFunnels.Funnel, userID string) error
ListFunnels(ctx context.Context, orgID string) ([]*traceFunnels.Funnel, error)
DeleteFunnel(ctx context.Context, funnelID string) error
SaveFunnel(ctx context.Context, funnel *traceFunnels.Funnel, userID string, orgID string) error
GetFunnelMetadata(ctx context.Context, funnelID string) (int64, int64, string, error)
UpdateMetadata(ctx context.Context, funnelID valuer.UUID, name, description string, userID string) error
// Analytics
ValidateTraces(ctx context.Context, funnel *traceFunnels.Funnel, timeRange traceFunnels.TimeRange) (*traceFunnels.ValidTracesResponse, 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)
}
// tracefunnel implements the Api interface
type tracefunnel struct {
store traceFunnels.TraceFunnelStore
}
// Newtracefunnel creates a new trace funnel service
func NewAPI(store traceFunnels.TraceFunnelStore) Api {
return &tracefunnel{
store: store,
}
}
// CreateFunnel creates a new funnel
func (tf *tracefunnel) CreateFunnel(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 := tf.store.Create(ctx, funnel); err != nil {
return nil, fmt.Errorf("failed to create funnel: %v", err)
}
return funnel, nil
}
// GetFunnel gets a funnel by ID
func (tf *tracefunnel) GetFunnel(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 tf.store.Get(ctx, uuid)
}
// UpdateFunnel updates a funnel
func (tf *tracefunnel) UpdateFunnel(ctx context.Context, funnel *traceFunnels.Funnel, userID string) error {
funnel.UpdatedBy = userID
return tf.store.Update(ctx, funnel)
}
// ListFunnels lists all funnels for an organization
func (tf *tracefunnel) ListFunnels(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 := tf.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
}
// DeleteFunnel deletes a funnel
func (tf *tracefunnel) DeleteFunnel(ctx context.Context, funnelID string) error {
uuid, err := valuer.NewUUID(funnelID)
if err != nil {
return fmt.Errorf("invalid funnel ID: %v", err)
}
return tf.store.Delete(ctx, uuid)
}
// SaveFunnel saves a funnel
func (tf *tracefunnel) SaveFunnel(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 tf.store.Update(ctx, funnel)
}
// GetFunnelMetadata gets metadata for a funnel
func (tf *tracefunnel) 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 := tf.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 (tf *tracefunnel) ValidateTraces(ctx context.Context, funnel *traceFunnels.Funnel, timeRange traceFunnels.TimeRange) (*traceFunnels.ValidTracesResponse, error) {
if err := tracefunnels.ValidateFunnel(funnel); err != nil {
return nil, fmt.Errorf("invalid funnel: %v", err)
}
if err := tracefunnels.ValidateTimeRange(timeRange); err != nil {
return nil, fmt.Errorf("invalid time range: %v", err)
}
_, err := tracefunnels.ValidateTraces(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 response
return &traceFunnels.ValidTracesResponse{
TraceIDs: []string{},
}, nil
}
// GetFunnelAnalytics gets analytics for a funnel
func (tf *tracefunnel) GetFunnelAnalytics(ctx context.Context, funnel *traceFunnels.Funnel, timeRange traceFunnels.TimeRange) (*traceFunnels.FunnelAnalytics, error) {
if err := tracefunnels.ValidateFunnel(funnel); err != nil {
return nil, fmt.Errorf("invalid funnel: %v", err)
}
if err := tracefunnels.ValidateTimeRange(timeRange); err != nil {
return nil, fmt.Errorf("invalid time range: %v", err)
}
_, err := tracefunnels.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 (tf *tracefunnel) GetStepAnalytics(ctx context.Context, funnel *traceFunnels.Funnel, timeRange traceFunnels.TimeRange) (*traceFunnels.FunnelAnalytics, error) {
if err := tracefunnels.ValidateFunnel(funnel); err != nil {
return nil, fmt.Errorf("invalid funnel: %v", err)
}
if err := tracefunnels.ValidateTimeRange(timeRange); err != nil {
return nil, fmt.Errorf("invalid time range: %v", err)
}
_, err := tracefunnels.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 (tf *tracefunnel) GetSlowestTraces(ctx context.Context, funnel *traceFunnels.Funnel, stepAOrder, stepBOrder int64, timeRange traceFunnels.TimeRange, isError bool) (*traceFunnels.ValidTracesResponse, error) {
if err := tracefunnels.ValidateFunnel(funnel); err != nil {
return nil, fmt.Errorf("invalid funnel: %v", err)
}
if err := tracefunnels.ValidateTimeRange(timeRange); err != nil {
return nil, fmt.Errorf("invalid time range: %v", err)
}
_, err := tracefunnels.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 (tf *tracefunnel) UpdateMetadata(ctx context.Context, funnelID valuer.UUID, name, description string, userID string) error {
return tf.store.UpdateMetadata(ctx, funnelID, name, description, userID)
}

View File

@@ -0,0 +1,247 @@
package tracefunnels
import (
"fmt"
"strings"
v3 "github.com/SigNoz/signoz/pkg/query-service/model/v3"
traceFunnels "github.com/SigNoz/signoz/pkg/types/tracefunnel"
)
// QueryBuilder helps build ClickHouse queries for funnel analysis
type QueryBuilder struct {
steps []traceFunnels.FunnelStep
timeRange traceFunnels.TimeRange
limit int
}
// NewQueryBuilder creates a new query builder
func NewQueryBuilder(funnel *traceFunnels.Funnel, timeRange traceFunnels.TimeRange) *QueryBuilder {
return &QueryBuilder{
steps: funnel.Steps,
timeRange: timeRange,
limit: 100,
}
}
// WithLimit sets the limit for the query
func (qb *QueryBuilder) WithLimit(limit int) *QueryBuilder {
qb.limit = limit
return qb
}
// buildStepCTE builds a CTE for a single step
func (qb *QueryBuilder) buildStepCTE(step traceFunnels.FunnelStep, stepNum int) string {
return fmt.Sprintf(`
step%d AS (
SELECT
trace_id,
service_name,
name as span_name,
timestamp,
duration_ns,
status_code
FROM signoz_traces.signoz_index_v2
WHERE service_name = '%s'
AND name = '%s'
AND timestamp >= %d
AND timestamp <= %d
)`,
stepNum,
step.ServiceName,
step.SpanName,
qb.timeRange.StartTime,
qb.timeRange.EndTime,
)
}
// buildStepCTEs builds all step CTEs
func (qb *QueryBuilder) buildStepCTEs() string {
var ctes []string
for i, step := range qb.steps {
ctes = append(ctes, qb.buildStepCTE(step, i+1))
}
return strings.Join(ctes, ",\n")
}
// buildStepJoins builds the JOIN clauses between steps
func (qb *QueryBuilder) buildStepJoins() string {
var joins []string
for i := 1; i < len(qb.steps); i++ {
joins = append(joins, fmt.Sprintf("JOIN step%d s%d ON s%d.trace_id = s%d.trace_id AND s%d.timestamp > s%d.timestamp",
i+1, i+1, i, i+1, i+1, i))
}
return strings.Join(joins, "\n")
}
// buildStepSelects builds the SELECT clauses for each step
func (qb *QueryBuilder) buildStepSelects() string {
var selects []string
for i := range qb.steps {
selects = append(selects, fmt.Sprintf("s%d.timestamp as step%d_timestamp", i+1, i+1))
selects = append(selects, fmt.Sprintf("s%d.duration_ns / 1000000 as step%d_duration_ms", i+1, i+1))
}
return strings.Join(selects, ",\n")
}
// ValidateTraces builds a ClickHouse query to validate traces in a funnel
func ValidateTraces(funnel *traceFunnels.Funnel, timeRange traceFunnels.TimeRange) (*v3.ClickHouseQuery, error) {
if err := ValidateFunnel(funnel); err != nil {
return nil, fmt.Errorf("invalid funnel: %v", err)
}
if err := ValidateTimeRange(timeRange); err != nil {
return nil, fmt.Errorf("invalid time range: %v", err)
}
qb := NewQueryBuilder(funnel, timeRange)
query := fmt.Sprintf(`
WITH %s
SELECT
s1.trace_id,
%s,
s%d.timestamp - s1.timestamp as total_duration_ms
FROM step1 s1
%s
ORDER BY total_duration_ms DESC
LIMIT %d
`,
qb.buildStepCTEs(),
qb.buildStepSelects(),
len(funnel.Steps),
qb.buildStepJoins(),
qb.limit,
)
return &v3.ClickHouseQuery{
Query: query,
}, nil
}
// ValidateTracesWithLatency builds a ClickHouse query to validate traces with latency information
func ValidateTracesWithLatency(funnel *traceFunnels.Funnel, timeRange traceFunnels.TimeRange) (*v3.ClickHouseQuery, error) {
if err := ValidateFunnel(funnel); err != nil {
return nil, fmt.Errorf("invalid funnel: %v", err)
}
if err := ValidateTimeRange(timeRange); err != nil {
return nil, fmt.Errorf("invalid time range: %v", err)
}
qb := NewQueryBuilder(funnel, timeRange)
query := fmt.Sprintf(`
WITH %s
SELECT
s1.trace_id,
%s,
s%d.timestamp - s1.timestamp as transition_duration_ms
FROM step1 s1
%s
ORDER BY transition_duration_ms DESC
LIMIT %d
`,
qb.buildStepCTEs(),
qb.buildStepSelects(),
len(funnel.Steps),
qb.buildStepJoins(),
qb.limit,
)
return &v3.ClickHouseQuery{
Query: query,
}, nil
}
// GetStepAnalytics builds a ClickHouse query to get analytics for each step
func GetStepAnalytics(funnel *traceFunnels.Funnel, timeRange traceFunnels.TimeRange) (*v3.ClickHouseQuery, error) {
if err := ValidateFunnel(funnel); err != nil {
return nil, fmt.Errorf("invalid funnel: %v", err)
}
if err := ValidateTimeRange(timeRange); err != nil {
return nil, fmt.Errorf("invalid time range: %v", err)
}
qb := NewQueryBuilder(funnel, timeRange)
query := fmt.Sprintf(`
WITH %s
SELECT
COUNT(DISTINCT s1.trace_id) as total_start,
COUNT(DISTINCT s%d.trace_id) as total_complete,
COUNT(DISTINCT CASE WHEN s%d.status_code >= 400 THEN s%d.trace_id END) as error_count,
AVG(s%d.timestamp - s1.timestamp) as avg_duration_ms,
quantile(0.99)(s%d.timestamp - s1.timestamp) as p99_latency_ms,
COUNT(DISTINCT s%d.trace_id) * 100.0 / COUNT(DISTINCT s1.trace_id) as conversion_rate
FROM step1 s1
%s
`,
qb.buildStepCTEs(),
len(funnel.Steps),
len(funnel.Steps),
len(funnel.Steps),
len(funnel.Steps),
len(funnel.Steps),
len(funnel.Steps),
qb.buildStepJoins(),
)
return &v3.ClickHouseQuery{
Query: query,
}, nil
}
// GetSlowestTraces builds a ClickHouse query to get the slowest traces between two steps
func GetSlowestTraces(funnel *traceFunnels.Funnel, stepAOrder, stepBOrder int64, timeRange traceFunnels.TimeRange, isError bool) (*v3.ClickHouseQuery, error) {
if err := ValidateFunnel(funnel); err != nil {
return nil, fmt.Errorf("invalid funnel: %v", err)
}
if err := ValidateTimeRange(timeRange); err != nil {
return nil, fmt.Errorf("invalid time range: %v", err)
}
// Find the steps by order
var stepA, stepB *traceFunnels.FunnelStep
for _, step := range funnel.Steps {
if step.Order == stepAOrder {
stepA = &step
}
if step.Order == stepBOrder {
stepB = &step
}
}
if stepA == nil || stepB == nil {
return nil, fmt.Errorf("steps not found")
}
// Create a new funnel with just the two steps we want to analyze
analysisFunnel := &traceFunnels.Funnel{
Steps: []traceFunnels.FunnelStep{*stepA, *stepB},
}
qb := NewQueryBuilder(analysisFunnel, timeRange)
query := fmt.Sprintf(`
WITH %s
SELECT
s1.trace_id,
%s,
s2.timestamp - s1.timestamp as transition_duration_ms,
s2.status_code
FROM step1 s1
%s
WHERE CASE WHEN %t THEN s2.status_code >= 400 ELSE 1=1 END
ORDER BY transition_duration_ms DESC
LIMIT %d
`,
qb.buildStepCTEs(),
qb.buildStepSelects(),
qb.buildStepJoins(),
isError,
qb.limit,
)
return &v3.ClickHouseQuery{
Query: query,
}, nil
}

View File

@@ -0,0 +1,177 @@
package tracefunnels
import (
"context"
"fmt"
"time"
"github.com/SigNoz/signoz/pkg/sqlstore"
traceFunnels "github.com/SigNoz/signoz/pkg/types/tracefunnel"
"github.com/SigNoz/signoz/pkg/valuer"
)
// store implements the TraceFunnelStore interface
type store struct {
db sqlstore.SQLStore
}
// NewStore creates a new trace funnel store
func NewStore(db sqlstore.SQLStore) traceFunnels.TraceFunnelStore {
return &store{db: db}
}
// Create creates a new funnel in the database
func (s *store) Create(ctx context.Context, funnel *traceFunnels.Funnel) error {
// Generate a new UUID for the funnel if not already set
if funnel.ID.IsZero() {
funnel.ID = valuer.GenerateUUID()
}
// Set timestamps if not set
if funnel.CreatedAt.IsZero() {
funnel.CreatedAt = time.Now()
}
if funnel.UpdatedAt.IsZero() {
funnel.UpdatedAt = time.Now()
}
// Insert the funnel
_, err := s.db.BunDB().NewInsert().Model(funnel).Exec(ctx)
if err != nil {
return fmt.Errorf("failed to create funnel: %v", err)
}
// Update the user relationship
if funnel.CreatedByUser != nil {
_, err = s.db.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 (s *store) Get(ctx context.Context, uuid valuer.UUID) (*traceFunnels.Funnel, error) {
funnel := &traceFunnels.Funnel{}
err := s.db.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 (s *store) Update(ctx context.Context, funnel *traceFunnels.Funnel) error {
// Update the updated_at timestamp
funnel.UpdatedAt = time.Now()
_, err := s.db.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 (s *store) List(ctx context.Context) ([]*traceFunnels.Funnel, error) {
var funnels []*traceFunnels.Funnel
err := s.db.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 (s *store) Delete(ctx context.Context, uuid valuer.UUID) error {
_, err := s.db.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 (s *store) ListByOrg(ctx context.Context, orgID valuer.UUID) ([]*traceFunnels.Funnel, error) {
var funnels []*traceFunnels.Funnel
err := s.db.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 (s *store) GetByIDAndOrg(ctx context.Context, id, orgID valuer.UUID) (*traceFunnels.Funnel, error) {
funnel := &traceFunnels.Funnel{}
err := s.db.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 (s *store) UpdateSteps(ctx context.Context, funnelID valuer.UUID, steps []traceFunnels.FunnelStep) error {
_, err := s.db.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 (s *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 := s.db.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 = s.db.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 = s.db.BunDB().NewSelect().Model(updatedFunnel).Where("id = ?", funnelID).Scan(ctx)
if err != nil {
return fmt.Errorf("failed to verify update: %v", err)
}
return nil
}

View File

@@ -0,0 +1,100 @@
package tracefunnels
import (
"fmt"
"time"
traceFunnels "github.com/SigNoz/signoz/pkg/types/tracefunnel"
)
// ValidateFunnel validates a funnel's data
func ValidateFunnel(funnel *traceFunnels.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 traceFunnels.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 traceFunnels.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 []traceFunnels.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
}

View File

@@ -0,0 +1,79 @@
package tracefunnel
import (
"fmt"
tf "github.com/SigNoz/signoz/pkg/types/tracefunnel"
"sort"
)
// ValidateSteps checks if the requested steps exist in the funnel
func ValidateSteps(funnel *tf.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
}
// 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 []tf.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 []tf.FunnelStep) []tf.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
}

View File

@@ -316,7 +316,8 @@ func (r *ClickHouseReader) GetServicesList(ctx context.Context) (*[]string, erro
return &services, nil
}
func (r *ClickHouseReader) GetTopLevelOperations(ctx context.Context, start, end time.Time, services []string) (*map[string][]string, *model.ApiError) {
func (r *ClickHouseReader) GetTopLevelOperations(ctx context.Context, skipConfig *model.SkipConfig, start, end time.Time, services []string) (*map[string][]string, *model.ApiError) {
start = start.In(time.UTC)
// The `top_level_operations` that have `time` >= start
@@ -346,6 +347,9 @@ func (r *ClickHouseReader) GetTopLevelOperations(ctx context.Context, start, end
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
@@ -410,13 +414,13 @@ func (r *ClickHouseReader) buildResourceSubQuery(tags []model.TagQueryParam, svc
return resourceSubQuery, nil
}
func (r *ClickHouseReader) GetServicesV2(ctx context.Context, queryParams *model.GetServicesParams) (*[]model.ServiceItem, *model.ApiError) {
func (r *ClickHouseReader) GetServicesV2(ctx context.Context, queryParams *model.GetServicesParams, skipConfig *model.SkipConfig) (*[]model.ServiceItem, *model.ApiError) {
if r.indexTable == "" {
return nil, &model.ApiError{Typ: model.ErrorExec, Err: ErrNoIndexTable}
}
topLevelOps, apiErr := r.GetTopLevelOperations(ctx, *queryParams.Start, *queryParams.End, nil)
topLevelOps, apiErr := r.GetTopLevelOperations(ctx, skipConfig, *queryParams.Start, *queryParams.End, nil)
if apiErr != nil {
return nil, apiErr
}
@@ -535,16 +539,16 @@ func (r *ClickHouseReader) GetServicesV2(ctx context.Context, queryParams *model
return &serviceItems, nil
}
func (r *ClickHouseReader) GetServices(ctx context.Context, queryParams *model.GetServicesParams) (*[]model.ServiceItem, *model.ApiError) {
func (r *ClickHouseReader) GetServices(ctx context.Context, queryParams *model.GetServicesParams, skipConfig *model.SkipConfig) (*[]model.ServiceItem, *model.ApiError) {
if r.useTraceNewSchema {
return r.GetServicesV2(ctx, queryParams)
return r.GetServicesV2(ctx, queryParams, skipConfig)
}
if r.indexTable == "" {
return nil, &model.ApiError{Typ: model.ErrorExec, Err: ErrNoIndexTable}
}
topLevelOps, apiErr := r.GetTopLevelOperations(ctx, *queryParams.Start, *queryParams.End, nil)
topLevelOps, apiErr := r.GetTopLevelOperations(ctx, skipConfig, *queryParams.Start, *queryParams.End, nil)
if apiErr != nil {
return nil, apiErr
}

View File

@@ -23,6 +23,9 @@ 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"
traceFunnel "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"
@@ -59,7 +62,8 @@ import (
"github.com/SigNoz/signoz/pkg/types"
"github.com/SigNoz/signoz/pkg/types/authtypes"
"github.com/SigNoz/signoz/pkg/types/pipelinetypes"
ruletypes "github.com/SigNoz/signoz/pkg/types/ruletypes"
"github.com/SigNoz/signoz/pkg/types/ruletypes"
tracefunnel "github.com/SigNoz/signoz/pkg/types/tracefunnel"
"go.uber.org/zap"
@@ -89,6 +93,7 @@ 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
@@ -144,6 +149,13 @@ type APIHandler struct {
FieldsAPI *fields.API
Signoz *signoz.SigNoz
Preference preference.API
OrganizationAPI organization.API
OrganizationModule organization.Module
TraceFunnels traceFunnel.Api
}
type APIHandlerOpts struct {
@@ -151,6 +163,8 @@ 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
@@ -189,6 +203,11 @@ type APIHandlerOpts struct {
FieldsAPI *fields.API
Signoz *signoz.SigNoz
Preference preference.API
OrganizationAPI organization.API
OrganizationModule organization.Module
TraceFunnels traceFunnel.Api
}
// NewAPIHandler returns an APIHandler
@@ -230,6 +249,7 @@ 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,
@@ -256,7 +276,11 @@ 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,
TraceFunnels: opts.TraceFunnels,
}
logsQueryBuilder := logsv3.PrepareLogsQuery
@@ -578,13 +602,23 @@ func (aH *APIHandler) RegisterRoutes(router *mux.Router, am *middleware.AuthZ) {
router.HandleFunc("/api/v1/disks", am.ViewAccess(aH.getDisks)).Methods(http.MethodGet)
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)
// === Preference APIs ===
// 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)
@@ -605,8 +639,9 @@ 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/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/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/v1/getResetPasswordToken/{id}", am.AdminAccess(aH.getResetPasswordToken)).Methods(http.MethodGet)
router.HandleFunc("/api/v1/resetPassword", am.OpenAccess(aH.resetPassword)).Methods(http.MethodPost)
@@ -1680,7 +1715,7 @@ func (aH *APIHandler) getServicesTopLevelOps(w http.ResponseWriter, r *http.Requ
end = time.Unix(0, endEpochInt)
}
result, apiErr := aH.reader.GetTopLevelOperations(r.Context(), start, end, services)
result, apiErr := aH.reader.GetTopLevelOperations(r.Context(), aH.skipConfig, start, end, services)
if apiErr != nil {
RespondError(w, apiErr, nil)
return
@@ -1696,7 +1731,7 @@ func (aH *APIHandler) getServices(w http.ResponseWriter, r *http.Request) {
return
}
result, apiErr := aH.reader.GetServices(r.Context(), query)
result, apiErr := aH.reader.GetServices(r.Context(), query, aH.skipConfig)
if apiErr != nil && aH.HandleError(w, apiErr.Err, http.StatusInternalServerError) {
return
}
@@ -2035,7 +2070,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.Signoz.Modules.Organization)
resp, err := auth.GetInvite(context.Background(), token, aH.OrganizationModule)
if err != nil {
RespondError(w, &model.ApiError{Err: err, Typ: model.ErrorNotFound}, nil)
return
@@ -2076,7 +2111,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.Signoz.Modules.Organization.Get(ctx, orgID)
org, err := aH.OrganizationModule.Get(ctx, orgID)
if err != nil {
render.Error(w, errorsV2.Newf(errorsV2.TypeInternal, errorsV2.CodeInternal, err.Error()))
}
@@ -2103,7 +2138,7 @@ func (aH *APIHandler) registerUser(w http.ResponseWriter, r *http.Request) {
return
}
_, apiErr := auth.Register(context.Background(), req, aH.Signoz.Alertmanager, aH.Signoz.Modules.Organization)
_, apiErr := auth.Register(context.Background(), req, aH.Signoz.Alertmanager, aH.OrganizationModule)
if apiErr != nil {
RespondError(w, apiErr, nil)
return
@@ -2362,6 +2397,18 @@ 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)
@@ -3396,6 +3443,44 @@ 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()
@@ -4621,6 +4706,7 @@ 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 {
@@ -5535,3 +5621,463 @@ 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-funnel", aH.handleNewFunnel).Methods("POST")
traceFunnelsRouter.HandleFunc("/steps/update", aH.handleUpdateFunnelStep).Methods("PUT")
traceFunnelsRouter.HandleFunc("/list", aH.handleListFunnels).Methods("GET")
// Standard RESTful endpoints for funnel resource
traceFunnelsRouter.HandleFunc("/{funnel_id}", aH.handleGetFunnel).Methods("GET")
traceFunnelsRouter.HandleFunc("/{funnel_id}", aH.handleDeleteFunnel).Methods("DELETE")
traceFunnelsRouter.HandleFunc("/save", aH.handleSaveFunnel).Methods("POST")
// 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")
}
// handleNewFunnel handles the creation of a new funnel
func (aH *APIHandler) handleNewFunnel(w http.ResponseWriter, r *http.Request) {
var req tracefunnel.FunnelRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
RespondError(w, &model.ApiError{Typ: model.ErrorBadData, Err: err}, nil)
return
}
claims, err := authtypes.ClaimsFromContext(r.Context())
if err != nil {
RespondError(w, &model.ApiError{Typ: model.ErrorUnauthorized, Err: fmt.Errorf("unauthenticated")}, nil)
return
}
userID := claims.UserID
orgID := claims.OrgID
// Check if a funnel with the same name already exists for this org
funnels, err := aH.TraceFunnels.ListFunnels(r.Context(), orgID)
if err != nil {
RespondError(w, &model.ApiError{Typ: model.ErrorInternal, Err: fmt.Errorf("failed to list funnels: %v", err)}, nil)
return
}
for _, f := range funnels {
if f.Name == req.Name {
RespondError(w, &model.ApiError{
Typ: model.ErrorBadData,
Err: fmt.Errorf("a funnel with name '%s' already exists in this organization", req.Name),
}, nil)
return
}
}
funnel, err := aH.TraceFunnels.CreateFunnel(r.Context(), req.Timestamp, req.Name, userID, orgID)
if err != nil {
RespondError(w, &model.ApiError{Typ: model.ErrorInternal, Err: fmt.Errorf("failed to create funnel: %v", err)}, nil)
return
}
response := tracefunnel.FunnelResponse{
FunnelID: funnel.ID.String(),
FunnelName: funnel.Name,
CreationTimestamp: req.Timestamp,
UserEmail: claims.Email,
OrgID: orgID,
}
aH.Respond(w, response)
}
// handleUpdateFunnelStep adds or updates steps for an existing funnel
func (aH *APIHandler) handleUpdateFunnelStep(w http.ResponseWriter, r *http.Request) {
var req tracefunnel.FunnelRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
RespondError(w, &model.ApiError{Typ: model.ErrorBadData, Err: err}, nil)
return
}
claims, err := authtypes.ClaimsFromContext(r.Context())
if err != nil {
RespondError(w, &model.ApiError{Typ: model.ErrorUnauthorized, Err: fmt.Errorf("unauthenticated")}, nil)
return
}
userID := claims.UserID
orgID := claims.OrgID
if err := traceFunnel.ValidateTimestamp(req.Timestamp, "timestamp"); err != nil {
RespondError(w, &model.ApiError{Typ: model.ErrorBadData, Err: err}, nil)
return
}
funnel, err := aH.TraceFunnels.GetFunnel(r.Context(), req.FunnelID.String())
if err != nil {
RespondError(w, &model.ApiError{Typ: model.ErrorNotFound, Err: fmt.Errorf("funnel not found: %v", err)}, nil)
return
}
// Check if name is being updated and if it already exists
if req.Name != "" && req.Name != funnel.Name {
funnels, err := aH.TraceFunnels.ListFunnels(r.Context(), orgID)
if err != nil {
RespondError(w, &model.ApiError{Typ: model.ErrorInternal, Err: fmt.Errorf("failed to list funnels: %v", err)}, nil)
return
}
for _, f := range funnels {
if f.Name == req.Name {
RespondError(w, &model.ApiError{
Typ: model.ErrorBadData,
Err: fmt.Errorf("a funnel with name '%s' already exists in this organization", req.Name),
}, nil)
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
}
}
if err := traceFunnel.ValidateFunnelSteps(req.Steps); err != nil {
RespondError(w, &model.ApiError{Typ: model.ErrorBadData, Err: fmt.Errorf("invalid funnel steps: %v", err)}, nil)
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
// Update funnel in database
err = aH.TraceFunnels.UpdateFunnel(r.Context(), funnel, userID)
if err != nil {
RespondError(w, &model.ApiError{Typ: model.ErrorInternal, Err: fmt.Errorf("failed to update funnel in database: %v", err)}, nil)
return
}
// Update name and description if provided
if req.Name != "" || req.Description != "" {
// If name is not provided, use the existing name
name := req.Name
if name == "" {
name = funnel.Name
}
// If description is not provided, use the existing description
description := req.Description
if description == "" {
description = funnel.Description
}
err = aH.TraceFunnels.UpdateMetadata(r.Context(), funnel.ID, name, description, userID)
if err != nil {
RespondError(w, &model.ApiError{Typ: model.ErrorInternal, Err: fmt.Errorf("failed to update funnel metadata: %v", err)}, nil)
return
}
}
// Get the updated funnel to return in response
updatedFunnel, err := aH.TraceFunnels.GetFunnel(r.Context(), funnel.ID.String())
if err != nil {
RespondError(w, &model.ApiError{Typ: model.ErrorInternal, Err: fmt.Errorf("failed to get updated funnel: %v", err)}, nil)
return
}
response := tracefunnel.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,
}
aH.Respond(w, response)
}
// handleListFunnels handles listing all funnels for a user
func (aH *APIHandler) handleListFunnels(w http.ResponseWriter, r *http.Request) {
claims, err := authtypes.ClaimsFromContext(r.Context())
if err != nil {
RespondError(w, &model.ApiError{
Typ: model.ErrorUnauthorized,
Err: fmt.Errorf("unauthenticated"),
}, nil)
return
}
orgID := claims.OrgID
funnels, err := aH.TraceFunnels.ListFunnels(r.Context(), orgID)
if err != nil {
RespondError(w, &model.ApiError{
Typ: model.ErrorInternal,
Err: fmt.Errorf("failed to list funnels: %v", err),
}, nil)
return
}
var response []tracefunnel.FunnelResponse
for _, f := range funnels {
funnelResp := tracefunnel.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)
}
aH.Respond(w, response)
}
// handleGetFunnel handles getting a single funnel
func (aH *APIHandler) handleGetFunnel(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
funnelID := vars["funnel_id"]
funnel, err := aH.TraceFunnels.GetFunnel(r.Context(), funnelID)
if err != nil {
RespondError(w, &model.ApiError{Typ: model.ErrorNotFound, Err: fmt.Errorf("funnel not found: %v", err)}, nil)
return
}
// Create a response with only the email from CreatedByUser
response := tracefunnel.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,
UserEmail: funnel.CreatedByUser.Email,
}
aH.Respond(w, response)
}
// handleDeleteFunnel handles deleting a funnel
func (aH *APIHandler) handleDeleteFunnel(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
funnelID := vars["funnel_id"]
err := aH.TraceFunnels.DeleteFunnel(r.Context(), funnelID)
if err != nil {
RespondError(w, &model.ApiError{Typ: model.ErrorInternal, Err: fmt.Errorf("failed to delete funnel: %v", err)}, nil)
return
}
aH.Respond(w, nil)
}
// handleSaveFunnel saves a funnel to the SQLite database
func (aH *APIHandler) handleSaveFunnel(w http.ResponseWriter, r *http.Request) {
var req tracefunnel.FunnelRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
RespondError(w, &model.ApiError{Typ: model.ErrorBadData, Err: err}, nil)
return
}
claims, err := authtypes.ClaimsFromContext(r.Context())
if err != nil {
RespondError(w, &model.ApiError{Typ: model.ErrorUnauthorized, Err: fmt.Errorf("unauthenticated")}, nil)
return
}
orgID := claims.OrgID
usrID := claims.UserID
funnel, err := aH.TraceFunnels.GetFunnel(r.Context(), req.FunnelID.String())
if err != nil {
RespondError(w, &model.ApiError{Typ: model.ErrorNotFound, Err: fmt.Errorf("funnel not found: %v", err)}, nil)
return
}
updateTimestamp := req.Timestamp
if updateTimestamp == 0 {
updateTimestamp = time.Now().UnixMilli()
} else if !traceFunnel.ValidateTimestampIsMilliseconds(updateTimestamp) {
RespondError(w, &model.ApiError{Typ: model.ErrorBadData, Err: fmt.Errorf("timestamp must be in milliseconds format (13 digits)")}, nil)
return
}
funnel.UpdatedAt = time.Unix(0, updateTimestamp*1000000) // Convert to nanoseconds
if req.UserID != "" {
funnel.UpdatedBy = usrID
}
funnel.Description = req.Description
if err := aH.TraceFunnels.SaveFunnel(r.Context(), funnel, funnel.UpdatedBy, orgID); err != nil {
RespondError(w, &model.ApiError{Typ: model.ErrorInternal, Err: fmt.Errorf("failed to save funnel: %v", err)}, nil)
return
}
// Try to fetch metadata from DB
createdAt, updatedAt, extraDataFromDB, err := aH.TraceFunnels.GetFunnelMetadata(r.Context(), funnel.ID.String())
resp := tracefunnel.FunnelResponse{
FunnelName: funnel.Name,
CreatedAt: createdAt,
UpdatedAt: updatedAt,
CreatedBy: funnel.CreatedBy,
UpdatedBy: funnel.UpdatedBy,
OrgID: funnel.OrgID.String(),
Description: extraDataFromDB,
}
aH.Respond(w, resp)
}
// handleValidateTraces handles validating traces in a funnel
func (aH *APIHandler) handleValidateTraces(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
funnelID := vars["funnel_id"]
funnel, err := aH.TraceFunnels.GetFunnel(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 tracefunnel.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
}
response, err := aH.TraceFunnels.ValidateTraces(r.Context(), funnel, timeRange)
if err != nil {
RespondError(w, &model.ApiError{Typ: model.ErrorInternal, Err: fmt.Errorf("error validating traces: %v", err)}, nil)
return
}
aH.Respond(w, response)
}
// handleFunnelAnalytics handles getting analytics for a funnel
func (aH *APIHandler) handleFunnelAnalytics(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
funnelID := vars["funnel_id"]
funnel, err := aH.TraceFunnels.GetFunnel(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 tracefunnel.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
}
response, err := aH.TraceFunnels.GetFunnelAnalytics(r.Context(), funnel, timeRange)
if err != nil {
RespondError(w, &model.ApiError{Typ: model.ErrorInternal, Err: fmt.Errorf("error getting funnel analytics: %v", err)}, nil)
return
}
aH.Respond(w, response)
}
// handleStepAnalytics handles getting analytics for each step
func (aH *APIHandler) handleStepAnalytics(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
funnelID := vars["funnel_id"]
funnel, err := aH.TraceFunnels.GetFunnel(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 tracefunnel.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
}
response, err := aH.TraceFunnels.GetStepAnalytics(r.Context(), funnel, timeRange)
if err != nil {
RespondError(w, &model.ApiError{Typ: model.ErrorInternal, Err: fmt.Errorf("error getting step analytics: %v", err)}, nil)
return
}
aH.Respond(w, response)
}
// 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 := traceFunnel.ValidateSteps(funnel, req.StepAOrder, req.StepBOrder); err != nil {
RespondError(w, &model.ApiError{Typ: model.ErrorBadData, Err: err}, nil)
return
}
response, err := aH.TraceFunnels.GetSlowestTraces(r.Context(), funnel, req.StepAOrder, req.StepBOrder, req.TimeRange, isError)
if err != nil {
RespondError(w, &model.ApiError{Typ: model.ErrorInternal, Err: fmt.Errorf("error getting traces: %v", err)}, nil)
return
}
aH.Respond(w, response)
}
// validateTracesRequest validates and extracts the request parameters
func (aH *APIHandler) validateTracesRequest(r *http.Request) (*tracefunnel.Funnel, *tracefunnel.StepTransitionRequest, error) {
vars := mux.Vars(r)
funnelID := vars["funnel_id"]
funnel, err := aH.TraceFunnels.GetFunnel(r.Context(), funnelID)
if err != nil {
return nil, nil, fmt.Errorf("funnel not found: %v", err)
}
var req tracefunnel.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
}

View File

@@ -14,6 +14,11 @@ 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/modules/tracefunnel"
tfCore "github.com/SigNoz/signoz/pkg/modules/tracefunnel/core"
"github.com/SigNoz/signoz/pkg/prometheus"
"github.com/SigNoz/signoz/pkg/query-service/agentConf"
"github.com/SigNoz/signoz/pkg/query-service/app/clickhouseReader"
@@ -27,6 +32,7 @@ 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"
@@ -38,6 +44,7 @@ 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"
@@ -45,9 +52,14 @@ import (
)
type ServerOptions struct {
Config signoz.Config
HTTPHostPort string
PrivateHostPort string
Config signoz.Config
PromConfigPath string
SkipTopLvlOpsPath string
HTTPHostPort string
PrivateHostPort string
// alert specific params
DisableRules bool
RuleRepoURL string
PreferSpanMetrics bool
CacheConfigPath string
FluxInterval string
@@ -116,6 +128,15 @@ 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)
@@ -126,9 +147,11 @@ 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,
@@ -162,8 +185,13 @@ 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))
tracefunnels := tracefunnel.NewAPI(tfCore.NewStore(serverOptions.SigNoz.SQLStore))
apiHandler, err := NewAPIHandler(APIHandlerOpts{
Reader: reader,
SkipConfig: skipConfig,
PreferSpanMetrics: serverOptions.PreferSpanMetrics,
AppDao: dao.DB(),
RuleManager: rm,
@@ -179,12 +207,18 @@ 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,
TraceFunnels: tracefunnels,
})
if err != nil {
return nil, err
}
s := &Server{
// logger: logger,
// tracer: tracer,
ruleManager: rm,
serverOptions: serverOptions,
unavailableChannel: make(chan healthcheck.Status),
@@ -289,6 +323,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{"*"},
@@ -344,7 +379,13 @@ func (s *Server) initListeners() error {
// Start listening on http and private http port concurrently
func (s *Server) Start(ctx context.Context) error {
s.ruleManager.Start(ctx)
// 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")
}
err := s.initListeners()
if err != nil {
@@ -381,7 +422,6 @@ func (s *Server) Start(ctx context.Context) error {
if port, err := utils.GetPort(s.privateConn.Addr()); err == nil {
privatePort = port
}
fmt.Println("starting private http")
go func() {
zap.L().Info("Starting Private HTTP server", zap.Int("port", privatePort), zap.String("addr", s.serverOptions.PrivateHostPort))
@@ -432,9 +472,11 @@ 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,
@@ -445,9 +487,11 @@ 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(),

View File

@@ -18,6 +18,10 @@ 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 {
@@ -53,6 +57,9 @@ 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")

View File

@@ -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.display_name as organization").
ColumnExpr("o.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.display_name as organization").
ColumnExpr("o.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.display_name as organization").
ColumnExpr("o.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.display_name as organization").
ColumnExpr("o.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.display_name as organization").
ColumnExpr("o.name as organization").
Join("JOIN organizations o ON o.id = users.org_id").
Where("users.role = ?", role)

View File

@@ -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, start, end time.Time, services []string) (*map[string][]string, *model.ApiError)
GetServices(ctx context.Context, query *model.GetServicesParams) (*[]model.ServiceItem, *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)
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)

View File

@@ -47,14 +47,10 @@ 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)")
@@ -62,11 +58,8 @@ 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()
@@ -120,8 +113,12 @@ 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,

View File

@@ -88,11 +88,6 @@ 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"`

View File

@@ -0,0 +1,57 @@
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
}

View File

@@ -0,0 +1,17 @@
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"`
}

View File

@@ -84,14 +84,18 @@ 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
Reader interfaces.Reader
Cache cache.Cache
Context context.Context
Logger *zap.Logger
ResendDelay time.Duration
DisableRules bool
Reader interfaces.Reader
Cache cache.Cache
EvalDelay time.Duration
@@ -391,9 +395,11 @@ func (m *Manager) EditRule(ctx context.Context, ruleStr string, idStr string) er
return err
}
err = m.syncRuleStateWithTask(ctx, claims.OrgID, prepareTaskName(existingRule.ID.StringValue()), parsedRule)
if err != nil {
return err
if !m.opts.DisableRules {
err = m.syncRuleStateWithTask(ctx, claims.OrgID, prepareTaskName(existingRule.ID.StringValue()), parsedRule)
if err != nil {
return err
}
}
return nil
@@ -490,7 +496,9 @@ func (m *Manager) DeleteRule(ctx context.Context, idStr string) error {
}
taskName := prepareTaskName(id.StringValue())
m.deleteTask(taskName)
if !m.opts.DisableRules {
m.deleteTask(taskName)
}
return nil
})
@@ -573,8 +581,10 @@ func (m *Manager) CreateRule(ctx context.Context, ruleStr string) (*ruletypes.Ge
}
taskName := prepareTaskName(id.StringValue())
if err := m.addTask(ctx, claims.OrgID, parsedRule, taskName); err != nil {
return err
if !m.opts.DisableRules {
if err := m.addTask(ctx, claims.OrgID, parsedRule, taskName); err != nil {
return err
}
}
return nil
@@ -714,6 +724,9 @@ 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(),
@@ -746,6 +759,9 @@ func (m *Manager) prepareTestNotifyFunc() NotifyFunc {
alert := alerts[0]
generatorURL := alert.GeneratorURL
if generatorURL == "" {
generatorURL = m.opts.RepoURL
}
a := &alertmanagertypes.PostableAlert{
Annotations: alert.Annotations.Map(),

View File

@@ -18,7 +18,6 @@ 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"
@@ -298,17 +297,11 @@ 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)

View File

@@ -10,7 +10,6 @@ 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"
@@ -361,19 +360,12 @@ 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)

View File

@@ -19,7 +19,6 @@ 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"
@@ -567,9 +566,6 @@ 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(),
@@ -577,10 +573,6 @@ 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)

View File

@@ -1,20 +0,0 @@
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"
)
type Handlers struct {
Organization organization.Handler
Preference preference.Handler
}
func NewHandlers(modules Modules) Handlers {
return Handlers{
Organization: implorganization.NewHandler(modules.Organization),
Preference: implpreference.NewHandler(modules.Preference),
}
}

View File

@@ -1,22 +0,0 @@
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/sqlstore"
"github.com/SigNoz/signoz/pkg/types/preferencetypes"
)
type Modules struct {
Organization organization.Module
Preference preference.Module
}
func NewModules(sqlstore sqlstore.SQLStore) Modules {
return Modules{
Organization: implorganization.NewModule(implorganization.NewStore(sqlstore)),
Preference: implpreference.NewModule(implpreference.NewStore(sqlstore), preferencetypes.NewDefaultPreferenceMap()),
}
}

View File

@@ -74,6 +74,7 @@ func NewSQLMigrationProviderFactories(sqlstore sqlstore.SQLStore) factory.NamedM
sqlmigration.NewUpdateIntegrationsFactory(sqlstore),
sqlmigration.NewUpdateOrganizationsFactory(sqlstore),
sqlmigration.NewDropGroupsFactory(sqlstore),
sqlmigration.NewAddTraceFunnelsFactory(sqlstore),
)
}

View File

@@ -26,8 +26,6 @@ type SigNoz struct {
TelemetryStore telemetrystore.TelemetryStore
Prometheus prometheus.Prometheus
Alertmanager alertmanager.Alertmanager
Modules Modules
Handlers Handlers
}
func New(
@@ -126,7 +124,6 @@ func New(
return nil, err
}
// Initialize alertmanager from the available alertmanager provider factories
alertmanager, err := factory.NewProviderFromNamedMap(
ctx,
providerSettings,
@@ -138,12 +135,6 @@ 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),
@@ -162,7 +153,5 @@ func New(
TelemetryStore: telemetrystore,
Prometheus: prometheus,
Alertmanager: alertmanager,
Modules: modules,
Handlers: handlers,
}, nil
}

View File

@@ -38,26 +38,14 @@ 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 {
if _, err := tx.NewDropTable().IfExists().Table("sites").Exec(ctx); err != nil {
return err
}
if _, err := tx.NewDropTable().IfExists().Table("licenses").Exec(ctx); err != nil {
return err
}
if _, err := tx.
NewDropTable().
IfExists().
Table("licenses").
Exec(ctx); err != nil {
return err
}
_, err = migration.
store.
Dialect().
RenameColumn(ctx, tx, "saved_views", "uuid", "id")
_, err = migration.store.Dialect().RenameColumn(ctx, tx, "saved_views", "uuid", "id")
if err != nil {
return err
}

View File

@@ -42,9 +42,12 @@ 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) {
@@ -52,7 +55,8 @@ 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
}
@@ -60,7 +64,8 @@ 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
}
@@ -83,7 +88,8 @@ 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).

View File

@@ -20,7 +20,9 @@ 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 {
@@ -32,33 +34,25 @@ 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
}

View File

@@ -77,9 +77,12 @@ 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) {
@@ -87,7 +90,8 @@ 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
}
@@ -95,7 +99,8 @@ 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
}

View File

@@ -49,9 +49,12 @@ 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) {
@@ -59,7 +62,8 @@ 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
}
@@ -67,7 +71,8 @@ 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
}
@@ -130,7 +135,8 @@ 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).

View File

@@ -61,9 +61,12 @@ 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) {
@@ -71,7 +74,8 @@ 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
}
@@ -79,7 +83,8 @@ 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
}
@@ -156,7 +161,8 @@ 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).

View File

@@ -61,9 +61,12 @@ 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) {
@@ -71,7 +74,8 @@ 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
}
@@ -79,7 +83,8 @@ 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
}
@@ -99,7 +104,8 @@ 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).
@@ -128,7 +134,8 @@ 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

View File

@@ -26,7 +26,9 @@ 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 {
@@ -134,7 +136,9 @@ func (migration *updateIntegrations) Up(ctx context.Context, db *bun.DB) error {
return nil
}
// ---
// installed integrations
// ---
err = migration.
store.
Dialect().
@@ -167,7 +171,9 @@ func (migration *updateIntegrations) Up(ctx context.Context, db *bun.DB) error {
return err
}
// ---
// cloud integrations
// ---
err = migration.
store.
Dialect().
@@ -207,7 +213,9 @@ func (migration *updateIntegrations) Up(ctx context.Context, db *bun.DB) error {
return err
}
// ---
// cloud integration service
// ---
err = migration.
store.
Dialect().

View File

@@ -93,9 +93,12 @@ 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) {
@@ -103,7 +106,8 @@ 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
}
@@ -111,7 +115,8 @@ 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
}

View File

@@ -14,9 +14,11 @@ 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) {
@@ -24,7 +26,8 @@ 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
}
@@ -32,7 +35,8 @@ 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
}

View 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
}

View File

@@ -4,11 +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/jmoiron/sqlx"
sqlite3 "github.com/mattn/go-sqlite3"
_ "github.com/mattn/go-sqlite3"
"github.com/uptrace/bun"
"github.com/uptrace/bun/dialect/sqlitedialect"
)
@@ -78,21 +77,3 @@ 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
}

View File

@@ -4,7 +4,6 @@ import (
"context"
"database/sql"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/jmoiron/sqlx"
"github.com/uptrace/bun"
)
@@ -31,12 +30,6 @@ 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 {

View File

@@ -6,7 +6,6 @@ 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"
@@ -75,11 +74,3 @@ 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...)
}

View File

@@ -4,16 +4,10 @@ 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

View File

@@ -2,6 +2,7 @@ package preferencetypes
import (
"context"
"fmt"
"strings"
"github.com/SigNoz/signoz/pkg/errors"
@@ -132,7 +133,7 @@ func NewDefaultPreferenceMap() map[string]Preference {
}
func (p *Preference) ErrorValueTypeMismatch() error {
return errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "the preference value is not of expected type: %s", p.ValueType)
return errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, fmt.Sprintf("the preference value is not of expected type: %s", p.ValueType))
}
func (p *Preference) checkIfInAllowedValues(preferenceValue interface{}) (bool, error) {
@@ -218,7 +219,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, "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, fmt.Sprintf("the preference value is not in the range specified, min: %v , max:%v", p.Range.Min, p.Range.Max))
}
}
case PreferenceValueTypeString:
@@ -247,7 +248,7 @@ func (p *Preference) IsValidValue(preferenceValue interface{}) error {
return valueMisMatchErr
}
if !isInAllowedValues {
return errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "the preference value is not in the list of allowedValues: %v", p.AllowedValues)
return errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, fmt.Sprintf("the preference value is not in the list of allowedValues: %v", p.AllowedValues))
}
}
}
@@ -279,11 +280,11 @@ func (p *Preference) SanitizeValue(preferenceValue interface{}) interface{} {
}
}
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
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
}

View File

@@ -0,0 +1,109 @@
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 {
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 {
// Common fields
FunnelID valuer.UUID `json:"funnel_id,omitempty"`
Steps []FunnelStep `json:"steps,omitempty"`
Timestamp int64 `json:"timestamp,omitempty"`
Description string `json:"description,omitempty"`
UserID string `json:"user_id,omitempty"`
Name string `json:"funnel_name,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 {
// Common fields
Description string `json:"description,omitempty"`
CreatedAt int64 `json:"created_at,omitempty"`
UpdatedAt int64 `json:"updated_at,omitempty"`
CreatedBy string `json:"created_by,omitempty"`
UpdatedBy string `json:"updated_by,omitempty"`
OrgID string `json:"org_id,omitempty"`
Steps []FunnelStep `json:"steps,omitempty"`
// Special fields
FunnelID string `json:"funnel_id,omitempty"`
FunnelName string `json:"funnel_name,omitempty"`
CreationTimestamp int64 `json:"creation_timestamp,omitempty"`
UserEmail string `json:"user_email,omitempty"`
Funnel *Funnel `json:"funnel,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"`
}

View File

@@ -0,0 +1,16 @@
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
UpdateMetadata(context.Context, valuer.UUID, string, string, string) error
}