Compare commits

..

9 Commits

Author SHA1 Message Date
Karan Balani
bcfb986127 feat: add zeus handler with profile and host apis 2026-02-18 01:26:29 +05:30
Abhi kumar
7f1d350ffe chore: hide chartmanager when queries are merged (#10332)
Some checks are pending
build-staging / prepare (push) Waiting to run
build-staging / js-build (push) Blocked by required conditions
build-staging / go-build (push) Blocked by required conditions
Release Drafter / update_release_draft (push) Waiting to run
build-staging / staging (push) Blocked by required conditions
* chore: hide chartmanager when queries are merged

* chore: added histogram panel tests
2026-02-17 14:02:23 +00:00
Ashwin Bhatkal
1d3134959d refactor: dashboard header components (#10326)
* chore: dashboard header components

* chore: small format
2026-02-17 13:41:03 +00:00
Abhi kumar
b86bd24dd9 feat: added new histogram panel (#10328)
* chore: refactored the config builder and added base config builder

* chore: added a common chart wrapper

* chore: tsc fix

* fix: pr review changes

* fix: pr review changes

* chore: added different tooltips

* chore: removed dayjs extention

* feat: added new barpanel component

* fix: added fix for pr review changes

* chore: added support for bar alignment configuration

* feat: added new histogram panel

* chore: updated structure for bar panel

* fix: fixed merge histogram breaking issue

* chore: pr review changes

* chore: added fill mode + bar color when merged

* chore: code cleanup

* chore: added fill mode + bar color when merged

* feat: added new histogram panel
2026-02-17 13:26:39 +00:00
Srikanth Chekuri
4c49d45cbf fix: update rate/increase query and address several issues in builder… (#10299) 2026-02-17 13:09:58 +00:00
Abhi kumar
9b3d3453b1 feat: added new histogram panel component (#10278)
* chore: refactored the config builder and added base config builder

* chore: added a common chart wrapper

* chore: tsc fix

* fix: pr review changes

* fix: pr review changes

* chore: added different tooltips

* chore: removed dayjs extention

* feat: added new barpanel component

* fix: added fix for pr review changes

* chore: added support for bar alignment configuration

* feat: added new histogram panel

* chore: updated structure for bar panel

* fix: fixed merge histogram breaking issue

* chore: pr review changes

* chore: added fill mode + bar color when merged

* chore: code cleanup

* chore: added fill mode + bar color when merged
2026-02-17 12:38:54 +00:00
Karan Balani
9d981d8a13 feat: improve root user provisioning and restore users unique index (#10327)
* chore: pushing forward root user

* chore: fix message

* fix: config naming
2026-02-17 17:53:13 +05:30
Pandey
6de4520a95 feat: add root user support (#10313)
## Summary

- Adds root user support with environment-based provisioning, protection guards, and automatic reconciliation. A root user is a special admin user that is provisioned via configuration (environment variables) rather than the UI, designed for automated/headless deployments.

## Key Features
- Environment-based provisioning: Configure root user via user.root.enabled, user.root.email, user.root.password, and user.root.org_name settings

- Automatic reconciliation: A background service runs on startup that:
    - Looks up the organization by configured org_name
    - If no matching org exists, creates the organization and root user via CreateFirstUser
    - If the org exists, reconciles the root user (creates, promotes existing user, or updates email/password to match config)
    - Retries every 10 seconds until successful

- Protection guards: Root users cannot be:
    - Updated or deleted through the API
    - Invited or have their password changed through the UI
    - Authenticated via SSO/SAML (password-only authentication enforced)

- Self-registration disabled: When root user provisioning is enabled, the self-registration endpoint (/register) is blocked to prevent creating duplicate organizations

- Idempotent password sync: On every reconciliation, the root user's password is synced with the configured value — if it differs, it's updated; if it matches, no-op
2026-02-17 15:26:56 +05:30
Abhishek Kumar Singh
f566909320 fix: added support for ucum based units in converter (#10284) 2026-02-17 14:09:43 +05:30
138 changed files with 4716 additions and 5532 deletions

View File

@@ -176,25 +176,6 @@ Wir haben Benchmarks veröffentlicht, die Loki mit SigNoz vergleichen. Schauen S
Wir ❤️ Beiträge zum Projekt, egal ob große oder kleine. Bitte lies dir zuerst die [CONTRIBUTING.md](CONTRIBUTING.md), durch, bevor du anfängst, Beiträge zu SigNoz zu machen.
Du bist dir nicht sicher, wie du anfangen sollst? Schreib uns einfach auf dem #contributing Kanal in unserer [slack community](https://signoz.io/slack)
### Unsere Projektbetreuer
#### Backend
- [Ankit Nayan](https://github.com/ankitnayan)
- [Nityananda Gohain](https://github.com/nityanandagohain)
- [Srikanth Chekuri](https://github.com/srikanthccv)
- [Vishal Sharma](https://github.com/makeavish)
#### Frontend
- [Palash Gupta](https://github.com/palashgdev)
- [Yunus M](https://github.com/YounixM)
- [Rajat Dabade](https://github.com/Rajat-Dabade)
#### DevOps
- [Prashant Shahi](https://github.com/prashant-shahi)
<br /><br />
## Dokumentation

View File

@@ -221,34 +221,6 @@ We ❤️ contributions big or small. Please read [CONTRIBUTING.md](CONTRIBUTING
Not sure how to get started? Just ping us on `#contributing` in our [slack community](https://signoz.io/slack)
### Project maintainers
#### Backend
- [Ankit Nayan](https://github.com/ankitnayan)
- [Nityananda Gohain](https://github.com/nityanandagohain)
- [Srikanth Chekuri](https://github.com/srikanthccv)
- [Vishal Sharma](https://github.com/makeavish)
- [Shivanshu Raj Shrivastava](https://github.com/shivanshuraj1333)
- [Ekansh Gupta](https://github.com/eKuG)
- [Aniket Agarwal](https://github.com/aniketio-ctrl)
#### Frontend
- [Yunus M](https://github.com/YounixM)
- [Vikrant Gupta](https://github.com/vikrantgupta25)
- [Sagar Rajput](https://github.com/SagarRajput-7)
- [Shaheer Kochai](https://github.com/ahmadshaheer)
- [Amlan Kumar Nandy](https://github.com/amlannandy)
- [Sahil Khan](https://github.com/sawhil)
- [Aditya Singh](https://github.com/aks07)
- [Abhi Kumar](https://github.com/ahrefabhi)
#### DevOps
- [Prashant Shahi](https://github.com/prashant-shahi)
- [Vibhu Pandey](https://github.com/therealpandey)
<br /><br />

View File

@@ -187,25 +187,6 @@ Jaeger 仅仅是一个分布式追踪系统。 但是 SigNoz 可以提供 metric
如果你不知道如何开始? 只需要在 [slack 社区](https://signoz.io/slack) 通过 `#contributing` 频道联系我们。
### 项目维护人员
#### 后端
- [Ankit Nayan](https://github.com/ankitnayan)
- [Nityananda Gohain](https://github.com/nityanandagohain)
- [Srikanth Chekuri](https://github.com/srikanthccv)
- [Vishal Sharma](https://github.com/makeavish)
#### 前端
- [Palash Gupta](https://github.com/palashgdev)
- [Yunus M](https://github.com/YounixM)
- [Rajat Dabade](https://github.com/Rajat-Dabade)
#### 运维开发
- [Prashant Shahi](https://github.com/prashant-shahi)
<br /><br />
## 文档

View File

@@ -294,7 +294,6 @@ flagger:
config:
boolean:
use_span_metrics: true
interpolation_enabled: false
kafka_span_eval: false
string:
float:
@@ -309,3 +308,14 @@ user:
allow_self: true
# The duration within which a user can reset their password.
max_token_lifetime: 6h
root:
# Whether to enable the root user. When enabled, a root user is provisioned
# on startup using the email and password below. The root user cannot be
# deleted, updated, or have their password changed through the UI.
enabled: false
# The email address of the root user.
email: ""
# The password of the root user. Must meet password requirements.
password: ""
# The name of the organization to create or look up for the root user.
org_name: default

View File

@@ -4678,6 +4678,8 @@ components:
type: string
id:
type: string
isRoot:
type: boolean
orgId:
type: string
role:

View File

@@ -45,7 +45,7 @@ type APIHandler struct {
}
// NewAPIHandler returns an APIHandler
func NewAPIHandler(opts APIHandlerOptions, signoz *signoz.SigNoz) (*APIHandler, error) {
func NewAPIHandler(opts APIHandlerOptions, signoz *signoz.SigNoz, config signoz.Config) (*APIHandler, error) {
baseHandler, err := baseapp.NewAPIHandler(baseapp.APIHandlerOpts{
Reader: opts.DataConnector,
RuleManager: opts.RulesManager,
@@ -58,7 +58,7 @@ func NewAPIHandler(opts APIHandlerOptions, signoz *signoz.SigNoz) (*APIHandler,
Signoz: signoz,
QuerierAPI: querierAPI.NewAPI(signoz.Instrumentation.ToProviderSettings(), signoz.Querier, signoz.Analytics),
QueryParserAPI: queryparser.NewAPI(signoz.Instrumentation.ToProviderSettings(), signoz.QueryParser),
})
}, config)
if err != nil {
return nil, err

View File

@@ -175,7 +175,7 @@ func NewServer(config signoz.Config, signoz *signoz.SigNoz) (*Server, error) {
GlobalConfig: config.Global,
}
apiHandler, err := api.NewAPIHandler(apiOpts, signoz)
apiHandler, err := api.NewAPIHandler(apiOpts, signoz, config)
if err != nil {
return nil, err
}

View File

@@ -3,6 +3,7 @@ package httpzeus
import (
"bytes"
"context"
"encoding/json"
"io"
"net/http"
"net/url"
@@ -10,6 +11,7 @@ import (
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/factory"
"github.com/SigNoz/signoz/pkg/http/client"
"github.com/SigNoz/signoz/pkg/types/zeustypes"
"github.com/SigNoz/signoz/pkg/zeus"
"github.com/tidwall/gjson"
)
@@ -119,8 +121,13 @@ func (provider *Provider) PutMeters(ctx context.Context, key string, data []byte
return err
}
func (provider *Provider) PutProfile(ctx context.Context, key string, body []byte) error {
_, err := provider.do(
func (provider *Provider) PutProfile(ctx context.Context, key string, profile *zeustypes.PostableProfile) error {
body, err := json.Marshal(profile)
if err != nil {
return err
}
_, err = provider.do(
ctx,
provider.config.URL.JoinPath("/v2/profiles/me"),
http.MethodPut,
@@ -131,8 +138,13 @@ func (provider *Provider) PutProfile(ctx context.Context, key string, body []byt
return err
}
func (provider *Provider) PutHost(ctx context.Context, key string, body []byte) error {
_, err := provider.do(
func (provider *Provider) PutHost(ctx context.Context, key string, host *zeustypes.PostableHost) error {
body, err := json.Marshal(host)
if err != nil {
return err
}
_, err = provider.do(
ctx,
provider.config.URL.JoinPath("/v2/deployments/me/hosts"),
http.MethodPut,

View File

@@ -58,7 +58,6 @@
"@signozhq/radio-group": "0.0.2",
"@signozhq/resizable": "0.0.0",
"@signozhq/sonner": "0.1.0",
"@signozhq/switch": "0.0.2",
"@signozhq/table": "0.3.7",
"@signozhq/tooltip": "0.0.2",
"@tanstack/react-table": "8.20.6",

View File

@@ -1542,6 +1542,10 @@ export interface TypesUserDTO {
* @type string
*/
id?: string;
/**
* @type boolean
*/
isRoot?: boolean;
/**
* @type string
*/

View File

@@ -0,0 +1,19 @@
import axios from 'api';
import { ErrorResponseHandlerV2 } from 'api/ErrorResponseHandlerV2';
import { AxiosError } from 'axios';
import { ErrorV2Resp, SuccessResponseV2 } from 'types/api';
const deleteDomain = async (id: string): Promise<SuccessResponseV2<null>> => {
try {
const response = await axios.delete<null>(`/domains/${id}`);
return {
httpStatusCode: response.status,
data: null,
};
} catch (error) {
ErrorResponseHandlerV2(error as AxiosError<ErrorV2Resp>);
}
};
export default deleteDomain;

View File

@@ -0,0 +1,25 @@
import axios from 'api';
import { ErrorResponseHandlerV2 } from 'api/ErrorResponseHandlerV2';
import { AxiosError } from 'axios';
import { ErrorV2Resp, RawSuccessResponse, SuccessResponseV2 } from 'types/api';
import { UpdatableAuthDomain } from 'types/api/v1/domains/put';
const put = async (
props: UpdatableAuthDomain,
): Promise<SuccessResponseV2<null>> => {
try {
const response = await axios.put<RawSuccessResponse<null>>(
`/domains/${props.id}`,
{ config: props.config },
);
return {
httpStatusCode: response.status,
data: response.data.data,
};
} catch (error) {
ErrorResponseHandlerV2(error as AxiosError<ErrorV2Resp>);
}
};
export default put;

View File

@@ -0,0 +1,24 @@
import axios from 'api';
import { ErrorResponseHandlerV2 } from 'api/ErrorResponseHandlerV2';
import { AxiosError } from 'axios';
import { ErrorV2Resp, RawSuccessResponse, SuccessResponseV2 } from 'types/api';
import { GettableAuthDomain } from 'types/api/v1/domains/list';
const listAllDomain = async (): Promise<
SuccessResponseV2<GettableAuthDomain[]>
> => {
try {
const response = await axios.get<RawSuccessResponse<GettableAuthDomain[]>>(
`/domains`,
);
return {
httpStatusCode: response.status,
data: response.data.data,
};
} catch (error) {
ErrorResponseHandlerV2(error as AxiosError<ErrorV2Resp>);
}
};
export default listAllDomain;

View File

@@ -0,0 +1,26 @@
import axios from 'api';
import { ErrorResponseHandlerV2 } from 'api/ErrorResponseHandlerV2';
import { AxiosError } from 'axios';
import { ErrorV2Resp, RawSuccessResponse, SuccessResponseV2 } from 'types/api';
import { GettableAuthDomain } from 'types/api/v1/domains/list';
import { PostableAuthDomain } from 'types/api/v1/domains/post';
const post = async (
props: PostableAuthDomain,
): Promise<SuccessResponseV2<GettableAuthDomain>> => {
try {
const response = await axios.post<RawSuccessResponse<GettableAuthDomain>>(
`/domains`,
props,
);
return {
httpStatusCode: response.status,
data: response.data.data,
};
} catch (error) {
ErrorResponseHandlerV2(error as AxiosError<ErrorV2Resp>);
}
};
export default post;

View File

@@ -24,6 +24,5 @@ import '@signozhq/popover';
import '@signozhq/radio-group';
import '@signozhq/resizable';
import '@signozhq/sonner';
import '@signozhq/switch';
import '@signozhq/table';
import '@signozhq/tooltip';

View File

@@ -9,74 +9,6 @@
padding: 0px;
}
.dashboard-header {
border-bottom: 1px solid var(--bg-slate-400);
display: flex;
justify-content: space-between;
gap: 16px;
align-items: center;
padding: 0 8px;
box-sizing: border-box;
.dashboard-breadcrumbs {
width: 100%;
height: 48px;
display: flex;
gap: 6px;
align-items: center;
max-width: 80%;
.dashboard-btn {
display: flex;
align-items: center;
color: var(--bg-vanilla-400);
font-family: Inter;
font-size: 14px;
font-style: normal;
font-weight: 400;
line-height: 20px; /* 142.857% */
letter-spacing: -0.07px;
padding: 0px;
height: 20px;
}
.dashboard-btn:hover {
background-color: unset;
}
.id-btn {
display: flex;
align-items: center;
padding: 0px 2px;
border-radius: 2px;
background: rgba(113, 144, 249, 0.1);
color: var(--bg-robin-400);
font-family: Inter;
font-size: 14px;
font-style: normal;
font-weight: 400;
line-height: 20px; /* 142.857% */
height: 20px;
max-width: calc(100% - 120px);
span {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.ant-btn-icon {
margin-inline-end: 4px;
}
}
.id-btn:hover {
background: rgba(113, 144, 249, 0.1);
color: var(--bg-robin-300);
}
}
}
.dashboard-details {
display: flex;
justify-content: space-between;
@@ -535,15 +467,6 @@
.dashboard-description-container {
color: var(--bg-ink-400);
.dashboard-header {
border-bottom: 1px solid var(--bg-vanilla-300);
.dashboard-breadcrumbs {
.dashboard-btn {
color: var(--bg-ink-400);
}
}
}
.dashboard-details {
.left-section {
.dashboard-title {

View File

@@ -16,9 +16,7 @@ import {
} from 'antd';
import logEvent from 'api/common/logEvent';
import ConfigureIcon from 'assets/Integrations/ConfigureIcon';
import HeaderRightSection from 'components/HeaderRightSection/HeaderRightSection';
import { PANEL_GROUP_TYPES, PANEL_TYPES } from 'constants/queryBuilder';
import ROUTES from 'constants/routes';
import { DeleteButton } from 'container/ListOfDashboard/TableComponents/DeleteButton';
import DateTimeSelectionV2 from 'container/TopNav/DateTimeSelectionV2';
import { useDashboardVariables } from 'hooks/dashboard/useDashboardVariables';
@@ -27,7 +25,6 @@ import { useUpdateDashboard } from 'hooks/dashboard/useUpdateDashboard';
import useComponentPermission from 'hooks/useComponentPermission';
import { useGetTenantLicense } from 'hooks/useGetTenantLicense';
import { useNotifications } from 'hooks/useNotifications';
import { useSafeNavigate } from 'hooks/useSafeNavigate';
import { isEmpty } from 'lodash-es';
import {
Check,
@@ -37,7 +34,6 @@ import {
FolderKanban,
Fullscreen,
Globe,
LayoutGrid,
LockKeyhole,
PenLine,
X,
@@ -51,6 +47,7 @@ import { ROLES, USER_ROLES } from 'types/roles';
import { ComponentTypes } from 'utils/permission';
import { v4 as uuid } from 'uuid';
import DashboardHeader from '../components/DashboardHeader/DashboardHeader';
import DashboardGraphSlider from '../ComponentsSlider';
import DashboardSettings from '../DashboardSettings';
import { Base64Icons } from '../DashboardSettings/General/utils';
@@ -71,7 +68,6 @@ interface DashboardDescriptionProps {
// eslint-disable-next-line sonarjs/cognitive-complexity
function DashboardDescription(props: DashboardDescriptionProps): JSX.Element {
const { safeNavigate } = useSafeNavigate();
const { handle } = props;
const {
selectedDashboard,
@@ -80,7 +76,6 @@ function DashboardDescription(props: DashboardDescriptionProps): JSX.Element {
layouts,
setLayouts,
isDashboardLocked,
listSortOrder,
setSelectedDashboard,
handleToggleDashboardSlider,
setSelectedRowWidgetId,
@@ -292,17 +287,6 @@ function DashboardDescription(props: DashboardDescriptionProps): JSX.Element {
});
}
function goToListPage(): void {
const urlParams = new URLSearchParams();
urlParams.set('columnKey', listSortOrder.columnKey as string);
urlParams.set('order', listSortOrder.order as string);
urlParams.set('page', listSortOrder.pagination as string);
urlParams.set('search', listSortOrder.search as string);
const generatedUrl = `${ROUTES.ALL_DASHBOARD}?${urlParams.toString()}`;
safeNavigate(generatedUrl);
}
const {
data: publicDashboardResponse,
isLoading: isLoadingPublicDashboardData,
@@ -351,32 +335,7 @@ function DashboardDescription(props: DashboardDescriptionProps): JSX.Element {
return (
<Card className="dashboard-description-container">
<div className="dashboard-header">
<section className="dashboard-breadcrumbs">
<Button
type="text"
icon={<LayoutGrid size={14} />}
className="dashboard-btn"
onClick={(): void => goToListPage()}
>
Dashboard /
</Button>
<Button type="text" className="id-btn dashboard-name-btn">
<img
src={image}
alt="dashboard-icon"
style={{ height: '14px', width: '14px' }}
/>
{title}
</Button>
</section>
<HeaderRightSection
enableAnnouncements={false}
enableShare
enableFeedback
/>
</div>
<DashboardHeader />
<section className="dashboard-details">
<div className="left-section">
<img src={image} alt="dashboard-img" className="dashboard-img" />

View File

@@ -0,0 +1,71 @@
.dashboard-breadcrumbs {
width: 100%;
height: 48px;
display: flex;
gap: 6px;
align-items: center;
max-width: 80%;
.dashboard-btn {
display: flex;
align-items: center;
color: var(--bg-vanilla-400);
font-family: Inter;
font-size: 14px;
font-style: normal;
font-weight: 400;
line-height: 20px; /* 142.857% */
letter-spacing: -0.07px;
padding: 0px;
height: 20px;
}
.dashboard-btn:hover {
background-color: unset;
}
.id-btn {
display: flex;
align-items: center;
gap: 4px;
padding: 0px 2px;
border-radius: 2px;
background: rgba(113, 144, 249, 0.1);
color: var(--bg-robin-400);
font-family: Inter;
font-size: 14px;
font-style: normal;
font-weight: 400;
line-height: 20px; /* 142.857% */
height: 20px;
max-width: calc(100% - 120px);
span {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.ant-btn-icon {
margin-inline-end: 4px;
}
}
.id-btn:hover {
background: rgba(113, 144, 249, 0.1);
color: var(--bg-robin-300);
}
.dashboard-icon-image {
height: 14px;
width: 14px;
}
}
.lightMode {
.dashboard-breadcrumbs {
.dashboard-btn {
color: var(--bg-ink-400);
}
}
}

View File

@@ -0,0 +1,55 @@
import { useCallback } from 'react';
import { Button } from 'antd';
import ROUTES from 'constants/routes';
import { useSafeNavigate } from 'hooks/useSafeNavigate';
import { LayoutGrid } from 'lucide-react';
import { useDashboard } from 'providers/Dashboard/Dashboard';
import { DashboardData } from 'types/api/dashboard/getAll';
import { Base64Icons } from '../../DashboardSettings/General/utils';
import './DashboardBreadcrumbs.styles.scss';
function DashboardBreadcrumbs(): JSX.Element {
const { safeNavigate } = useSafeNavigate();
const { selectedDashboard, listSortOrder } = useDashboard();
const selectedData = selectedDashboard
? {
...selectedDashboard.data,
uuid: selectedDashboard.id,
}
: ({} as DashboardData);
const { title = '', image = Base64Icons[0] } = selectedData || {};
const goToListPage = useCallback(() => {
const urlParams = new URLSearchParams();
urlParams.set('columnKey', listSortOrder.columnKey as string);
urlParams.set('order', listSortOrder.order as string);
urlParams.set('page', listSortOrder.pagination as string);
urlParams.set('search', listSortOrder.search as string);
const generatedUrl = `${ROUTES.ALL_DASHBOARD}?${urlParams.toString()}`;
safeNavigate(generatedUrl);
}, [listSortOrder, safeNavigate]);
return (
<div className="dashboard-breadcrumbs">
<Button
type="text"
icon={<LayoutGrid size={14} />}
className="dashboard-btn"
onClick={goToListPage}
>
Dashboard /
</Button>
<Button type="text" className="id-btn dashboard-name-btn">
<img src={image} alt="dashboard-icon" className="dashboard-icon-image" />
{title}
</Button>
</div>
);
}
export default DashboardBreadcrumbs;

View File

@@ -0,0 +1,15 @@
.dashboard-header {
border-bottom: 1px solid var(--bg-slate-400);
display: flex;
justify-content: space-between;
gap: 16px;
align-items: center;
padding: 0 8px;
box-sizing: border-box;
}
.lightMode {
.dashboard-header {
border-bottom: 1px solid var(--bg-vanilla-300);
}
}

View File

@@ -0,0 +1,17 @@
import { memo } from 'react';
import HeaderRightSection from 'components/HeaderRightSection/HeaderRightSection';
import DashboardBreadcrumbs from './DashboardBreadcrumbs';
import './DashboardHeader.styles.scss';
function DashboardHeader(): JSX.Element {
return (
<div className="dashboard-header">
<DashboardBreadcrumbs />
<HeaderRightSection enableAnnouncements={false} enableShare enableFeedback />
</div>
);
}
export default memo(DashboardHeader);

View File

@@ -23,6 +23,7 @@ export default function ChartWrapper({
width: containerWidth,
height: containerHeight,
showTooltip = true,
showLegend = true,
canPinTooltip = false,
syncMode,
syncKey,
@@ -36,6 +37,9 @@ export default function ChartWrapper({
const legendComponent = useCallback(
(averageLegendWidth: number): React.ReactNode => {
if (!showLegend) {
return null;
}
return (
<Legend
config={config}
@@ -44,7 +48,7 @@ export default function ChartWrapper({
/>
);
},
[config, legendConfig.position],
[config, legendConfig.position, showLegend],
);
const renderTooltipCallback = useCallback(
@@ -60,6 +64,7 @@ export default function ChartWrapper({
return (
<PlotContextProvider>
<ChartLayout
showLegend={showLegend}
config={config}
containerWidth={containerWidth}
containerHeight={containerHeight}

View File

@@ -0,0 +1,55 @@
import { useCallback } from 'react';
import ChartWrapper from 'container/DashboardContainer/visualization/charts/ChartWrapper/ChartWrapper';
import HistogramTooltip from 'lib/uPlotV2/components/Tooltip/HistogramTooltip';
import { buildTooltipContent } from 'lib/uPlotV2/components/Tooltip/utils';
import {
HistogramTooltipProps,
TooltipRenderArgs,
} from 'lib/uPlotV2/components/types';
import { HistogramChartProps } from '../types';
export default function Histogram(props: HistogramChartProps): JSX.Element {
const {
children,
renderTooltip: customRenderTooltip,
isQueriesMerged,
...rest
} = props;
const renderTooltip = useCallback(
(props: TooltipRenderArgs): React.ReactNode => {
if (customRenderTooltip) {
return customRenderTooltip(props);
}
const content = buildTooltipContent({
data: props.uPlotInstance.data,
series: props.uPlotInstance.series,
dataIndexes: props.dataIndexes,
activeSeriesIndex: props.seriesIndex,
uPlotInstance: props.uPlotInstance,
yAxisUnit: rest.yAxisUnit ?? '',
decimalPrecision: rest.decimalPrecision,
});
const tooltipProps: HistogramTooltipProps = {
...props,
timezone: rest.timezone,
yAxisUnit: rest.yAxisUnit,
decimalPrecision: rest.decimalPrecision,
content,
};
return <HistogramTooltip {...tooltipProps} />;
},
[customRenderTooltip, rest.timezone, rest.yAxisUnit, rest.decimalPrecision],
);
return (
<ChartWrapper
showLegend={!isQueriesMerged}
{...rest}
renderTooltip={renderTooltip}
>
{children}
</ChartWrapper>
);
}

View File

@@ -7,6 +7,7 @@ interface BaseChartProps {
width: number;
height: number;
showTooltip?: boolean;
showLegend?: boolean;
timezone: string;
canPinTooltip?: boolean;
yAxisUnit?: string;
@@ -17,6 +18,7 @@ interface BaseChartProps {
interface UPlotBasedChartProps {
config: UPlotConfigBuilder;
data: uPlot.AlignedData;
legendConfig: LegendConfig;
syncMode?: DashboardCursorSync;
syncKey?: string;
plotRef?: (plot: uPlot | null) => void;
@@ -26,14 +28,20 @@ interface UPlotBasedChartProps {
}
export interface TimeSeriesChartProps
extends BaseChartProps,
UPlotBasedChartProps {}
export interface HistogramChartProps
extends BaseChartProps,
UPlotBasedChartProps {
legendConfig: LegendConfig;
isQueriesMerged?: boolean;
}
export interface BarChartProps extends BaseChartProps, UPlotBasedChartProps {
legendConfig: LegendConfig;
isStackedBarChart?: boolean;
}
export type ChartProps = TimeSeriesChartProps | BarChartProps;
export type ChartProps =
| TimeSeriesChartProps
| BarChartProps
| HistogramChartProps;

View File

@@ -0,0 +1,69 @@
import { renderHook } from '@testing-library/react';
import { useDashboard } from 'providers/Dashboard/Dashboard';
import { useScrollWidgetIntoView } from '../useScrollWidgetIntoView';
jest.mock('providers/Dashboard/Dashboard');
type MockHTMLElement = {
scrollIntoView: jest.Mock;
focus: jest.Mock;
};
function createMockElement(): MockHTMLElement {
return {
scrollIntoView: jest.fn(),
focus: jest.fn(),
};
}
describe('useScrollWidgetIntoView', () => {
const mockedUseDashboard = useDashboard as jest.MockedFunction<
typeof useDashboard
>;
beforeEach(() => {
jest.clearAllMocks();
});
it('scrolls into view and focuses when toScrollWidgetId matches widget id', () => {
const setToScrollWidgetId = jest.fn();
const mockElement = createMockElement();
const ref = ({
current: mockElement,
} as unknown) as React.RefObject<HTMLDivElement>;
mockedUseDashboard.mockReturnValue(({
toScrollWidgetId: 'widget-id',
setToScrollWidgetId,
} as unknown) as ReturnType<typeof useDashboard>);
renderHook(() => useScrollWidgetIntoView('widget-id', ref));
expect(mockElement.scrollIntoView).toHaveBeenCalledWith({
behavior: 'smooth',
block: 'center',
});
expect(mockElement.focus).toHaveBeenCalled();
expect(setToScrollWidgetId).toHaveBeenCalledWith('');
});
it('does nothing when toScrollWidgetId does not match widget id', () => {
const setToScrollWidgetId = jest.fn();
const mockElement = createMockElement();
const ref = ({
current: mockElement,
} as unknown) as React.RefObject<HTMLDivElement>;
mockedUseDashboard.mockReturnValue(({
toScrollWidgetId: 'other-widget',
setToScrollWidgetId,
} as unknown) as ReturnType<typeof useDashboard>);
renderHook(() => useScrollWidgetIntoView('widget-id', ref));
expect(mockElement.scrollIntoView).not.toHaveBeenCalled();
expect(mockElement.focus).not.toHaveBeenCalled();
expect(setToScrollWidgetId).not.toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,26 @@
import { RefObject, useEffect } from 'react';
import { useDashboard } from 'providers/Dashboard/Dashboard';
/**
* Scrolls the given widget container into view when the dashboard
* requests it via `toScrollWidgetId`.
*
* Intended for use in panel components that render a single widget.
*/
export function useScrollWidgetIntoView<T extends HTMLElement>(
widgetId: string,
widgetContainerRef: RefObject<T>,
): void {
const { toScrollWidgetId, setToScrollWidgetId } = useDashboard();
useEffect(() => {
if (toScrollWidgetId === widgetId) {
widgetContainerRef.current?.scrollIntoView({
behavior: 'smooth',
block: 'center',
});
widgetContainerRef.current?.focus();
setToScrollWidgetId('');
}
}, [toScrollWidgetId, setToScrollWidgetId, widgetId, widgetContainerRef]);
}

View File

@@ -1,12 +1,14 @@
import { useMemo } from 'react';
import cx from 'classnames';
import { calculateChartDimensions } from 'container/DashboardContainer/visualization/charts/utils';
import { MAX_LEGEND_WIDTH } from 'lib/uPlotV2/components/Legend/Legend';
import { LegendConfig, LegendPosition } from 'lib/uPlotV2/components/types';
import { UPlotConfigBuilder } from 'lib/uPlotV2/config/UPlotConfigBuilder';
import './ChartLayout.styles.scss';
export interface ChartLayoutProps {
showLegend?: boolean;
legendComponent: (legendPerSet: number) => React.ReactNode;
children: (props: {
chartWidth: number;
@@ -20,6 +22,7 @@ export interface ChartLayoutProps {
config: UPlotConfigBuilder;
}
export default function ChartLayout({
showLegend = true,
legendComponent,
children,
layoutChildren,
@@ -30,6 +33,15 @@ export default function ChartLayout({
}: ChartLayoutProps): JSX.Element {
const chartDimensions = useMemo(
() => {
if (!showLegend) {
return {
width: containerWidth,
height: containerHeight,
legendWidth: 0,
legendHeight: 0,
averageLegendWidth: MAX_LEGEND_WIDTH,
};
}
const legendItemsMap = config.getLegendItems();
const seriesLabels = Object.values(legendItemsMap)
.map((item) => item.label)
@@ -42,7 +54,7 @@ export default function ChartLayout({
});
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[containerWidth, containerHeight, legendConfig],
[containerWidth, containerHeight, legendConfig, showLegend],
);
return (
@@ -60,15 +72,17 @@ export default function ChartLayout({
averageLegendWidth: chartDimensions.averageLegendWidth,
})}
</div>
<div
className="chart-layout__legend-wrapper"
style={{
height: chartDimensions.legendHeight,
width: chartDimensions.legendWidth,
}}
>
{legendComponent(chartDimensions.averageLegendWidth)}
</div>
{showLegend && (
<div
className="chart-layout__legend-wrapper"
style={{
height: chartDimensions.legendHeight,
width: chartDimensions.legendWidth,
}}
>
{legendComponent(chartDimensions.averageLegendWidth)}
</div>
)}
</div>
{layoutChildren}
</div>

View File

@@ -1,10 +1,10 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useScrollWidgetIntoView } from 'container/DashboardContainer/visualization/hooks/useScrollWidgetIntoView';
import { PanelWrapperProps } from 'container/PanelWrapper/panelWrapper.types';
import { useIsDarkMode } from 'hooks/useDarkMode';
import { useResizeObserver } from 'hooks/useDimensions';
import { LegendPosition } from 'lib/uPlotV2/components/types';
import ContextMenu from 'periscope/components/ContextMenu';
import { useDashboard } from 'providers/Dashboard/Dashboard';
import { useTimezone } from 'providers/Timezone';
import { MetricRangePayloadProps } from 'types/api/metrics/getQueryRange';
import uPlot from 'uplot';
@@ -27,7 +27,6 @@ function BarPanel(props: PanelWrapperProps): JSX.Element {
onToggleModelHandler,
} = props;
const uPlotRef = useRef<uPlot | null>(null);
const { toScrollWidgetId, setToScrollWidgetId } = useDashboard();
const graphRef = useRef<HTMLDivElement>(null);
const [minTimeScale, setMinTimeScale] = useState<number>();
const [maxTimeScale, setMaxTimeScale] = useState<number>();
@@ -36,16 +35,7 @@ function BarPanel(props: PanelWrapperProps): JSX.Element {
const isDarkMode = useIsDarkMode();
const { timezone } = useTimezone();
useEffect(() => {
if (toScrollWidgetId === widget.id) {
graphRef.current?.scrollIntoView({
behavior: 'smooth',
block: 'center',
});
graphRef.current?.focus();
setToScrollWidgetId('');
}
}, [toScrollWidgetId, setToScrollWidgetId, widget.id]);
useScrollWidgetIntoView(widget.id, graphRef);
useEffect((): void => {
const { startTime, endTime } = getTimeRange(queryResponse);

View File

@@ -0,0 +1,114 @@
import { useMemo, useRef } from 'react';
import { useScrollWidgetIntoView } from 'container/DashboardContainer/visualization/hooks/useScrollWidgetIntoView';
import { PanelWrapperProps } from 'container/PanelWrapper/panelWrapper.types';
import { useIsDarkMode } from 'hooks/useDarkMode';
import { useResizeObserver } from 'hooks/useDimensions';
import { LegendPosition } from 'lib/uPlotV2/components/types';
import { DashboardCursorSync } from 'lib/uPlotV2/plugins/TooltipPlugin/types';
import { useTimezone } from 'providers/Timezone';
import { MetricRangePayloadProps } from 'types/api/metrics/getQueryRange';
import uPlot from 'uplot';
import Histogram from '../../charts/Histogram/Histogram';
import ChartManager from '../../components/ChartManager/ChartManager';
import {
prepareHistogramPanelConfig,
prepareHistogramPanelData,
} from './utils';
import '../Panel.styles.scss';
function HistogramPanel(props: PanelWrapperProps): JSX.Element {
const {
panelMode,
queryResponse,
widget,
isFullViewMode,
onToggleModelHandler,
} = props;
const uPlotRef = useRef<uPlot | null>(null);
const graphRef = useRef<HTMLDivElement>(null);
const containerDimensions = useResizeObserver(graphRef);
const isDarkMode = useIsDarkMode();
const { timezone } = useTimezone();
useScrollWidgetIntoView(widget.id, graphRef);
const config = useMemo(() => {
return prepareHistogramPanelConfig({
widget,
isDarkMode,
apiResponse: queryResponse?.data?.payload as MetricRangePayloadProps,
panelMode,
});
}, [widget, isDarkMode, queryResponse?.data?.payload, panelMode]);
const chartData = useMemo(() => {
if (!queryResponse?.data?.payload) {
return [];
}
return prepareHistogramPanelData({
apiResponse: queryResponse?.data?.payload as MetricRangePayloadProps,
bucketWidth: widget?.bucketWidth,
bucketCount: widget?.bucketCount,
mergeAllActiveQueries: widget?.mergeAllActiveQueries,
});
}, [
queryResponse?.data?.payload,
widget?.bucketWidth,
widget?.bucketCount,
widget?.mergeAllActiveQueries,
]);
const layoutChildren = useMemo(() => {
if (!isFullViewMode || widget.mergeAllActiveQueries) {
return null;
}
return (
<ChartManager
config={config}
alignedData={chartData}
yAxisUnit={widget.yAxisUnit}
onCancel={onToggleModelHandler}
/>
);
}, [
isFullViewMode,
config,
chartData,
widget.yAxisUnit,
onToggleModelHandler,
widget.mergeAllActiveQueries,
]);
return (
<div className="panel-container" ref={graphRef}>
{containerDimensions.width > 0 && containerDimensions.height > 0 && (
<Histogram
config={config}
legendConfig={{
position: widget?.legendPosition ?? LegendPosition.BOTTOM,
}}
plotRef={(plot: uPlot | null): void => {
uPlotRef.current = plot;
}}
onDestroy={(): void => {
uPlotRef.current = null;
}}
isQueriesMerged={widget.mergeAllActiveQueries}
yAxisUnit={widget.yAxisUnit}
decimalPrecision={widget.decimalPrecision}
syncMode={DashboardCursorSync.Crosshair}
timezone={timezone.value}
data={chartData as uPlot.AlignedData}
width={containerDimensions.width}
height={containerDimensions.height}
layoutChildren={layoutChildren}
/>
)}
</div>
);
}
export default HistogramPanel;

View File

@@ -0,0 +1,223 @@
/* eslint-disable simple-import-sort/imports */
import type { UseQueryResult } from 'react-query';
import { render, screen } from 'tests/test-utils';
import { PanelMode } from 'container/DashboardContainer/visualization/panels/types';
import { LegendPosition } from 'lib/uPlotV2/components/types';
import { Widgets } from 'types/api/dashboard/getAll';
import {
MetricQueryRangeSuccessResponse,
MetricRangePayloadProps,
} from 'types/api/metrics/getQueryRange';
import HistogramPanel from '../HistogramPanel';
import { HistogramChartProps } from 'container/DashboardContainer/visualization/charts/types';
jest.mock('hooks/useDimensions', () => ({
useResizeObserver: jest.fn().mockReturnValue({ width: 800, height: 400 }),
}));
jest.mock('hooks/useDarkMode', () => ({
useIsDarkMode: jest.fn().mockReturnValue(false),
}));
jest.mock('providers/Timezone', () => ({
__esModule: true,
// Provide a no-op provider component so AllTheProviders can render
default: ({ children }: { children: React.ReactNode }): JSX.Element => (
<>{children}</>
),
// And mock the hook used by HistogramPanel
useTimezone: jest.fn().mockReturnValue({
timezone: { value: 'UTC' },
}),
}));
jest.mock(
'container/DashboardContainer/visualization/hooks/useScrollWidgetIntoView',
() => ({
useScrollWidgetIntoView: jest.fn(),
}),
);
jest.mock(
'container/DashboardContainer/visualization/charts/Histogram/Histogram',
() => ({
__esModule: true,
default: (props: HistogramChartProps): JSX.Element => (
<div data-testid="histogram-chart">
<div data-testid="histogram-props">
{JSON.stringify({
legendPosition: props.legendConfig?.position,
isQueriesMerged: props.isQueriesMerged,
yAxisUnit: props.yAxisUnit,
decimalPrecision: props.decimalPrecision,
})}
</div>
{props.layoutChildren}
</div>
),
}),
);
jest.mock(
'container/DashboardContainer/visualization/components/ChartManager/ChartManager',
() => ({
__esModule: true,
default: (): JSX.Element => (
<div data-testid="chart-manager">ChartManager</div>
),
}),
);
function createQueryResponse(
payloadOverrides: Partial<MetricRangePayloadProps> = {},
): { data: { payload: MetricRangePayloadProps } } {
const basePayload: MetricRangePayloadProps = {
data: {
result: [
{
metric: {},
queryName: 'A',
legend: 'Series A',
values: [
[1, '10'],
[2, '20'],
],
},
],
resultType: 'matrix',
newResult: {
data: {
result: [],
resultType: 'matrix',
},
},
},
};
return {
data: {
payload: {
...basePayload,
...payloadOverrides,
},
},
};
}
type WidgetLike = {
id: string;
yAxisUnit: string;
decimalPrecision: number;
legendPosition: LegendPosition;
mergeAllActiveQueries: boolean;
};
function createWidget(overrides: Partial<WidgetLike> = {}): WidgetLike {
return {
id: 'widget-id',
yAxisUnit: 'ms',
decimalPrecision: 2,
legendPosition: LegendPosition.BOTTOM,
mergeAllActiveQueries: false,
...overrides,
};
}
describe('HistogramPanel', () => {
it('renders Histogram when container has dimensions', () => {
const widget = (createWidget() as unknown) as Widgets;
const queryResponse = (createQueryResponse() as unknown) as UseQueryResult<
MetricQueryRangeSuccessResponse,
Error
>;
render(
<HistogramPanel
panelMode={PanelMode.DASHBOARD_VIEW}
widget={widget}
queryResponse={queryResponse}
isFullViewMode={false}
onToggleModelHandler={jest.fn()}
onDragSelect={jest.fn()}
/>,
);
expect(screen.getByTestId('histogram-chart')).toBeInTheDocument();
});
it('passes legend position and other props to Histogram', () => {
const widget = (createWidget({
legendPosition: LegendPosition.RIGHT,
}) as unknown) as Widgets;
const queryResponse = (createQueryResponse() as unknown) as UseQueryResult<
MetricQueryRangeSuccessResponse,
Error
>;
render(
<HistogramPanel
panelMode={PanelMode.DASHBOARD_VIEW}
widget={widget}
queryResponse={queryResponse}
isFullViewMode={false}
onToggleModelHandler={jest.fn()}
onDragSelect={jest.fn()}
/>,
);
const propsJson = screen.getByTestId('histogram-props').textContent || '{}';
const parsed = JSON.parse(propsJson);
expect(parsed.legendPosition).toBe(LegendPosition.RIGHT);
expect(parsed.yAxisUnit).toBe('ms');
expect(parsed.decimalPrecision).toBe(2);
});
it('renders ChartManager in full view when queries are not merged', () => {
const widget = (createWidget({
mergeAllActiveQueries: false,
}) as unknown) as Widgets;
const queryResponse = (createQueryResponse() as unknown) as UseQueryResult<
MetricQueryRangeSuccessResponse,
Error
>;
render(
<HistogramPanel
panelMode={PanelMode.DASHBOARD_VIEW}
widget={widget}
queryResponse={queryResponse}
isFullViewMode
onToggleModelHandler={jest.fn()}
onDragSelect={jest.fn()}
/>,
);
expect(screen.getByTestId('chart-manager')).toBeInTheDocument();
});
it('does not render ChartManager when queries are merged', () => {
const widget = (createWidget({
mergeAllActiveQueries: true,
}) as unknown) as Widgets;
const queryResponse = (createQueryResponse() as unknown) as UseQueryResult<
MetricQueryRangeSuccessResponse,
Error
>;
render(
<HistogramPanel
panelMode={PanelMode.DASHBOARD_VIEW}
widget={widget}
queryResponse={queryResponse}
isFullViewMode
onToggleModelHandler={jest.fn()}
onDragSelect={jest.fn()}
/>,
);
expect(screen.queryByTestId('chart-manager')).not.toBeInTheDocument();
});
});

View File

@@ -0,0 +1,231 @@
import { histogramBucketSizes } from '@grafana/data';
import { PANEL_TYPES } from 'constants/queryBuilder';
import { DEFAULT_BUCKET_COUNT } from 'container/PanelWrapper/constants';
import { getLegend } from 'lib/dashboard/getQueryResults';
import getLabelName from 'lib/getLabelName';
import { DrawStyle } from 'lib/uPlotV2/config/types';
import { UPlotConfigBuilder } from 'lib/uPlotV2/config/UPlotConfigBuilder';
import { Widgets } from 'types/api/dashboard/getAll';
import { MetricRangePayloadProps } from 'types/api/metrics/getQueryRange';
import { AlignedData } from 'uplot';
import { incrRoundDn, roundDecimals } from 'utils/round';
import { PanelMode } from '../types';
import { buildBaseConfig } from '../utils/baseConfigBuilder';
import {
buildHistogramBuckets,
mergeAlignedDataTables,
prependNullBinToFirstHistogramSeries,
replaceUndefinedWithNullInAlignedData,
} from '../utils/histogram';
export interface PrepareHistogramPanelDataParams {
apiResponse: MetricRangePayloadProps;
bucketWidth?: number;
bucketCount?: number;
mergeAllActiveQueries?: boolean;
}
const BUCKET_OFFSET = 0;
const HIST_SORT = (a: number, b: number): number => a - b;
function extractNumericValues(
result: MetricRangePayloadProps['data']['result'],
): number[] {
const values: number[] = [];
for (const item of result) {
for (const [, valueStr] of item.values) {
values.push(Number.parseFloat(valueStr) || 0);
}
}
return values;
}
function computeSmallestDelta(sortedValues: number[]): number {
if (sortedValues.length <= 1) {
return 0;
}
let smallest = Infinity;
for (let i = 1; i < sortedValues.length; i++) {
const delta = sortedValues[i] - sortedValues[i - 1];
if (delta > 0) {
smallest = Math.min(smallest, delta);
}
}
return smallest === Infinity ? 0 : smallest;
}
function selectBucketSize({
range,
bucketCount,
smallestDelta,
bucketWidthOverride,
}: {
range: number;
bucketCount: number;
smallestDelta: number;
bucketWidthOverride?: number;
}): number {
if (bucketWidthOverride != null && bucketWidthOverride > 0) {
return bucketWidthOverride;
}
const targetSize = range / bucketCount;
for (const candidate of histogramBucketSizes) {
if (targetSize < candidate && candidate >= smallestDelta) {
return candidate;
}
}
return 0;
}
function buildFrames(
result: MetricRangePayloadProps['data']['result'],
mergeAllActiveQueries: boolean,
): number[][] {
const frames: number[][] = result.map((item) =>
item.values.map(([, valueStr]) => Number.parseFloat(valueStr) || 0),
);
if (mergeAllActiveQueries && frames.length > 1) {
const first = frames[0];
for (let i = 1; i < frames.length; i++) {
first.push(...frames[i]);
frames[i] = [];
}
}
return frames;
}
export function prepareHistogramPanelData({
apiResponse,
bucketWidth,
bucketCount: bucketCountProp = DEFAULT_BUCKET_COUNT,
mergeAllActiveQueries = false,
}: PrepareHistogramPanelDataParams): AlignedData {
const bucketCount = bucketCountProp ?? DEFAULT_BUCKET_COUNT;
const result = apiResponse.data.result;
const seriesValues = extractNumericValues(result);
if (seriesValues.length === 0) {
return [[]];
}
const sorted = [...seriesValues].sort((a, b) => a - b);
const min = sorted[0];
const max = sorted[sorted.length - 1];
const range = max - min;
const smallestDelta = computeSmallestDelta(sorted);
let bucketSize = selectBucketSize({
range,
bucketCount,
smallestDelta,
bucketWidthOverride: bucketWidth,
});
if (bucketSize <= 0) {
bucketSize = range > 0 ? range / bucketCount : 1;
}
const getBucket = (v: number): number =>
roundDecimals(incrRoundDn(v - BUCKET_OFFSET, bucketSize) + BUCKET_OFFSET, 9);
const frames = buildFrames(result, mergeAllActiveQueries);
const histogramsPerSeries: AlignedData[] = frames
.filter((frame) => frame.length > 0)
.map((frame) => buildHistogramBuckets(frame, getBucket, HIST_SORT));
if (histogramsPerSeries.length === 0) {
return [[]];
}
const mergedHistogramData = mergeAlignedDataTables(histogramsPerSeries);
replaceUndefinedWithNullInAlignedData(mergedHistogramData);
prependNullBinToFirstHistogramSeries(mergedHistogramData, bucketSize);
return mergedHistogramData;
}
export function prepareHistogramPanelConfig({
widget,
apiResponse,
panelMode,
isDarkMode,
}: {
widget: Widgets;
apiResponse: MetricRangePayloadProps;
panelMode: PanelMode;
isDarkMode: boolean;
}): UPlotConfigBuilder {
const builder = buildBaseConfig({
widget,
isDarkMode,
apiResponse,
panelMode,
panelType: PANEL_TYPES.HISTOGRAM,
});
builder.setCursor({
drag: {
x: false,
y: false,
setScale: true,
},
focus: {
prox: 1e3,
},
});
builder.addScale({
scaleKey: 'x',
time: false,
auto: true,
});
builder.addScale({
scaleKey: 'y',
time: false,
auto: true,
min: 0,
});
const currentQuery = widget.query;
const mergeAllActiveQueries = widget?.mergeAllActiveQueries ?? false;
// When merged, data has only one y column; add one series to match. Otherwise add one per result.
if (mergeAllActiveQueries) {
builder.addSeries({
label: '',
scaleKey: 'y',
drawStyle: DrawStyle.Bar,
panelType: PANEL_TYPES.HISTOGRAM,
colorMapping: widget.customLegendColors ?? {},
spanGaps: false,
barWidthFactor: 1,
pointSize: 5,
lineColor: '#3f5ecc',
fillColor: '#4E74F8',
isDarkMode,
});
} else {
apiResponse.data.result.forEach((series) => {
const baseLabelName = getLabelName(
series.metric,
series.queryName || '', // query
series.legend || '',
);
const label = currentQuery
? getLegend(series, currentQuery, baseLabelName)
: baseLabelName;
builder.addSeries({
label: label,
scaleKey: 'y',
drawStyle: DrawStyle.Bar,
panelType: PANEL_TYPES.HISTOGRAM,
colorMapping: widget.customLegendColors ?? {},
spanGaps: false,
barWidthFactor: 1,
pointSize: 5,
isDarkMode,
});
});
}
return builder;
}

View File

@@ -2,12 +2,12 @@ import { useEffect, useMemo, useRef, useState } from 'react';
import TimeSeries from 'container/DashboardContainer/visualization/charts/TimeSeries/TimeSeries';
import ChartManager from 'container/DashboardContainer/visualization/components/ChartManager/ChartManager';
import { usePanelContextMenu } from 'container/DashboardContainer/visualization/hooks/usePanelContextMenu';
import { useScrollWidgetIntoView } from 'container/DashboardContainer/visualization/hooks/useScrollWidgetIntoView';
import { PanelWrapperProps } from 'container/PanelWrapper/panelWrapper.types';
import { useIsDarkMode } from 'hooks/useDarkMode';
import { useResizeObserver } from 'hooks/useDimensions';
import { LegendPosition } from 'lib/uPlotV2/components/types';
import { ContextMenu } from 'periscope/components/ContextMenu';
import { useDashboard } from 'providers/Dashboard/Dashboard';
import { useTimezone } from 'providers/Timezone';
import { MetricRangePayloadProps } from 'types/api/metrics/getQueryRange';
import uPlot from 'uplot';
@@ -26,7 +26,6 @@ function TimeSeriesPanel(props: PanelWrapperProps): JSX.Element {
isFullViewMode,
onToggleModelHandler,
} = props;
const { toScrollWidgetId, setToScrollWidgetId } = useDashboard();
const graphRef = useRef<HTMLDivElement>(null);
const [minTimeScale, setMinTimeScale] = useState<number>();
const [maxTimeScale, setMaxTimeScale] = useState<number>();
@@ -35,16 +34,7 @@ function TimeSeriesPanel(props: PanelWrapperProps): JSX.Element {
const isDarkMode = useIsDarkMode();
const { timezone } = useTimezone();
useEffect(() => {
if (toScrollWidgetId === widget.id) {
graphRef.current?.scrollIntoView({
behavior: 'smooth',
block: 'center',
});
graphRef.current?.focus();
setToScrollWidgetId('');
}
}, [toScrollWidgetId, setToScrollWidgetId, widget.id]);
useScrollWidgetIntoView(widget.id, graphRef);
useEffect((): void => {
const { startTime, endTime } = getTimeRange(queryResponse);

View File

@@ -19,9 +19,9 @@ export interface BaseConfigBuilderProps {
widget: Widgets;
apiResponse: MetricRangePayloadProps;
isDarkMode: boolean;
onClick: OnClickPluginOpts['onClick'];
onDragSelect: (startTime: number, endTime: number) => void;
timezone: Timezone;
onClick?: OnClickPluginOpts['onClick'];
onDragSelect?: (startTime: number, endTime: number) => void;
timezone?: Timezone;
panelMode: PanelMode;
panelType: PANEL_TYPES;
minTimeScale?: number;
@@ -40,8 +40,10 @@ export function buildBaseConfig({
minTimeScale,
maxTimeScale,
}: BaseConfigBuilderProps): UPlotConfigBuilder {
const tzDate = (timestamp: number): Date =>
uPlot.tzDate(new Date(timestamp * 1e3), timezone.value);
const tzDate = timezone
? (timestamp: number): Date =>
uPlot.tzDate(new Date(timestamp * 1e3), timezone.value)
: undefined;
const builder = new UPlotConfigBuilder({
onDragSelect,

View File

@@ -0,0 +1,225 @@
import {
NULL_EXPAND,
NULL_REMOVE,
NULL_RETAIN,
} from 'container/PanelWrapper/constants';
import { AlignedData } from 'uplot';
/**
* Expands contiguous runs of `null` values to the left and right of their
* original positions so that visual gaps in the series are continuous.
*
* This is used when `NULL_EXPAND` mode is selected while joining series.
*/
function propagateNullsAcrossNeighbors(
seriesValues: Array<number | null>,
nullIndices: number[],
alignedLength: number,
): void {
for (
let i = 0, currentIndex, lastExpandedNullIndex = -1;
i < nullIndices.length;
i++
) {
const nullIndex = nullIndices[i];
if (nullIndex > lastExpandedNullIndex) {
// expand left until we hit a non-null value
currentIndex = nullIndex - 1;
while (currentIndex >= 0 && seriesValues[currentIndex] == null) {
seriesValues[currentIndex--] = null;
}
// expand right until we hit a non-null value
currentIndex = nullIndex + 1;
while (currentIndex < alignedLength && seriesValues[currentIndex] == null) {
seriesValues[(lastExpandedNullIndex = currentIndex++)] = null;
}
}
}
}
/**
* Merges multiple uPlot `AlignedData` tables into a single aligned table.
*
* - Merges and sorts all distinct x-values from each table.
* - Re-aligns every series onto the merged x-axis.
* - Applies per-series null handling (`NULL_REMOVE`, `NULL_RETAIN`, `NULL_EXPAND`).
*/
/* eslint-disable sonarjs/cognitive-complexity */
export function mergeAlignedDataTables(
alignedTables: AlignedData[],
nullModes?: number[][],
): AlignedData {
let mergedXValues: Set<number>;
// eslint-disable-next-line prefer-const
mergedXValues = new Set();
// Collect all unique x-values from every table.
for (let tableIndex = 0; tableIndex < alignedTables.length; tableIndex++) {
const table = alignedTables[tableIndex];
const xValues = table[0];
const xLength = xValues.length;
for (let i = 0; i < xLength; i++) {
mergedXValues.add(xValues[i]);
}
}
// Sorted, merged x-axis used by the final result.
const alignedData: (number | null | undefined)[][] = [
Array.from(mergedXValues).sort((a, b) => a - b),
];
const alignedLength = alignedData[0].length;
// Map from x-value to its index in the merged x-axis.
const xValueToIndexMap = new Map<number, number>();
for (let i = 0; i < alignedLength; i++) {
xValueToIndexMap.set(alignedData[0][i] as number, i);
}
// Re-align all series from all tables onto the merged x-axis.
for (let tableIndex = 0; tableIndex < alignedTables.length; tableIndex++) {
const table = alignedTables[tableIndex];
const xValues = table[0];
for (let seriesIndex = 1; seriesIndex < table.length; seriesIndex++) {
const seriesValues = table[seriesIndex];
const alignedSeriesValues = Array(alignedLength).fill(undefined);
const nullHandlingMode = nullModes
? nullModes[tableIndex][seriesIndex]
: NULL_RETAIN;
const nullIndices: number[] = [];
for (let i = 0; i < seriesValues.length; i++) {
const valueAtPoint = seriesValues[i];
const alignedIndex = xValueToIndexMap.get(xValues[i]);
if (alignedIndex == null) {
continue;
}
if (valueAtPoint === null) {
if (nullHandlingMode !== NULL_REMOVE) {
alignedSeriesValues[alignedIndex] = valueAtPoint;
if (nullHandlingMode === NULL_EXPAND) {
nullIndices.push(alignedIndex);
}
}
} else {
alignedSeriesValues[alignedIndex] = valueAtPoint;
}
}
// Optionally expand nulls to visually preserve gaps.
propagateNullsAcrossNeighbors(
alignedSeriesValues,
nullIndices,
alignedLength,
);
alignedData.push(alignedSeriesValues);
}
}
return alignedData as AlignedData;
}
/**
* Builds histogram buckets from raw values.
*
* - Each value is mapped into a bucket via `getBucketForValue`.
* - Counts how many values fall into each bucket.
* - Optionally sorts buckets using the provided comparator.
*/
export function buildHistogramBuckets(
values: number[],
getBucketForValue: (value: number) => number,
sortBuckets?: ((a: number, b: number) => number) | null,
): AlignedData {
const bucketMap = new Map<number, { value: number; count: number }>();
for (let i = 0; i < values.length; i++) {
let value = values[i];
if (value != null) {
value = getBucketForValue(value);
}
const bucket = bucketMap.get(value);
if (bucket) {
bucket.count++;
} else {
bucketMap.set(value, { value, count: 1 });
}
}
const buckets = [...bucketMap.values()];
// eslint-disable-next-line @typescript-eslint/no-unused-expressions
sortBuckets && buckets.sort((a, b) => sortBuckets(a.value, b.value));
const bucketValues = Array(buckets.length);
const bucketCounts = Array(buckets.length);
for (let i = 0; i < buckets.length; i++) {
bucketValues[i] = buckets[i].value;
bucketCounts[i] = buckets[i].count;
}
return [bucketValues, bucketCounts];
}
/**
* Mutates an `AlignedData` instance, replacing all `undefined` entries
* with explicit `null` values so uPlot treats them as gaps.
*/
export function replaceUndefinedWithNullInAlignedData(
data: AlignedData,
): AlignedData {
const seriesList = data as (number | null | undefined)[][];
for (let seriesIndex = 0; seriesIndex < seriesList.length; seriesIndex++) {
for (
let pointIndex = 0;
pointIndex < seriesList[seriesIndex].length;
pointIndex++
) {
if (seriesList[seriesIndex][pointIndex] === undefined) {
seriesList[seriesIndex][pointIndex] = null;
}
}
}
return data;
}
/**
* Ensures the first histogram series has a leading "empty" bin so that
* all series line up visually when rendered as bars.
*
* - Prepends a new x-value (first x - `bucketSize`) to the first series.
* - Prepends `null` to all subsequent series at the same index.
*/
export function prependNullBinToFirstHistogramSeries(
alignedData: AlignedData,
bucketSize: number,
): void {
const seriesList = alignedData as (number | null)[][];
if (
seriesList.length > 0 &&
seriesList[0].length > 0 &&
seriesList[0][0] !== null
) {
seriesList[0].unshift(seriesList[0][0] - bucketSize);
for (let seriesIndex = 1; seriesIndex < seriesList.length; seriesIndex++) {
seriesList[seriesIndex].unshift(null);
}
}
}

View File

@@ -7,12 +7,6 @@
display: flex;
align-items: center;
justify-content: space-between;
.auth-domain-title {
margin: 0;
font-size: 20px;
font-weight: 600;
}
}
}
@@ -21,29 +15,5 @@
display: flex;
flex-direction: row;
gap: 24px;
.auth-domain-list-action-link {
cursor: pointer;
color: var(--bg-robin-500);
transition: color 0.3s;
border: none;
background: none;
padding: 0;
font-size: inherit;
&:hover {
opacity: 0.8;
text-decoration: underline;
}
&.delete {
color: var(--bg-cherry-500);
}
}
}
.auth-domain-list-na {
padding-left: 6px;
color: var(--text-secondary);
}
}

View File

@@ -1,27 +1,14 @@
import { useCallback, useState } from 'react';
import { useState } from 'react';
import { Button, Form, Modal } from 'antd';
import { ErrorResponseHandlerV2 } from 'api/ErrorResponseHandlerV2';
import {
useCreateAuthDomain,
useUpdateAuthDomain,
} from 'api/generated/services/authdomains';
import {
AuthtypesGettableAuthDomainDTO,
AuthtypesGoogleConfigDTO,
AuthtypesOIDCConfigDTO,
AuthtypesPostableAuthDomainDTO,
AuthtypesRoleMappingDTO,
AuthtypesSamlConfigDTO,
RenderErrorResponseDTO,
} from 'api/generated/services/sigNoz.schemas';
import { AxiosError } from 'axios';
import put from 'api/v1/domains/id/put';
import post from 'api/v1/domains/post';
import { FeatureKeys } from 'constants/features';
import { useNotifications } from 'hooks/useNotifications';
import { defaultTo } from 'lodash-es';
import { useAppContext } from 'providers/App/App';
import { useErrorModal } from 'providers/ErrorModalProvider';
import { ErrorV2Resp } from 'types/api';
import APIError from 'types/api/error';
import { GettableAuthDomain } from 'types/api/v1/domains/list';
import { PostableAuthDomain } from 'types/api/v1/domains/post';
import AuthnProviderSelector from './AuthnProviderSelector';
import ConfigureGoogleAuthAuthnProvider from './Providers/AuthnGoogleAuth';
@@ -33,22 +20,7 @@ import './CreateEdit.styles.scss';
interface CreateOrEditProps {
isCreate: boolean;
onClose: () => void;
record?: AuthtypesGettableAuthDomainDTO;
}
// Form values interface for internal use (includes array-based fields for UI)
interface FormValues {
name?: string;
ssoEnabled?: boolean;
ssoType?: string;
googleAuthConfig?: AuthtypesGoogleConfigDTO & {
domainToAdminEmailList?: Array<{ domain?: string; adminEmail?: string }>;
};
samlConfig?: AuthtypesSamlConfigDTO;
oidcConfig?: AuthtypesOIDCConfigDTO;
roleMapping?: AuthtypesRoleMappingDTO & {
groupMappingsList?: Array<{ groupName?: string; role?: string }>;
};
record?: GettableAuthDomain;
}
function configureAuthnProvider(
@@ -67,299 +39,64 @@ function configureAuthnProvider(
}
}
/**
* Converts groupMappingsList array to groupMappings Record for API
*/
function convertGroupMappingsToRecord(
groupMappingsList?: Array<{ groupName?: string; role?: string }>,
): Record<string, string> | undefined {
if (!Array.isArray(groupMappingsList) || groupMappingsList.length === 0) {
return undefined;
}
const groupMappings: Record<string, string> = {};
groupMappingsList.forEach((item) => {
if (item.groupName && item.role) {
groupMappings[item.groupName] = item.role;
}
});
return Object.keys(groupMappings).length > 0 ? groupMappings : undefined;
}
/**
* Converts groupMappings Record to groupMappingsList array for form
*/
function convertGroupMappingsToList(
groupMappings?: Record<string, string> | null,
): Array<{ groupName: string; role: string }> {
if (!groupMappings) {
return [];
}
return Object.entries(groupMappings).map(([groupName, role]) => ({
groupName,
role,
}));
}
/**
* Converts domainToAdminEmailList array to domainToAdminEmail Record for API
*/
function convertDomainMappingsToRecord(
domainToAdminEmailList?: Array<{ domain?: string; adminEmail?: string }>,
): Record<string, string> | undefined {
if (
!Array.isArray(domainToAdminEmailList) ||
domainToAdminEmailList.length === 0
) {
return undefined;
}
const domainToAdminEmail: Record<string, string> = {};
domainToAdminEmailList.forEach((item) => {
if (item.domain && item.adminEmail) {
domainToAdminEmail[item.domain] = item.adminEmail;
}
});
return Object.keys(domainToAdminEmail).length > 0
? domainToAdminEmail
: undefined;
}
/**
* Converts domainToAdminEmail Record to domainToAdminEmailList array for form
*/
function convertDomainMappingsToList(
domainToAdminEmail?: Record<string, string>,
): Array<{ domain: string; adminEmail: string }> {
if (!domainToAdminEmail) {
return [];
}
return Object.entries(domainToAdminEmail).map(([domain, adminEmail]) => ({
domain,
adminEmail,
}));
}
/**
* Prepares initial form values from API record
*/
function prepareInitialValues(
record?: AuthtypesGettableAuthDomainDTO,
): FormValues {
if (!record) {
return {
name: '',
ssoEnabled: false,
ssoType: '',
};
}
return {
...record,
googleAuthConfig: record.googleAuthConfig
? {
...record.googleAuthConfig,
domainToAdminEmailList: convertDomainMappingsToList(
record.googleAuthConfig.domainToAdminEmail,
),
}
: undefined,
roleMapping: record.roleMapping
? {
...record.roleMapping,
groupMappingsList: convertGroupMappingsToList(
record.roleMapping.groupMappings,
),
}
: undefined,
};
}
function CreateOrEdit(props: CreateOrEditProps): JSX.Element {
const { isCreate, record, onClose } = props;
const [form] = Form.useForm<AuthtypesPostableAuthDomainDTO>();
const [form] = Form.useForm<PostableAuthDomain>();
const [authnProvider, setAuthnProvider] = useState<string>(
record?.ssoType || '',
);
const { notifications } = useNotifications();
const { showErrorModal } = useErrorModal();
const { featureFlags } = useAppContext();
const handleError = useCallback(
(error: AxiosError<RenderErrorResponseDTO>): void => {
try {
ErrorResponseHandlerV2(error as AxiosError<ErrorV2Resp>);
} catch (apiError) {
showErrorModal(apiError as APIError);
}
},
[showErrorModal],
);
const samlEnabled =
featureFlags?.find((flag) => flag.name === FeatureKeys.SSO)?.active || false;
const {
mutate: createAuthDomain,
isLoading: isCreating,
} = useCreateAuthDomain<AxiosError<RenderErrorResponseDTO>>();
const {
mutate: updateAuthDomain,
isLoading: isUpdating,
} = useUpdateAuthDomain<AxiosError<RenderErrorResponseDTO>>();
/**
* Prepares Google Auth config for API payload
*/
const getGoogleAuthConfig = useCallback(():
| AuthtypesGoogleConfigDTO
| undefined => {
const config = form.getFieldValue('googleAuthConfig');
if (!config) {
return undefined;
}
const { domainToAdminEmailList, ...rest } = config;
const domainToAdminEmail = convertDomainMappingsToRecord(
domainToAdminEmailList,
);
return {
...rest,
...(domainToAdminEmail && { domainToAdminEmail }),
};
}, [form]);
/**
* Prepares role mapping for API payload
*/
const getRoleMapping = useCallback((): AuthtypesRoleMappingDTO | undefined => {
const roleMapping = form.getFieldValue('roleMapping');
if (!roleMapping) {
return undefined;
}
const { groupMappingsList, ...rest } = roleMapping;
const groupMappings = convertGroupMappingsToRecord(groupMappingsList);
// Only return roleMapping if there's meaningful content
const hasDefaultRole = !!rest.defaultRole;
const hasUseRoleAttribute = rest.useRoleAttribute === true;
const hasGroupMappings =
groupMappings && Object.keys(groupMappings).length > 0;
if (!hasDefaultRole && !hasUseRoleAttribute && !hasGroupMappings) {
return undefined;
}
return {
...rest,
...(groupMappings && { groupMappings }),
};
}, [form]);
const onSubmitHandler = useCallback(async (): Promise<void> => {
try {
await form.validateFields();
} catch {
return;
}
const onSubmitHandler = async (): Promise<void> => {
const name = form.getFieldValue('name');
const googleAuthConfig = getGoogleAuthConfig();
const googleAuthConfig = form.getFieldValue('googleAuthConfig');
const samlConfig = form.getFieldValue('samlConfig');
const oidcConfig = form.getFieldValue('oidcConfig');
const roleMapping = getRoleMapping();
if (isCreate) {
createAuthDomain(
{
data: {
name,
config: {
ssoEnabled: true,
ssoType: authnProvider,
googleAuthConfig,
samlConfig,
oidcConfig,
roleMapping,
},
try {
if (isCreate) {
await post({
name,
config: {
ssoEnabled: true,
ssoType: authnProvider,
googleAuthConfig,
samlConfig,
oidcConfig,
},
},
{
onSuccess: () => {
notifications.success({
message: 'Domain created successfully',
});
onClose();
});
} else {
await put({
id: record?.id || '',
config: {
ssoEnabled: form.getFieldValue('ssoEnabled'),
ssoType: authnProvider,
googleAuthConfig,
samlConfig,
oidcConfig,
},
onError: handleError,
},
);
} else {
if (!record?.id) {
return;
});
}
updateAuthDomain(
{
pathParams: { id: record.id },
data: {
config: {
ssoEnabled: form.getFieldValue('ssoEnabled'),
ssoType: authnProvider,
googleAuthConfig,
samlConfig,
oidcConfig,
roleMapping,
},
},
},
{
onSuccess: () => {
notifications.success({
message: 'Domain updated successfully',
});
onClose();
},
onError: handleError,
},
);
onClose();
} catch (error) {
showErrorModal(error as APIError);
}
}, [
authnProvider,
createAuthDomain,
form,
getGoogleAuthConfig,
getRoleMapping,
handleError,
isCreate,
notifications,
onClose,
record,
updateAuthDomain,
]);
};
const onBackHandler = useCallback((): void => {
form.resetFields();
const onBackHandler = (): void => {
setAuthnProvider('');
}, [form]);
};
return (
<Modal
open
footer={null}
onCancel={onClose}
width={authnProvider ? 980 : undefined}
>
<Modal open footer={null} onCancel={onClose}>
<Form
name="auth-domain"
initialValues={defaultTo(prepareInitialValues(record), {
initialValues={defaultTo(record, {
name: '',
ssoEnabled: false,
ssoType: '',
@@ -379,11 +116,7 @@ function CreateOrEdit(props: CreateOrEditProps): JSX.Element {
<section className="action-buttons">
{isCreate && <Button onClick={onBackHandler}>Back</Button>}
{!isCreate && <Button onClick={onClose}>Cancel</Button>}
<Button
onClick={onSubmitHandler}
type="primary"
loading={isCreating || isUpdating}
>
<Button onClick={onSubmitHandler} type="primary">
Save Changes
</Button>
</section>

View File

@@ -1,67 +1,20 @@
import { useCallback, useState } from 'react';
import { Callout } from '@signozhq/callout';
import { Checkbox } from '@signozhq/checkbox';
import { Color } from '@signozhq/design-tokens';
import {
ChevronDown,
ChevronRight,
CircleHelp,
TriangleAlert,
} from '@signozhq/icons';
import { Input } from '@signozhq/input';
import { Collapse, Form, Tooltip } from 'antd';
import TextArea from 'antd/lib/input/TextArea';
import DomainMappingList from './components/DomainMappingList';
import EmailTagInput from './components/EmailTagInput';
import { useCollapseSectionErrors } from './components/hooks/useCollapseSectionErrors';
import RoleMappingSection from './components/RoleMappingSection';
import { Form, Input, Typography } from 'antd';
import './Providers.styles.scss';
type ExpandedSection = 'workspace-groups' | 'role-mapping' | null;
function ConfigureGoogleAuthAuthnProvider({
isCreate,
}: {
isCreate: boolean;
}): JSX.Element {
const form = Form.useFormInstance();
const fetchGroups = Form.useWatch(['googleAuthConfig', 'fetchGroups'], form);
const [expandedSection, setExpandedSection] = useState<ExpandedSection>(null);
const handleWorkspaceGroupsChange = useCallback(
(keys: string | string[]): void => {
const isExpanding = Array.isArray(keys) ? keys.length > 0 : !!keys;
setExpandedSection(isExpanding ? 'workspace-groups' : null);
},
[],
);
const handleRoleMappingChange = useCallback((expanded: boolean): void => {
setExpandedSection(expanded ? 'role-mapping' : null);
}, []);
const {
hasErrors: hasWorkspaceGroupsErrors,
errorMessages: workspaceGroupsErrorMessages,
} = useCollapseSectionErrors(
['googleAuthConfig'],
[
['googleAuthConfig', 'fetchGroups'],
['googleAuthConfig', 'serviceAccountJson'],
['googleAuthConfig', 'domainToAdminEmailList'],
['googleAuthConfig', 'fetchTransitiveGroupMembership'],
['googleAuthConfig', 'allowedGroups'],
],
);
return (
<div className="authn-provider">
<section className="authn-provider__header">
<h3 className="authn-provider__title">Edit Google Authentication</h3>
<p className="authn-provider__description">
<div className="google-auth">
<section className="header">
<Typography.Text className="title">
Edit Google Authentication
</Typography.Text>
<Typography.Paragraph className="description">
Enter OAuth 2.0 credentials obtained from the Google API Console below.
Read the{' '}
<a
@@ -72,247 +25,50 @@ function ConfigureGoogleAuthAuthnProvider({
docs
</a>{' '}
for more information.
</p>
</Typography.Paragraph>
</section>
<div className="authn-provider__columns">
{/* Left Column - Core OAuth Settings */}
<div className="authn-provider__left">
<div className="authn-provider__field-group">
<label className="authn-provider__label" htmlFor="google-domain">
Domain
<Tooltip title="The email domain for users who should use SSO (e.g., `example.com` for users with `@example.com` emails)">
<CircleHelp size={14} className="authn-provider__label-icon" />
</Tooltip>
</label>
<Form.Item
name="name"
className="authn-provider__form-item"
rules={[
{ required: true, message: 'Domain is required', whitespace: true },
]}
>
<Input id="google-domain" disabled={!isCreate} />
</Form.Item>
</div>
<Form.Item
label="Domain"
name="name"
className="field"
tooltip={{
title:
'The email domain for users who should use SSO (e.g., `example.com` for users with `@example.com` emails)',
}}
>
<Input disabled={!isCreate} />
</Form.Item>
<div className="authn-provider__field-group">
<label className="authn-provider__label" htmlFor="google-client-id">
Client ID
<Tooltip title="ClientID is the application's ID. For example, 292085223830.apps.googleusercontent.com.">
<CircleHelp size={14} className="authn-provider__label-icon" />
</Tooltip>
</label>
<Form.Item
name={['googleAuthConfig', 'clientId']}
className="authn-provider__form-item"
rules={[
{ required: true, message: 'Client ID is required', whitespace: true },
]}
>
<Input id="google-client-id" />
</Form.Item>
</div>
<Form.Item
label="Client ID"
name={['googleAuthConfig', 'clientId']}
className="field"
tooltip={{
title: `ClientID is the application's ID. For example, 292085223830.apps.googleusercontent.com.`,
}}
>
<Input />
</Form.Item>
<div className="authn-provider__field-group">
<label className="authn-provider__label" htmlFor="google-client-secret">
Client Secret
<Tooltip title="It is the application's secret.">
<CircleHelp size={14} className="authn-provider__label-icon" />
</Tooltip>
</label>
<Form.Item
name={['googleAuthConfig', 'clientSecret']}
className="authn-provider__form-item"
rules={[
{
required: true,
message: 'Client Secret is required',
whitespace: true,
},
]}
>
<Input id="google-client-secret" />
</Form.Item>
</div>
<Form.Item
label="Client Secret"
name={['googleAuthConfig', 'clientSecret']}
className="field"
tooltip={{
title: `It is the application's secret.`,
}}
>
<Input />
</Form.Item>
<div className="authn-provider__checkbox-row">
<Form.Item
name={['googleAuthConfig', 'insecureSkipEmailVerified']}
valuePropName="checked"
noStyle
>
<Checkbox
id="google-skip-email-verification"
labelName="Skip Email Verification"
onCheckedChange={(checked: boolean): void => {
form.setFieldValue(
['googleAuthConfig', 'insecureSkipEmailVerified'],
checked,
);
}}
/>
</Form.Item>
<Tooltip title='Whether to skip email verification. Defaults to "false"'>
<CircleHelp size={14} className="authn-provider__label-icon" />
</Tooltip>
</div>
<Callout
type="warning"
size="small"
showIcon
description="Google OAuth2 won't be enabled unless you enter all the attributes above"
className="callout"
/>
</div>
{/* Right Column - Google Workspace Groups (Advanced) */}
<div className="authn-provider__right">
<Collapse
bordered={false}
activeKey={
expandedSection === 'workspace-groups' ? ['workspace-groups'] : []
}
onChange={handleWorkspaceGroupsChange}
className="authn-provider__collapse"
expandIcon={(): null => null}
>
<Collapse.Panel
key="workspace-groups"
header={
<div className="authn-provider__collapse-header">
{expandedSection !== 'workspace-groups' ? (
<ChevronRight size={16} />
) : (
<ChevronDown size={16} />
)}
<div className="authn-provider__collapse-header-text">
<h4 className="authn-provider__section-title">
Google Workspace Groups (Advanced)
</h4>
<p className="authn-provider__section-description">
Enable group fetching to retrieve user groups from Google Workspace.
Requires a Service Account with domain-wide delegation.
</p>
</div>
{expandedSection !== 'workspace-groups' && hasWorkspaceGroupsErrors && (
<Tooltip
title={
<div>
{workspaceGroupsErrorMessages.map((msg) => (
<div key={msg}>{msg}</div>
))}
</div>
}
>
<TriangleAlert size={16} color={Color.BG_CHERRY_500} />
</Tooltip>
)}
</div>
}
>
<div className="authn-provider__group-content">
<div className="authn-provider__checkbox-row">
<Form.Item
name={['googleAuthConfig', 'fetchGroups']}
valuePropName="checked"
noStyle
>
<Checkbox
id="google-fetch-groups"
labelName="Fetch Groups"
onCheckedChange={(checked: boolean): void => {
form.setFieldValue(['googleAuthConfig', 'fetchGroups'], checked);
}}
/>
</Form.Item>
<Tooltip title="Enable fetching Google Workspace groups for the user. Requires service account configuration.">
<CircleHelp size={14} className="authn-provider__label-icon" />
</Tooltip>
</div>
{fetchGroups && (
<div className="authn-provider__group-fields">
<div className="authn-provider__field-group">
<label
className="authn-provider__label"
htmlFor="google-service-account-json"
>
Service Account JSON
<Tooltip title="The JSON content of the Google Service Account credentials file. Required for group fetching.">
<CircleHelp size={14} className="authn-provider__label-icon" />
</Tooltip>
</label>
<Form.Item
name={['googleAuthConfig', 'serviceAccountJson']}
className="authn-provider__form-item"
>
<TextArea
id="google-service-account-json"
rows={3}
placeholder="Paste service account JSON"
className="authn-provider__textarea"
/>
</Form.Item>
</div>
<DomainMappingList
fieldNamePrefix={['googleAuthConfig', 'domainToAdminEmailList']}
/>
<div className="authn-provider__checkbox-row">
<Form.Item
name={['googleAuthConfig', 'fetchTransitiveGroupMembership']}
valuePropName="checked"
noStyle
>
<Checkbox
id="google-transitive-membership"
labelName="Fetch Transitive Group Membership"
onCheckedChange={(checked: boolean): void => {
form.setFieldValue(
['googleAuthConfig', 'fetchTransitiveGroupMembership'],
checked,
);
}}
/>
</Form.Item>
<Tooltip title="If enabled, recursively fetch groups that contain other groups (transitive membership).">
<CircleHelp size={14} className="authn-provider__label-icon" />
</Tooltip>
</div>
<div className="authn-provider__field-group">
<label
className="authn-provider__label"
htmlFor="google-allowed-groups"
>
Allowed Groups
<Tooltip title="Optional list of allowed groups. If configured, only users belonging to one of these groups will be allowed to login.">
<CircleHelp size={14} className="authn-provider__label-icon" />
</Tooltip>
</label>
<Form.Item
name={['googleAuthConfig', 'allowedGroups']}
className="authn-provider__form-item"
>
<EmailTagInput placeholder="Type a group email and press Enter" />
</Form.Item>
</div>
</div>
)}
</div>
</Collapse.Panel>
</Collapse>
<RoleMappingSection
fieldNamePrefix={['roleMapping']}
isExpanded={expandedSection === 'role-mapping'}
onExpandChange={handleRoleMappingChange}
/>
</div>
</div>
<Callout
type="warning"
size="small"
showIcon
description="Google OAuth2 wont be enabled unless you enter all the attributes above"
className="callout"
/>
</div>
);
}

View File

@@ -1,211 +1,110 @@
import { useCallback, useState } from 'react';
import { Callout } from '@signozhq/callout';
import { Checkbox } from '@signozhq/checkbox';
import { CircleHelp } from '@signozhq/icons';
import { Input } from '@signozhq/input';
import { Form, Tooltip } from 'antd';
import ClaimMappingSection from './components/ClaimMappingSection';
import RoleMappingSection from './components/RoleMappingSection';
import { Checkbox, Form, Input, Typography } from 'antd';
import './Providers.styles.scss';
type ExpandedSection = 'claim-mapping' | 'role-mapping' | null;
function ConfigureOIDCAuthnProvider({
isCreate,
}: {
isCreate: boolean;
}): JSX.Element {
const form = Form.useFormInstance();
const [expandedSection, setExpandedSection] = useState<ExpandedSection>(null);
const handleClaimMappingChange = useCallback((expanded: boolean): void => {
setExpandedSection(expanded ? 'claim-mapping' : null);
}, []);
const handleRoleMappingChange = useCallback((expanded: boolean): void => {
setExpandedSection(expanded ? 'role-mapping' : null);
}, []);
return (
<div className="authn-provider">
<section className="authn-provider__header">
<h3 className="authn-provider__title">Edit OIDC Authentication</h3>
<p className="authn-provider__description">
Configure OpenID Connect Single Sign-On with your Identity Provider. Read
the{' '}
<a
href="https://signoz.io/docs/userguide/sso-authentication"
target="_blank"
rel="noreferrer"
>
docs
</a>{' '}
for more information.
</p>
<div className="saml">
<section className="header">
<Typography.Text className="title">
Edit OIDC Authentication
</Typography.Text>
</section>
<div className="authn-provider__columns">
{/* Left Column - Core OIDC Settings */}
<div className="authn-provider__left">
<div className="authn-provider__field-group">
<label className="authn-provider__label" htmlFor="oidc-domain">
Domain
<Tooltip title="The email domain for users who should use SSO (e.g., `example.com` for users with `@example.com` emails)">
<CircleHelp size={14} className="authn-provider__label-icon" />
</Tooltip>
</label>
<Form.Item
name="name"
className="authn-provider__form-item"
rules={[
{ required: true, message: 'Domain is required', whitespace: true },
]}
>
<Input id="oidc-domain" disabled={!isCreate} />
</Form.Item>
</div>
<Form.Item
label="Domain"
name="name"
tooltip={{
title:
'The email domain for users who should use SSO (e.g., `example.com` for users with `@example.com` emails)',
}}
>
<Input disabled={!isCreate} />
</Form.Item>
<div className="authn-provider__field-group">
<label className="authn-provider__label" htmlFor="oidc-issuer">
Issuer URL
<Tooltip title='The URL identifier for the OIDC provider. For example: "https://accounts.google.com" or "https://login.salesforce.com".'>
<CircleHelp size={14} className="authn-provider__label-icon" />
</Tooltip>
</label>
<Form.Item
name={['oidcConfig', 'issuer']}
className="authn-provider__form-item"
rules={[
{ required: true, message: 'Issuer URL is required', whitespace: true },
]}
>
<Input id="oidc-issuer" />
</Form.Item>
</div>
<Form.Item
label="Issuer URL"
name={['oidcConfig', 'issuer']}
tooltip={{
title: `It is the URL identifier for the service. For example: "https://accounts.google.com" or "https://login.salesforce.com".`,
}}
>
<Input />
</Form.Item>
<div className="authn-provider__field-group">
<label className="authn-provider__label" htmlFor="oidc-issuer-alias">
Issuer Alias
<Tooltip title="Optional: Override the issuer URL from .well-known/openid-configuration for providers like Azure or Oracle IDCS.">
<CircleHelp size={14} className="authn-provider__label-icon" />
</Tooltip>
</label>
<Form.Item
name={['oidcConfig', 'issuerAlias']}
className="authn-provider__form-item"
>
<Input id="oidc-issuer-alias" />
</Form.Item>
</div>
<Form.Item
label="Issuer Alias"
name={['oidcConfig', 'issuerAlias']}
tooltip={{
title: `Some offspec providers like Azure, Oracle IDCS have oidc discovery url different from issuer url which causes issuerValidation to fail.
This provides a way to override the Issuer url from the .well-known/openid-configuration issuer`,
}}
>
<Input />
</Form.Item>
<div className="authn-provider__field-group">
<label className="authn-provider__label" htmlFor="oidc-client-id">
Client ID
<Tooltip title="The application's client ID from your OIDC provider.">
<CircleHelp size={14} className="authn-provider__label-icon" />
</Tooltip>
</label>
<Form.Item
name={['oidcConfig', 'clientId']}
className="authn-provider__form-item"
rules={[
{ required: true, message: 'Client ID is required', whitespace: true },
]}
>
<Input id="oidc-client-id" />
</Form.Item>
</div>
<Form.Item
label="Client ID"
name={['oidcConfig', 'clientId']}
tooltip={{ title: `It is the application's ID.` }}
>
<Input />
</Form.Item>
<div className="authn-provider__field-group">
<label className="authn-provider__label" htmlFor="oidc-client-secret">
Client Secret
<Tooltip title="The application's client secret from your OIDC provider.">
<CircleHelp size={14} className="authn-provider__label-icon" />
</Tooltip>
</label>
<Form.Item
name={['oidcConfig', 'clientSecret']}
className="authn-provider__form-item"
rules={[
{
required: true,
message: 'Client Secret is required',
whitespace: true,
},
]}
>
<Input id="oidc-client-secret" />
</Form.Item>
</div>
<Form.Item
label="Client Secret"
name={['oidcConfig', 'clientSecret']}
tooltip={{ title: `It is the application's secret.` }}
>
<Input />
</Form.Item>
<div className="authn-provider__checkbox-row">
<Form.Item
name={['oidcConfig', 'insecureSkipEmailVerified']}
valuePropName="checked"
noStyle
>
<Checkbox
id="oidc-skip-email-verification"
labelName="Skip Email Verification"
onCheckedChange={(checked: boolean): void => {
form.setFieldValue(
['oidcConfig', 'insecureSkipEmailVerified'],
checked,
);
}}
/>
</Form.Item>
<Tooltip title='Whether to skip email verification. Defaults to "false"'>
<CircleHelp size={14} className="authn-provider__label-icon" />
</Tooltip>
</div>
<Form.Item
label="Email Claim Mapping"
name={['oidcConfig', 'claimMapping', 'email']}
tooltip={{
title: `Mapping of email claims to the corresponding email field in the token.`,
}}
>
<Input />
</Form.Item>
<div className="authn-provider__checkbox-row">
<Form.Item
name={['oidcConfig', 'getUserInfo']}
valuePropName="checked"
noStyle
>
<Checkbox
id="oidc-get-user-info"
labelName="Get User Info"
onCheckedChange={(checked: boolean): void => {
form.setFieldValue(['oidcConfig', 'getUserInfo'], checked);
}}
/>
</Form.Item>
<Tooltip title="Use the userinfo endpoint to get additional claims. Useful when providers return thin ID tokens.">
<CircleHelp size={14} className="authn-provider__label-icon" />
</Tooltip>
</div>
<Form.Item
label="Skip Email Verification"
name={['oidcConfig', 'insecureSkipEmailVerified']}
valuePropName="checked"
className="field"
tooltip={{
title: `Whether to skip email verification. Defaults to "false"`,
}}
>
<Checkbox />
</Form.Item>
<Callout
type="warning"
size="small"
showIcon
description="OIDC won't be enabled unless you enter all the attributes above"
className="callout"
/>
</div>
<Form.Item
label="Get User Info"
name={['oidcConfig', 'getUserInfo']}
valuePropName="checked"
className="field"
tooltip={{
title: `Uses the userinfo endpoint to get additional claims for the token. This is especially useful where upstreams return "thin" id tokens`,
}}
>
<Checkbox />
</Form.Item>
{/* Right Column - Advanced Settings */}
<div className="authn-provider__right">
<ClaimMappingSection
fieldNamePrefix={['oidcConfig', 'claimMapping']}
isExpanded={expandedSection === 'claim-mapping'}
onExpandChange={handleClaimMappingChange}
/>
<RoleMappingSection
fieldNamePrefix={['roleMapping']}
isExpanded={expandedSection === 'role-mapping'}
onExpandChange={handleRoleMappingChange}
/>
</div>
</div>
<Callout
type="warning"
size="small"
showIcon
description="OIDC wont be enabled unless you enter all the attributes above"
className="callout"
/>
</div>
);
}

View File

@@ -1,190 +1,82 @@
import { useCallback, useState } from 'react';
import { Callout } from '@signozhq/callout';
import { Checkbox } from '@signozhq/checkbox';
import { CircleHelp } from '@signozhq/icons';
import { Input } from '@signozhq/input';
import { Form, Tooltip } from 'antd';
import TextArea from 'antd/lib/input/TextArea';
import AttributeMappingSection from './components/AttributeMappingSection';
import RoleMappingSection from './components/RoleMappingSection';
import { Checkbox, Form, Input, Typography } from 'antd';
import './Providers.styles.scss';
type ExpandedSection = 'attribute-mapping' | 'role-mapping' | null;
function ConfigureSAMLAuthnProvider({
isCreate,
}: {
isCreate: boolean;
}): JSX.Element {
const form = Form.useFormInstance();
const [expandedSection, setExpandedSection] = useState<ExpandedSection>(null);
const handleAttributeMappingChange = useCallback((expanded: boolean): void => {
setExpandedSection(expanded ? 'attribute-mapping' : null);
}, []);
const handleRoleMappingChange = useCallback((expanded: boolean): void => {
setExpandedSection(expanded ? 'role-mapping' : null);
}, []);
return (
<div className="authn-provider">
<section className="authn-provider__header">
<h3 className="authn-provider__title">Edit SAML Authentication</h3>
<p className="authn-provider__description">
Configure SAML 2.0 Single Sign-On with your Identity Provider. Read the{' '}
<a
href="https://signoz.io/docs/userguide/sso-authentication"
target="_blank"
rel="noreferrer"
>
docs
</a>{' '}
for more information.
</p>
<div className="saml">
<section className="header">
<Typography.Text className="title">
Edit SAML Authentication
</Typography.Text>
</section>
<div className="authn-provider__columns">
{/* Left Column - Core SAML Settings */}
<div className="authn-provider__left">
<div className="authn-provider__field-group">
<label className="authn-provider__label" htmlFor="saml-domain">
Domain
<Tooltip title="The email domain for users who should use SSO (e.g., `example.com` for users with `@example.com` emails)">
<CircleHelp size={14} className="authn-provider__label-icon" />
</Tooltip>
</label>
<Form.Item
name="name"
className="authn-provider__form-item"
rules={[
{ required: true, message: 'Domain is required', whitespace: true },
]}
>
<Input id="saml-domain" disabled={!isCreate} />
</Form.Item>
</div>
<Form.Item
label="Domain"
name="name"
tooltip={{
title:
'The email domain for users who should use SSO (e.g., `example.com` for users with `@example.com` emails)',
}}
>
<Input disabled={!isCreate} />
</Form.Item>
<div className="authn-provider__field-group">
<label className="authn-provider__label" htmlFor="saml-acs-url">
SAML ACS URL
<Tooltip title="The SSO endpoint of the SAML identity provider. It can typically be found in the SingleSignOnService element in the SAML metadata of the identity provider.">
<CircleHelp size={14} className="authn-provider__label-icon" />
</Tooltip>
</label>
<Form.Item
name={['samlConfig', 'samlIdp']}
className="authn-provider__form-item"
rules={[
{
required: true,
message: 'SAML ACS URL is required',
whitespace: true,
},
]}
>
<Input id="saml-acs-url" />
</Form.Item>
</div>
<Form.Item
label="SAML ACS URL"
name={['samlConfig', 'samlIdp']}
tooltip={{
title: `The SSO endpoint of the SAML identity provider. It can typically be found in the SingleSignOnService element in the SAML metadata of the identity provider. Example: <md:SingleSignOnService Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST" Location="{samlIdp}"/>`,
}}
>
<Input />
</Form.Item>
<div className="authn-provider__field-group">
<label className="authn-provider__label" htmlFor="saml-entity-id">
SAML Entity ID
<Tooltip title="The entityID of the SAML identity provider. It can typically be found in the EntityID attribute of the EntityDescriptor element in the SAML metadata.">
<CircleHelp size={14} className="authn-provider__label-icon" />
</Tooltip>
</label>
<Form.Item
name={['samlConfig', 'samlEntity']}
className="authn-provider__form-item"
rules={[
{
required: true,
message: 'SAML Entity ID is required',
whitespace: true,
},
]}
>
<Input id="saml-entity-id" />
</Form.Item>
</div>
<Form.Item
label="SAML Entity ID"
name={['samlConfig', 'samlEntity']}
tooltip={{
title: `The entityID of the SAML identity provider. It can typically be found in the EntityID attribute of the EntityDescriptor element in the SAML metadata of the identity provider. Example: <md:EntityDescriptor xmlns:md="urn:oasis:names:tc:SAML:2.0:metadata" entityID="{samlEntity}">`,
}}
>
<Input />
</Form.Item>
<div className="authn-provider__field-group">
<label className="authn-provider__label" htmlFor="saml-certificate">
SAML X.509 Certificate
<Tooltip title="The certificate of the SAML identity provider. It can typically be found in the X509Certificate element in the SAML metadata.">
<CircleHelp size={14} className="authn-provider__label-icon" />
</Tooltip>
</label>
<Form.Item
name={['samlConfig', 'samlCert']}
className="authn-provider__form-item"
rules={[
{
required: true,
message: 'SAML Certificate is required',
whitespace: true,
},
]}
>
<TextArea
id="saml-certificate"
rows={3}
placeholder="Paste X.509 certificate"
className="authn-provider__textarea"
/>
</Form.Item>
</div>
<Form.Item
label="SAML X.509 Certificate"
name={['samlConfig', 'samlCert']}
tooltip={{
title: `The certificate of the SAML identity provider. It can typically be found in the X509Certificate element in the SAML metadata of the identity provider. Example: <ds:X509Certificate><ds:X509Certificate>{samlCert}</ds:X509Certificate></ds:X509Certificate>`,
}}
>
<Input.TextArea rows={4} />
</Form.Item>
<div className="authn-provider__checkbox-row">
<Form.Item
name={['samlConfig', 'insecureSkipAuthNRequestsSigned']}
valuePropName="checked"
noStyle
>
<Checkbox
id="saml-skip-signing"
labelName="Skip Signing AuthN Requests"
onCheckedChange={(checked: boolean): void => {
form.setFieldValue(
['samlConfig', 'insecureSkipAuthNRequestsSigned'],
checked,
);
}}
/>
</Form.Item>
<Tooltip title="Whether to skip signing the SAML requests. For providers like JumpCloud, this should be enabled.">
<CircleHelp size={14} className="authn-provider__label-icon" />
</Tooltip>
</div>
<Form.Item
label="Skip Signing AuthN Requests"
name={['samlConfig', 'insecureSkipAuthNRequestsSigned']}
valuePropName="checked"
className="field"
tooltip={{
title: `Whether to skip signing the SAML requests. It can typically be found in the WantAuthnRequestsSigned attribute of the IDPSSODescriptor element in the SAML metadata of the identity provider. Example: <md:IDPSSODescriptor WantAuthnRequestsSigned="false" protocolSupportEnumeration="urn:oasis:names:tc:SAML:2.0:protocol">
For providers like jumpcloud, this should be set to true.Note: This is the reverse of WantAuthnRequestsSigned. If WantAuthnRequestsSigned is false, then InsecureSkipAuthNRequestsSigned should be true.`,
}}
>
<Checkbox />
</Form.Item>
<Callout
type="warning"
size="small"
showIcon
description="SAML won't be enabled unless you enter all the attributes above"
className="callout"
/>
</div>
{/* Right Column - Advanced Settings */}
<div className="authn-provider__right">
<AttributeMappingSection
fieldNamePrefix={['samlConfig', 'attributeMapping']}
isExpanded={expandedSection === 'attribute-mapping'}
onExpandChange={handleAttributeMappingChange}
/>
<RoleMappingSection
fieldNamePrefix={['roleMapping']}
isExpanded={expandedSection === 'role-mapping'}
onExpandChange={handleRoleMappingChange}
/>
</div>
</div>
<Callout
type="warning"
size="small"
showIcon
description="SAML wont be enabled unless you enter all the attributes above"
className="callout"
/>
</div>
);
}

View File

@@ -1,246 +1,24 @@
.authn-provider {
.google-auth {
display: flex;
flex-direction: column;
&__header {
display: flex;
flex-direction: column;
gap: 4px;
margin-bottom: 16px;
.ant-form-item {
margin-bottom: 12px !important;
}
&__title {
margin: 0;
color: var(--l1-foreground);
font-family: 'Inter', sans-serif;
font-size: 14px;
font-weight: 600;
line-height: 20px;
}
&__description {
margin: 0;
color: var(--l2-foreground);
font-family: 'Inter', sans-serif;
font-size: 14px;
font-weight: 400;
line-height: 20px;
a {
color: var(--accent-primary);
text-decoration: none;
&:hover {
text-decoration: underline;
}
}
}
&__columns {
display: grid;
grid-template-columns: 0.9fr 1fr;
gap: 24px;
}
&__left {
display: flex;
flex-direction: column;
}
&__right {
border-left: 1px solid var(--l3-border);
padding-left: 24px;
display: flex;
flex-direction: column;
}
&__field-group {
.header {
display: flex;
flex-direction: column;
gap: 4px;
margin-bottom: 12px;
}
&__label {
display: inline-flex;
align-items: center;
gap: 6px;
color: var(--l1-foreground);
}
&__label-icon {
color: var(--l3-foreground);
cursor: help;
flex-shrink: 0;
}
&__form-item {
margin-bottom: 0 !important;
}
&__checkbox-row {
display: flex;
align-items: center;
gap: 6px;
margin-bottom: 12px;
}
input,
textarea {
height: 32px;
background: var(--l3-background) !important;
border: 1px solid var(--l3-border) !important;
border-radius: 2px;
color: var(--l1-foreground) !important;
&::placeholder {
color: var(--l3-foreground) !important;
opacity: 1;
.title {
font-weight: bold;
}
&:hover {
border-color: var(--l3-border) !important;
.description {
margin-bottom: 0px !important;
}
&:focus,
&:focus-visible {
border-color: var(--bg-robin-500) !important;
box-shadow: none !important;
outline: none;
}
}
textarea {
height: auto;
}
&__textarea {
min-height: 60px !important;
max-height: 200px;
resize: vertical;
background: var(--l3-background) !important;
border: 1px solid var(--l3-border) !important;
border-radius: 2px;
color: var(--l1-foreground) !important;
font-family: 'SF Mono', monospace;
font-size: 12px;
line-height: 18px;
&::placeholder {
color: var(--l3-foreground) !important;
font-family: Inter, sans-serif;
}
&:hover {
border-color: var(--l3-border) !important;
}
&:focus,
&:focus-visible {
border-color: var(--bg-robin-500) !important;
box-shadow: none !important;
outline: none;
}
}
button[role='checkbox'] {
border: 1px solid var(--l2-foreground) !important;
border-radius: 2px;
&[data-state='checked'] {
background-color: var(--bg-robin-500) !important;
border-color: var(--bg-robin-500) !important;
}
}
&__collapse {
background: transparent !important;
.ant-collapse-item {
border: none !important;
}
.ant-collapse-header {
padding: 0 !important;
}
.ant-collapse-content {
border-top: none !important;
background: transparent !important;
}
.ant-collapse-content-box {
padding: 12px 0 0 24px !important;
}
}
&__collapse-header {
display: flex;
align-items: flex-start;
gap: 8px;
cursor: pointer;
position: relative;
width: 100%;
svg {
margin-top: 2px;
color: var(--l3-foreground);
flex-shrink: 0;
}
}
&__collapse-header-text {
display: flex;
flex-direction: column;
gap: 4px;
}
&__section-title {
margin: 0;
color: var(--l1-foreground);
}
&__section-description {
margin: 0;
color: var(--l3-foreground);
}
&__group-content {
display: flex;
flex-direction: column;
gap: 12px;
}
&__group-fields {
display: flex;
flex-direction: column;
gap: 24px;
max-height: 45vh;
overflow-y: auto;
padding-right: 4px;
.authn-provider__field-group,
.authn-provider__checkbox-row {
margin-bottom: 0;
}
&::-webkit-scrollbar {
width: 4px;
}
&::-webkit-scrollbar-track {
background: transparent;
}
&::-webkit-scrollbar-thumb {
background: var(--l3-foreground);
border-radius: 4px;
&:hover {
background: var(--l2-foreground);
}
}
scrollbar-width: thin;
scrollbar-color: var(--l3-foreground) transparent;
}
.callout {

View File

@@ -1,133 +0,0 @@
.attribute-mapping-section {
display: flex;
flex-direction: column;
&__collapse {
background: transparent !important;
.ant-collapse-item {
border: none !important;
}
.ant-collapse-header {
padding: 0 !important;
}
.ant-collapse-content {
border-top: none !important;
background: transparent !important;
}
.ant-collapse-content-box {
padding: 12px 0 0 24px !important;
}
}
&__collapse-header {
display: flex;
align-items: flex-start;
gap: 8px;
cursor: pointer;
position: relative;
width: 100%;
svg {
margin-top: 2px;
color: var(--l3-foreground);
flex-shrink: 0;
}
}
&__collapse-header-text {
display: flex;
flex-direction: column;
gap: 4px;
}
&__section-title {
margin: 0;
color: var(--l1-foreground);
}
&__section-description {
margin: 0;
color: var(--l3-foreground);
}
&__content {
display: flex;
flex-direction: column;
gap: 16px;
max-height: 45vh;
overflow-y: auto;
padding-right: 4px;
// Thin scrollbar
&::-webkit-scrollbar {
width: 4px;
}
&::-webkit-scrollbar-track {
background: transparent;
}
&::-webkit-scrollbar-thumb {
background: var(--l3-foreground);
border-radius: 4px;
&:hover {
background: var(--l2-foreground);
}
}
scrollbar-width: thin;
scrollbar-color: var(--l3-foreground) transparent;
}
&__field-group {
display: flex;
flex-direction: column;
gap: 4px;
}
&__label {
display: inline-flex;
align-items: center;
gap: 6px;
color: var(--l1-foreground);
}
&__label-icon {
color: var(--l3-foreground);
cursor: help;
flex-shrink: 0;
}
&__form-item {
margin-bottom: 0 !important;
}
input {
height: 32px;
background: var(--l3-background) !important;
border: 1px solid var(--l3-border) !important;
border-radius: 2px;
color: var(--l1-foreground) !important;
&::placeholder {
color: var(--l3-foreground) !important;
opacity: 1;
}
&:hover {
border-color: var(--l3-border) !important;
}
&:focus,
&:focus-visible {
border-color: var(--bg-robin-500) !important;
box-shadow: none !important;
outline: none;
}
}
}

View File

@@ -1,191 +0,0 @@
import { useCallback, useState } from 'react';
import { Color } from '@signozhq/design-tokens';
import {
ChevronDown,
ChevronRight,
CircleHelp,
TriangleAlert,
} from '@signozhq/icons';
import { Input } from '@signozhq/input';
import { Collapse, Form, Tooltip } from 'antd';
import { useCollapseSectionErrors } from './hooks/useCollapseSectionErrors';
import './AttributeMappingSection.styles.scss';
interface AttributeMappingSectionProps {
/** The form field name prefix for the attribute mapping configuration */
fieldNamePrefix: string[];
/** Whether the section is expanded (controlled mode) */
isExpanded?: boolean;
/** Callback when expand/collapse is toggled */
onExpandChange?: (expanded: boolean) => void;
}
function AttributeMappingSection({
fieldNamePrefix,
isExpanded,
onExpandChange,
}: AttributeMappingSectionProps): JSX.Element {
// Support both controlled and uncontrolled modes
const [internalExpanded, setInternalExpanded] = useState(false);
const isControlled = isExpanded !== undefined;
const expanded = isControlled ? isExpanded : internalExpanded;
const handleCollapseChange = useCallback(
(keys: string | string[]): void => {
const newExpanded = Array.isArray(keys) ? keys.length > 0 : !!keys;
if (isControlled && onExpandChange) {
onExpandChange(newExpanded);
} else {
setInternalExpanded(newExpanded);
}
},
[isControlled, onExpandChange],
);
const collapseActiveKey = expanded ? ['attribute-mapping'] : [];
const { hasErrors, errorMessages } = useCollapseSectionErrors(fieldNamePrefix);
return (
<div className="attribute-mapping-section">
<Collapse
bordered={false}
activeKey={collapseActiveKey}
onChange={handleCollapseChange}
className="attribute-mapping-section__collapse"
expandIcon={(): null => null}
>
<Collapse.Panel
key="attribute-mapping"
header={
<div
className="attribute-mapping-section__collapse-header"
role="button"
aria-expanded={expanded}
aria-controls="attribute-mapping-content"
>
{!expanded ? <ChevronRight size={16} /> : <ChevronDown size={16} />}
<div className="attribute-mapping-section__collapse-header-text">
<h4 className="attribute-mapping-section__section-title">
Attribute Mapping (Advanced)
</h4>
<p className="attribute-mapping-section__section-description">
Configure how SAML assertion attributes from your Identity Provider map
to SigNoz user attributes. Leave empty to use default values.
</p>
</div>
{!expanded && hasErrors && (
<Tooltip
title={
<div>
{errorMessages.map((msg) => (
<div key={msg}>{msg}</div>
))}
</div>
}
>
<TriangleAlert size={16} color={Color.BG_CHERRY_500} />
</Tooltip>
)}
</div>
}
>
<div
id="attribute-mapping-content"
className="attribute-mapping-section__content"
>
<div className="attribute-mapping-section__field-group">
<label
className="attribute-mapping-section__label"
htmlFor="email-attribute"
>
Email Attribute
<Tooltip title="The SAML attribute key that contains the user's email. Default: 'email'">
<CircleHelp
size={14}
className="attribute-mapping-section__label-icon"
/>
</Tooltip>
</label>
<Form.Item
name={[...fieldNamePrefix, 'email']}
className="attribute-mapping-section__form-item"
>
<Input id="email-attribute" placeholder="Email" />
</Form.Item>
</div>
{/* Name Attribute */}
<div className="attribute-mapping-section__field-group">
<label
className="attribute-mapping-section__label"
htmlFor="name-attribute"
>
Name Attribute
<Tooltip title="The SAML attribute key that contains the user's display name. Default: 'name'">
<CircleHelp
size={14}
className="attribute-mapping-section__label-icon"
/>
</Tooltip>
</label>
<Form.Item
name={[...fieldNamePrefix, 'name']}
className="attribute-mapping-section__form-item"
>
<Input id="name-attribute" placeholder="Name" />
</Form.Item>
</div>
{/* Groups Attribute */}
<div className="attribute-mapping-section__field-group">
<label
className="attribute-mapping-section__label"
htmlFor="groups-attribute"
>
Groups Attribute
<Tooltip title="The SAML attribute key that contains the user's group memberships. Used for role mapping. Default: 'groups'">
<CircleHelp
size={14}
className="attribute-mapping-section__label-icon"
/>
</Tooltip>
</label>
<Form.Item
name={[...fieldNamePrefix, 'groups']}
className="attribute-mapping-section__form-item"
>
<Input id="groups-attribute" placeholder="Groups" />
</Form.Item>
</div>
{/* Role Attribute */}
<div className="attribute-mapping-section__field-group">
<label
className="attribute-mapping-section__label"
htmlFor="role-attribute"
>
Role Attribute
<Tooltip title="The SAML attribute key that contains the user's role directly from the IDP. Default: 'role'">
<CircleHelp
size={14}
className="attribute-mapping-section__label-icon"
/>
</Tooltip>
</label>
<Form.Item
name={[...fieldNamePrefix, 'role']}
className="attribute-mapping-section__form-item"
>
<Input id="role-attribute" placeholder="Role" />
</Form.Item>
</div>
</div>
</Collapse.Panel>
</Collapse>
</div>
);
}
export default AttributeMappingSection;

View File

@@ -1,133 +0,0 @@
.claim-mapping-section {
display: flex;
flex-direction: column;
&__collapse {
background: transparent !important;
.ant-collapse-item {
border: none !important;
}
.ant-collapse-header {
padding: 0 !important;
}
.ant-collapse-content {
border-top: none !important;
background: transparent !important;
}
.ant-collapse-content-box {
padding: 12px 0 0 24px !important;
}
}
&__collapse-header {
display: flex;
align-items: flex-start;
gap: 8px;
cursor: pointer;
position: relative;
width: 100%;
svg {
margin-top: 2px;
color: var(--l3-foreground);
flex-shrink: 0;
}
}
&__collapse-header-text {
display: flex;
flex-direction: column;
gap: 4px;
}
&__section-title {
margin: 0;
color: var(--l1-foreground);
}
&__section-description {
margin: 0;
color: var(--l3-foreground);
}
&__content {
display: flex;
flex-direction: column;
gap: 16px;
max-height: 45vh;
overflow-y: auto;
padding-right: 4px;
// Thin scrollbar
&::-webkit-scrollbar {
width: 4px;
}
&::-webkit-scrollbar-track {
background: transparent;
}
&::-webkit-scrollbar-thumb {
background: var(--l3-foreground);
border-radius: 4px;
&:hover {
background: var(--l2-foreground);
}
}
scrollbar-width: thin;
scrollbar-color: var(--l3-foreground) transparent;
}
&__field-group {
display: flex;
flex-direction: column;
gap: 4px;
}
&__label {
display: inline-flex;
align-items: center;
gap: 6px;
color: var(--l1-foreground);
}
&__label-icon {
color: var(--l3-foreground);
cursor: help;
flex-shrink: 0;
}
&__form-item {
margin-bottom: 0 !important;
}
input {
height: 32px;
background: var(--l3-background) !important;
border: 1px solid var(--l3-border) !important;
border-radius: 2px;
color: var(--l1-foreground) !important;
&::placeholder {
color: var(--l3-foreground) !important;
opacity: 1;
}
&:hover {
border-color: var(--l3-border) !important;
}
&:focus,
&:focus-visible {
border-color: var(--bg-robin-500) !important;
box-shadow: none !important;
outline: none;
}
}
}

View File

@@ -1,165 +0,0 @@
import { useCallback, useState } from 'react';
import { Color } from '@signozhq/design-tokens';
import {
ChevronDown,
ChevronRight,
CircleHelp,
TriangleAlert,
} from '@signozhq/icons';
import { Input } from '@signozhq/input';
import { Collapse, Form, Tooltip } from 'antd';
import { useCollapseSectionErrors } from './hooks/useCollapseSectionErrors';
import './ClaimMappingSection.styles.scss';
interface ClaimMappingSectionProps {
/** The form field name prefix for the claim mapping configuration */
fieldNamePrefix: string[];
/** Whether the section is expanded (controlled mode) */
isExpanded?: boolean;
/** Callback when expand/collapse is toggled */
onExpandChange?: (expanded: boolean) => void;
}
function ClaimMappingSection({
fieldNamePrefix,
isExpanded,
onExpandChange,
}: ClaimMappingSectionProps): JSX.Element {
// Support both controlled and uncontrolled modes
const [internalExpanded, setInternalExpanded] = useState(false);
const isControlled = isExpanded !== undefined;
const expanded = isControlled ? isExpanded : internalExpanded;
const handleCollapseChange = useCallback(
(keys: string | string[]): void => {
const newExpanded = Array.isArray(keys) ? keys.length > 0 : !!keys;
if (isControlled && onExpandChange) {
onExpandChange(newExpanded);
} else {
setInternalExpanded(newExpanded);
}
},
[isControlled, onExpandChange],
);
const collapseActiveKey = expanded ? ['claim-mapping'] : [];
const { hasErrors, errorMessages } = useCollapseSectionErrors(fieldNamePrefix);
return (
<div className="claim-mapping-section">
<Collapse
bordered={false}
activeKey={collapseActiveKey}
onChange={handleCollapseChange}
className="claim-mapping-section__collapse"
expandIcon={(): null => null}
>
<Collapse.Panel
key="claim-mapping"
header={
<div
className="claim-mapping-section__collapse-header"
role="button"
aria-expanded={expanded}
aria-controls="claim-mapping-content"
>
{!expanded ? <ChevronRight size={16} /> : <ChevronDown size={16} />}
<div className="claim-mapping-section__collapse-header-text">
<h4 className="claim-mapping-section__section-title">
Claim Mapping (Advanced)
</h4>
<p className="claim-mapping-section__section-description">
Configure how claims from your Identity Provider map to SigNoz user
attributes. Leave empty to use default values.
</p>
</div>
{!expanded && hasErrors && (
<Tooltip
title={
<div>
{errorMessages.map((msg) => (
<div key={msg}>{msg}</div>
))}
</div>
}
>
<TriangleAlert size={16} color={Color.BG_CHERRY_500} />
</Tooltip>
)}
</div>
}
>
<div id="claim-mapping-content" className="claim-mapping-section__content">
{/* Email Claim */}
<div className="claim-mapping-section__field-group">
<label className="claim-mapping-section__label" htmlFor="email-claim">
Email Claim
<Tooltip title="The claim key that contains the user's email address. Default: 'email'">
<CircleHelp size={14} className="claim-mapping-section__label-icon" />
</Tooltip>
</label>
<Form.Item
name={[...fieldNamePrefix, 'email']}
className="claim-mapping-section__form-item"
>
<Input id="email-claim" placeholder="Email" />
</Form.Item>
</div>
{/* Name Claim */}
<div className="claim-mapping-section__field-group">
<label className="claim-mapping-section__label" htmlFor="name-claim">
Name Claim
<Tooltip title="The claim key that contains the user's display name. Default: 'name'">
<CircleHelp size={14} className="claim-mapping-section__label-icon" />
</Tooltip>
</label>
<Form.Item
name={[...fieldNamePrefix, 'name']}
className="claim-mapping-section__form-item"
>
<Input id="name-claim" placeholder="Name" />
</Form.Item>
</div>
{/* Groups Claim */}
<div className="claim-mapping-section__field-group">
<label className="claim-mapping-section__label" htmlFor="groups-claim">
Groups Claim
<Tooltip title="The claim key that contains the user's group memberships. Used for role mapping. Default: 'groups'">
<CircleHelp size={14} className="claim-mapping-section__label-icon" />
</Tooltip>
</label>
<Form.Item
name={[...fieldNamePrefix, 'groups']}
className="claim-mapping-section__form-item"
>
<Input id="groups-claim" placeholder="Groups" />
</Form.Item>
</div>
{/* Role Claim */}
<div className="claim-mapping-section__field-group">
<label className="claim-mapping-section__label" htmlFor="role-claim">
Role Claim
<Tooltip title="The claim key that contains the user's role directly from the IDP. Default: 'role'">
<CircleHelp size={14} className="claim-mapping-section__label-icon" />
</Tooltip>
</label>
<Form.Item
name={[...fieldNamePrefix, 'role']}
className="claim-mapping-section__form-item"
>
<Input id="role-claim" placeholder="Role" />
</Form.Item>
</div>
</div>
</Collapse.Panel>
</Collapse>
</div>
);
}
export default ClaimMappingSection;

View File

@@ -1,103 +0,0 @@
.domain-mapping-list {
display: flex;
flex-direction: column;
gap: 8px;
&__header {
display: flex;
flex-direction: column;
gap: 2px;
margin-bottom: 4px;
}
&__title {
margin: 0;
color: var(--l1-foreground);
}
&__description {
margin: 0;
color: var(--l3-foreground);
}
&__items {
display: flex;
flex-direction: column;
gap: 8px;
}
&__row {
display: flex;
align-items: flex-start;
gap: 8px;
.ant-form-item {
margin-bottom: 0;
}
}
&__field {
flex: 1;
}
&__remove-btn {
flex-shrink: 0;
width: 32px !important;
height: 32px !important;
min-width: 32px !important;
padding: 0 !important;
border: none !important;
border-radius: 2px !important;
background: transparent !important;
color: var(--bg-cherry-500) !important;
opacity: 0.6 !important;
cursor: pointer;
transition: background-color 0.2s, opacity 0.2s;
box-shadow: none !important;
display: flex;
align-items: center;
justify-content: center;
svg {
color: var(--bg-cherry-500) !important;
width: 12px !important;
height: 12px !important;
}
&:hover {
background: rgba(229, 72, 77, 0.1) !important;
opacity: 0.9 !important;
color: var(--bg-cherry-500) !important;
svg {
color: var(--bg-cherry-500) !important;
}
}
&:active {
opacity: 0.7 !important;
background: rgba(229, 72, 77, 0.15) !important;
}
}
&__add-btn {
width: 100%;
// Ensure icon is visible
svg,
[class*='icon'] {
color: var(--l2-foreground) !important;
display: inline-block !important;
opacity: 1 !important;
}
&:hover {
color: var(--l1-foreground);
svg,
[class*='icon'] {
color: var(--l1-foreground) !important;
}
}
}
}

View File

@@ -1,88 +0,0 @@
import { Button } from '@signozhq/button';
import { Plus, Trash2 } from '@signozhq/icons';
import { Input } from '@signozhq/input';
import { Form } from 'antd';
import './DomainMappingList.styles.scss';
const EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
const validateEmail = (_: unknown, value: string): Promise<void> => {
if (!value) {
return Promise.reject(new Error('Admin email is required'));
}
if (!EMAIL_REGEX.test(value)) {
return Promise.reject(new Error('Please enter a valid email'));
}
return Promise.resolve();
};
interface DomainMappingListProps {
/** The form field name prefix for the domain mapping list */
fieldNamePrefix: string[];
}
function DomainMappingList({
fieldNamePrefix,
}: DomainMappingListProps): JSX.Element {
return (
<div className="domain-mapping-list">
<div className="domain-mapping-list__header">
<span className="domain-mapping-list__title">
Domain to Admin Email Mapping
</span>
<p className="domain-mapping-list__description">
Map workspace domains to admin emails for service account impersonation.
Use &quot;*&quot; as a wildcard for any domain.
</p>
</div>
<Form.List name={fieldNamePrefix}>
{(fields, { add, remove }): JSX.Element => (
<div className="domain-mapping-list__items">
{fields.map((field) => (
<div key={field.key} className="domain-mapping-list__row">
<Form.Item
name={[field.name, 'domain']}
className="domain-mapping-list__field"
rules={[{ required: true, message: 'Domain is required' }]}
>
<Input placeholder="Domain (e.g., example.com or *)" />
</Form.Item>
<Form.Item
name={[field.name, 'adminEmail']}
className="domain-mapping-list__field"
rules={[{ validator: validateEmail }]}
>
<Input placeholder="Admin Email" />
</Form.Item>
<Button
variant="ghost"
color="secondary"
className="domain-mapping-list__remove-btn"
onClick={(): void => remove(field.name)}
aria-label="Remove mapping"
>
<Trash2 size={12} />
</Button>
</div>
))}
<Button
variant="dashed"
onClick={(): void => add({ domain: '', adminEmail: '' })}
prefixIcon={<Plus size={14} />}
className="domain-mapping-list__add-btn"
>
Add Domain Mapping
</Button>
</div>
)}
</Form.List>
</div>
);
}
export default DomainMappingList;

View File

@@ -1,28 +0,0 @@
.email-tag-input {
display: flex;
flex-direction: column;
gap: 4px;
&__select {
width: 100%;
.ant-select-selector {
.ant-select-selection-search {
input {
height: 32px !important;
border: none !important;
background: transparent !important;
padding: 0 !important;
margin: 0 !important;
box-shadow: none !important;
font-family: inherit !important;
}
}
}
}
&__error {
margin: 0;
color: var(--bg-cherry-500);
}
}

View File

@@ -1,61 +0,0 @@
import { useCallback, useState } from 'react';
import { Select, Tooltip } from 'antd';
import './EmailTagInput.styles.scss';
const EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
interface EmailTagInputProps {
/** Current value (array of email strings) */
value?: string[];
/** Change handler */
onChange?: (value: string[]) => void;
/** Placeholder text */
placeholder?: string;
}
function EmailTagInput({
value = [],
onChange,
placeholder = 'Type an email and press Enter',
}: EmailTagInputProps): JSX.Element {
const [validationError, setValidationError] = useState('');
const handleChange = useCallback(
(newValues: string[]): void => {
const addedValues = newValues.filter((v) => !value.includes(v));
const invalidEmail = addedValues.find((v) => !EMAIL_REGEX.test(v));
if (invalidEmail) {
setValidationError(`"${invalidEmail}" is not a valid email`);
return;
}
setValidationError('');
onChange?.(newValues);
},
[onChange, value],
);
return (
<div className="email-tag-input">
<Tooltip
title={validationError}
open={!!validationError}
placement="topRight"
>
<Select
mode="tags"
value={value}
onChange={handleChange}
placeholder={placeholder}
tokenSeparators={[',', ' ']}
className="email-tag-input__select"
allowClear
status={validationError ? 'error' : undefined}
/>
</Tooltip>
</div>
);
}
export default EmailTagInput;

View File

@@ -1,294 +0,0 @@
.role-mapping-section {
display: flex;
flex-direction: column;
margin-top: 24px;
&__collapse {
background: transparent !important;
.ant-collapse-item {
border: none !important;
}
.ant-collapse-header {
padding: 0 !important;
}
.ant-collapse-content {
border-top: none !important;
background: transparent !important;
}
.ant-collapse-content-box {
padding: 12px 0 0 24px !important;
}
}
&__collapse-header {
display: flex;
align-items: flex-start;
gap: 8px;
cursor: pointer;
position: relative;
width: 100%;
svg {
margin-top: 2px;
color: var(--l3-foreground);
flex-shrink: 0;
}
}
&__collapse-header-text {
display: flex;
flex-direction: column;
gap: 4px;
}
&__section-title {
margin: 0;
color: var(--l1-foreground);
}
&__section-description {
margin: 0;
color: var(--l3-foreground);
}
&__content {
display: flex;
flex-direction: column;
gap: 16px;
max-height: 45vh;
overflow-y: auto;
padding-right: 4px;
// Thin scrollbar
&::-webkit-scrollbar {
width: 4px;
}
&::-webkit-scrollbar-track {
background: transparent;
}
&::-webkit-scrollbar-thumb {
background: var(--l3-foreground);
border-radius: 4px;
&:hover {
background: var(--l2-foreground);
}
}
scrollbar-width: thin;
scrollbar-color: var(--l3-foreground) transparent;
}
&__field-group {
display: flex;
flex-direction: column;
gap: 4px;
}
&__label {
display: inline-flex;
align-items: center;
gap: 6px;
color: var(--l1-foreground);
}
&__label-icon {
color: var(--l3-foreground);
cursor: help;
flex-shrink: 0;
}
&__form-item {
margin-bottom: 0 !important;
}
&__checkbox-row {
display: flex;
align-items: center;
gap: 6px;
}
&__select {
width: 100%;
&.ant-select {
.ant-select-selector {
height: 32px;
background: var(--l3-background) !important;
border: 1px solid var(--l3-border) !important;
border-radius: 2px;
color: var(--l1-foreground) !important;
.ant-select-selection-item {
color: var(--l1-foreground) !important;
}
}
&:hover .ant-select-selector {
border-color: var(--l3-border) !important;
}
&.ant-select-focused .ant-select-selector {
border-color: var(--bg-robin-500) !important;
box-shadow: none !important;
}
.ant-select-arrow {
color: var(--l3-foreground);
}
}
}
&__group-mappings {
display: flex;
flex-direction: column;
gap: 8px;
}
&__group-header {
display: flex;
flex-direction: column;
gap: 2px;
margin-bottom: 4px;
}
&__group-title {
margin: 0;
color: var(--l1-foreground);
}
&__group-description {
margin: 0;
color: var(--l3-foreground);
}
&__items {
display: flex;
flex-direction: column;
gap: 8px;
}
&__row {
display: flex;
align-items: flex-start;
gap: 8px;
.ant-form-item {
margin-bottom: 0;
}
}
&__field {
&--group {
flex: 2;
}
&--role {
flex: 1;
min-width: 120px;
}
}
&__remove-btn {
flex-shrink: 0;
width: 32px !important;
height: 32px !important;
min-width: 32px !important;
padding: 0 !important;
border: none !important;
border-radius: 2px !important;
background: transparent !important;
color: var(--bg-cherry-500) !important;
opacity: 0.6 !important;
cursor: pointer;
transition: background-color 0.2s, opacity 0.2s;
box-shadow: none !important;
display: flex;
align-items: center;
justify-content: center;
svg {
color: var(--bg-cherry-500) !important;
width: 12px !important;
height: 12px !important;
}
&:hover {
background: rgba(229, 72, 77, 0.1) !important;
opacity: 0.9 !important;
color: var(--bg-cherry-500) !important;
svg {
color: var(--bg-cherry-500) !important;
}
}
&:active {
opacity: 0.7 !important;
background: rgba(229, 72, 77, 0.15) !important;
}
}
&__add-btn {
width: 100%;
// Ensure icon is visible
svg,
[class*='icon'] {
color: var(--l2-foreground) !important;
display: inline-block !important;
opacity: 1 !important;
}
&:hover {
color: var(--l1-foreground);
svg,
[class*='icon'] {
color: var(--l1-foreground) !important;
}
}
}
// --- Checkbox border visibility ---
button[role='checkbox'] {
border: 1px solid var(--l2-foreground) !important;
border-radius: 2px;
&[data-state='checked'] {
background-color: var(--bg-robin-500) !important;
border-color: var(--bg-robin-500) !important;
}
}
// --- Input styles ---
input {
height: 32px;
background: var(--l3-background) !important;
border: 1px solid var(--l3-border) !important;
border-radius: 2px;
color: var(--l1-foreground) !important;
&::placeholder {
color: var(--l3-foreground) !important;
opacity: 1;
}
&:hover {
border-color: var(--l3-border) !important;
}
&:focus,
&:focus-visible {
border-color: var(--bg-robin-500) !important;
box-shadow: none !important;
outline: none;
}
}
}

View File

@@ -1,223 +0,0 @@
import { useCallback, useState } from 'react';
import { Button } from '@signozhq/button';
import { Checkbox } from '@signozhq/checkbox';
import { Color } from '@signozhq/design-tokens';
import {
ChevronDown,
ChevronRight,
CircleHelp,
Plus,
Trash2,
TriangleAlert,
} from '@signozhq/icons';
import { Input } from '@signozhq/input';
import { Collapse, Form, Select, Tooltip } from 'antd';
import { useCollapseSectionErrors } from './hooks/useCollapseSectionErrors';
import './RoleMappingSection.styles.scss';
const ROLE_OPTIONS = [
{ value: 'VIEWER', label: 'VIEWER' },
{ value: 'EDITOR', label: 'EDITOR' },
{ value: 'ADMIN', label: 'ADMIN' },
];
interface RoleMappingSectionProps {
/** The form field name prefix for the role mapping configuration */
fieldNamePrefix: string[];
/** Whether the section is expanded (controlled mode) */
isExpanded?: boolean;
/** Callback when expand/collapse is toggled */
onExpandChange?: (expanded: boolean) => void;
}
function RoleMappingSection({
fieldNamePrefix,
isExpanded,
onExpandChange,
}: RoleMappingSectionProps): JSX.Element {
const form = Form.useFormInstance();
const useRoleAttribute = Form.useWatch(
[...fieldNamePrefix, 'useRoleAttribute'],
form,
);
// Support both controlled and uncontrolled modes
const [internalExpanded, setInternalExpanded] = useState(false);
const isControlled = isExpanded !== undefined;
const expanded = isControlled ? isExpanded : internalExpanded;
const handleCollapseChange = useCallback(
(keys: string | string[]): void => {
const newExpanded = Array.isArray(keys) ? keys.length > 0 : !!keys;
if (isControlled && onExpandChange) {
onExpandChange(newExpanded);
} else {
setInternalExpanded(newExpanded);
}
},
[isControlled, onExpandChange],
);
const collapseActiveKey = expanded ? ['role-mapping'] : [];
const { hasErrors, errorMessages } = useCollapseSectionErrors(fieldNamePrefix);
return (
<div className="role-mapping-section">
<Collapse
bordered={false}
activeKey={collapseActiveKey}
onChange={handleCollapseChange}
className="role-mapping-section__collapse"
expandIcon={(): null => null}
>
<Collapse.Panel
key="role-mapping"
header={
<div
className="role-mapping-section__collapse-header"
role="button"
aria-expanded={expanded}
aria-controls="role-mapping-content"
>
{!expanded ? <ChevronRight size={16} /> : <ChevronDown size={16} />}
<div className="role-mapping-section__collapse-header-text">
<h4 className="role-mapping-section__section-title">
Role Mapping (Advanced)
</h4>
<p className="role-mapping-section__section-description">
Configure how user roles are determined from your Identity Provider.
You can either use a direct role attribute or map IDP groups to SigNoz
roles.
</p>
</div>
{!expanded && hasErrors && (
<Tooltip
title={
<div>
{errorMessages.map((msg) => (
<div key={msg}>{msg}</div>
))}
</div>
}
>
<TriangleAlert size={16} color={Color.BG_CHERRY_500} />
</Tooltip>
)}
</div>
}
>
<div id="role-mapping-content" className="role-mapping-section__content">
{/* Default Role */}
<div className="role-mapping-section__field-group">
<label className="role-mapping-section__label" htmlFor="default-role">
Default Role
<Tooltip title='The default role assigned to new SSO users if no other role mapping applies. Default: "VIEWER"'>
<CircleHelp size={14} className="role-mapping-section__label-icon" />
</Tooltip>
</label>
<Form.Item
name={[...fieldNamePrefix, 'defaultRole']}
className="role-mapping-section__form-item"
initialValue="VIEWER"
>
<Select
id="default-role"
options={ROLE_OPTIONS}
className="role-mapping-section__select"
/>
</Form.Item>
</div>
{/* Use Role Attribute */}
<div className="role-mapping-section__checkbox-row">
<Form.Item
name={[...fieldNamePrefix, 'useRoleAttribute']}
valuePropName="checked"
noStyle
>
<Checkbox
id="use-role-attribute"
labelName="Use Role Attribute Directly"
onCheckedChange={(checked: boolean): void => {
form.setFieldValue([...fieldNamePrefix, 'useRoleAttribute'], checked);
}}
/>
</Form.Item>
<Tooltip title="If enabled, the role claim/attribute from the IDP will be used directly instead of group mappings. The role value must match a SigNoz role (VIEWER, EDITOR, or ADMIN).">
<CircleHelp size={14} className="role-mapping-section__label-icon" />
</Tooltip>
</div>
{/* Group to Role Mappings - only show when useRoleAttribute is false */}
{!useRoleAttribute && (
<div className="role-mapping-section__group-mappings">
<div className="role-mapping-section__group-header">
<span className="role-mapping-section__group-title">
Group to Role Mappings
</span>
<p className="role-mapping-section__group-description">
Map IDP group names to SigNoz roles. If a user belongs to multiple
groups, the highest privilege role will be assigned.
</p>
</div>
<Form.List name={[...fieldNamePrefix, 'groupMappingsList']}>
{(fields, { add, remove }): JSX.Element => (
<div className="role-mapping-section__items">
{fields.map((field) => (
<div key={field.key} className="role-mapping-section__row">
<Form.Item
name={[field.name, 'groupName']}
className="role-mapping-section__field role-mapping-section__field--group"
rules={[{ required: true, message: 'Group name is required' }]}
>
<Input placeholder="IDP Group Name" />
</Form.Item>
<Form.Item
name={[field.name, 'role']}
className="role-mapping-section__field role-mapping-section__field--role"
rules={[{ required: true, message: 'Role is required' }]}
initialValue="VIEWER"
>
<Select
options={ROLE_OPTIONS}
className="role-mapping-section__select"
/>
</Form.Item>
<Button
variant="ghost"
color="secondary"
className="role-mapping-section__remove-btn"
onClick={(): void => remove(field.name)}
aria-label="Remove mapping"
>
<Trash2 size={12} />
</Button>
</div>
))}
<Button
variant="dashed"
onClick={(): void => add({ groupName: '', role: 'VIEWER' })}
prefixIcon={<Plus size={14} />}
className="role-mapping-section__add-btn"
>
Add Group Mapping
</Button>
</div>
)}
</Form.List>
</div>
)}
</div>
</Collapse.Panel>
</Collapse>
</div>
);
}
export default RoleMappingSection;

View File

@@ -1,60 +0,0 @@
import { useMemo } from 'react';
import { Form } from 'antd';
export interface CollapseSectionErrors {
hasErrors: boolean;
errorMessages: string[];
}
/**
* Detects validation errors in a form section
* @param fieldNamePrefix - Field path prefix for the section
* @param specificFields - Optional specific field prefixes to check (uses prefix matching)
*/
export function useCollapseSectionErrors(
fieldNamePrefix: string[],
specificFields?: string[][],
): CollapseSectionErrors {
const form = Form.useFormInstance();
Form.useWatch([], form);
// eslint-disable-next-line sonarjs/cognitive-complexity
return useMemo(() => {
const fieldErrors = form.getFieldsError();
const messages: string[] = [];
if (specificFields?.length) {
fieldErrors.forEach((field) => {
const fieldPath = Array.isArray(field.name) ? field.name : [field.name];
const isMatch = specificFields.some((specificField) => {
if (fieldPath.length < specificField.length) {
return false;
}
return specificField.every((part, idx) => fieldPath[idx] === part);
});
if (isMatch && field.errors.length > 0) {
field.errors.forEach((error) => messages.push(error));
}
});
} else {
const prefixPath = fieldNamePrefix.join('.');
fieldErrors.forEach((field) => {
const fieldPath = Array.isArray(field.name)
? field.name.join('.')
: String(field.name);
if (fieldPath.startsWith(prefixPath) && field.errors.length > 0) {
field.errors.forEach((error) => messages.push(error));
}
});
}
return {
hasErrors: messages.length > 0,
errorMessages: messages,
};
}, [form, fieldNamePrefix, specificFields]);
}

View File

@@ -1,74 +1,45 @@
import { useEffect, useState } from 'react';
import { Switch } from '@signozhq/switch';
import { ErrorResponseHandlerV2 } from 'api/ErrorResponseHandlerV2';
import { useUpdateAuthDomain } from 'api/generated/services/authdomains';
import {
AuthtypesGettableAuthDomainDTO,
RenderErrorResponseDTO,
} from 'api/generated/services/sigNoz.schemas';
import { AxiosError } from 'axios';
import { useState } from 'react';
import { Switch } from 'antd';
import put from 'api/v1/domains/id/put';
import { useErrorModal } from 'providers/ErrorModalProvider';
import { ErrorV2Resp } from 'types/api';
import APIError from 'types/api/error';
interface ToggleProps {
isDefaultChecked: boolean;
record: AuthtypesGettableAuthDomainDTO;
}
import { GettableAuthDomain } from 'types/api/v1/domains/list';
function Toggle({ isDefaultChecked, record }: ToggleProps): JSX.Element {
const [isChecked, setIsChecked] = useState<boolean>(isDefaultChecked);
const [isLoading, setIsLoading] = useState<boolean>(false);
const { showErrorModal } = useErrorModal();
useEffect(() => {
setIsChecked(isDefaultChecked);
}, [isDefaultChecked]);
const onChangeHandler = async (checked: boolean): Promise<void> => {
setIsLoading(true);
const { mutate: updateAuthDomain, isLoading } = useUpdateAuthDomain<
AxiosError<RenderErrorResponseDTO>
>();
const onChangeHandler = (checked: boolean): void => {
if (!record.id) {
return;
try {
await put({
id: record.id,
config: {
ssoEnabled: checked,
ssoType: record.ssoType,
googleAuthConfig: record.googleAuthConfig,
oidcConfig: record.oidcConfig,
samlConfig: record.samlConfig,
},
});
setIsChecked(checked);
} catch (error) {
showErrorModal(error as APIError);
}
setIsChecked(checked);
updateAuthDomain(
{
pathParams: { id: record.id },
data: {
config: {
ssoEnabled: checked,
ssoType: record.ssoType,
googleAuthConfig: record.googleAuthConfig,
oidcConfig: record.oidcConfig,
samlConfig: record.samlConfig,
roleMapping: record.roleMapping,
},
},
},
{
onError: (error) => {
setIsChecked(!checked);
try {
ErrorResponseHandlerV2(error as AxiosError<ErrorV2Resp>);
} catch (apiError) {
showErrorModal(apiError as APIError);
}
},
},
);
setIsLoading(false);
};
return (
<Switch
disabled={isLoading}
checked={isChecked}
onCheckedChange={onChangeHandler}
/>
<Switch loading={isLoading} checked={isChecked} onChange={onChangeHandler} />
);
}
interface ToggleProps {
isDefaultChecked: boolean;
record: GettableAuthDomain;
}
export default Toggle;

View File

@@ -1,142 +0,0 @@
import { rest, server } from 'mocks-server/server';
import { render, screen, userEvent, waitFor } from 'tests/test-utils';
import AuthDomain from '../index';
import {
AUTH_DOMAINS_LIST_ENDPOINT,
mockDomainsListResponse,
mockEmptyDomainsResponse,
mockErrorResponse,
} from './mocks';
const successNotification = jest.fn();
jest.mock('hooks/useNotifications', () => ({
__esModule: true,
useNotifications: jest.fn(() => ({
notifications: {
success: successNotification,
error: jest.fn(),
},
})),
}));
describe('AuthDomain', () => {
beforeEach(() => {
jest.clearAllMocks();
});
afterEach(() => {
server.resetHandlers();
});
describe('List View', () => {
it('renders page header and add button', async () => {
server.use(
rest.get(AUTH_DOMAINS_LIST_ENDPOINT, (_, res, ctx) =>
res(ctx.status(200), ctx.json(mockEmptyDomainsResponse)),
),
);
render(<AuthDomain />);
expect(screen.getByText(/authenticated domains/i)).toBeInTheDocument();
expect(
screen.getByRole('button', { name: /add domain/i }),
).toBeInTheDocument();
});
it('renders list of auth domains successfully', async () => {
server.use(
rest.get(AUTH_DOMAINS_LIST_ENDPOINT, (_, res, ctx) =>
res(ctx.status(200), ctx.json(mockDomainsListResponse)),
),
);
render(<AuthDomain />);
await waitFor(() => {
expect(screen.getByText('signoz.io')).toBeInTheDocument();
expect(screen.getByText('example.com')).toBeInTheDocument();
expect(screen.getByText('corp.io')).toBeInTheDocument();
});
});
it('renders empty state when no domains exist', async () => {
server.use(
rest.get(AUTH_DOMAINS_LIST_ENDPOINT, (_, res, ctx) =>
res(ctx.status(200), ctx.json(mockEmptyDomainsResponse)),
),
);
render(<AuthDomain />);
await waitFor(() => {
expect(screen.getByText(/no data/i)).toBeInTheDocument();
});
});
it('displays error content when API fails', async () => {
server.use(
rest.get(AUTH_DOMAINS_LIST_ENDPOINT, (_, res, ctx) =>
res(ctx.status(500), ctx.json(mockErrorResponse)),
),
);
render(<AuthDomain />);
await waitFor(() => {
expect(
screen.getByText(/failed to perform operation/i),
).toBeInTheDocument();
});
});
});
describe('Add Domain', () => {
it('opens create modal when Add Domain button is clicked', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
server.use(
rest.get(AUTH_DOMAINS_LIST_ENDPOINT, (_, res, ctx) =>
res(ctx.status(200), ctx.json(mockEmptyDomainsResponse)),
),
);
render(<AuthDomain />);
const addButton = await screen.findByRole('button', { name: /add domain/i });
await user.click(addButton);
await waitFor(() => {
expect(
screen.getByText(/configure authentication method/i),
).toBeInTheDocument();
});
});
});
describe('Configure Domain', () => {
it('opens edit modal when configure action is clicked', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
server.use(
rest.get(AUTH_DOMAINS_LIST_ENDPOINT, (_, res, ctx) =>
res(ctx.status(200), ctx.json(mockDomainsListResponse)),
),
);
render(<AuthDomain />);
await waitFor(() => {
expect(screen.getByText('signoz.io')).toBeInTheDocument();
});
const configureLinks = await screen.findAllByText(/configure google auth/i);
await user.click(configureLinks[0]);
await waitFor(() => {
expect(screen.getByText(/edit google authentication/i)).toBeInTheDocument();
});
});
});
});

View File

@@ -1,354 +0,0 @@
import { render, screen, userEvent, waitFor } from 'tests/test-utils';
import CreateEdit from '../CreateEdit/CreateEdit';
import {
mockDomainWithRoleMapping,
mockGoogleAuthDomain,
mockGoogleAuthWithWorkspaceGroups,
mockOidcAuthDomain,
mockOidcWithClaimMapping,
mockSamlAuthDomain,
mockSamlWithAttributeMapping,
} from './mocks';
const mockOnClose = jest.fn();
describe('CreateEdit Modal', () => {
beforeEach(() => {
jest.clearAllMocks();
});
describe('Provider Selection (Create Mode)', () => {
it('renders provider selection when creating new domain', () => {
render(<CreateEdit isCreate onClose={mockOnClose} />);
expect(
screen.getByText(/configure authentication method/i),
).toBeInTheDocument();
expect(screen.getByText(/google apps authentication/i)).toBeInTheDocument();
expect(screen.getByText(/saml authentication/i)).toBeInTheDocument();
expect(screen.getByText(/oidc authentication/i)).toBeInTheDocument();
});
it('returns to provider selection when back button is clicked', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
render(<CreateEdit isCreate onClose={mockOnClose} />);
const configureButtons = await screen.findAllByRole('button', {
name: /configure/i,
});
await user.click(configureButtons[0]);
await waitFor(() => {
expect(screen.getByText(/edit google authentication/i)).toBeInTheDocument();
});
const backButton = screen.getByRole('button', { name: /back/i });
await user.click(backButton);
await waitFor(() => {
expect(
screen.getByText(/configure authentication method/i),
).toBeInTheDocument();
});
});
});
describe('Edit Mode', () => {
it('shows provider form directly when editing existing domain', () => {
render(
<CreateEdit
isCreate={false}
record={mockGoogleAuthDomain}
onClose={mockOnClose}
/>,
);
expect(screen.getByText(/edit google authentication/i)).toBeInTheDocument();
expect(
screen.queryByText(/configure authentication method/i),
).not.toBeInTheDocument();
});
it('pre-fills form with existing domain values', () => {
render(
<CreateEdit
isCreate={false}
record={mockGoogleAuthDomain}
onClose={mockOnClose}
/>,
);
expect(screen.getByDisplayValue('signoz.io')).toBeInTheDocument();
expect(screen.getByDisplayValue('test-client-id')).toBeInTheDocument();
});
it('disables domain field when editing', () => {
render(
<CreateEdit
isCreate={false}
record={mockGoogleAuthDomain}
onClose={mockOnClose}
/>,
);
const domainInput = screen.getByDisplayValue('signoz.io');
expect(domainInput).toBeDisabled();
});
it('shows cancel button instead of back when editing', () => {
render(
<CreateEdit
isCreate={false}
record={mockGoogleAuthDomain}
onClose={mockOnClose}
/>,
);
expect(screen.getByRole('button', { name: /cancel/i })).toBeInTheDocument();
expect(
screen.queryByRole('button', { name: /back/i }),
).not.toBeInTheDocument();
});
});
describe('Form Validation', () => {
it('shows validation error when submitting without required fields', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
render(<CreateEdit isCreate onClose={mockOnClose} />);
const configureButtons = await screen.findAllByRole('button', {
name: /configure/i,
});
await user.click(configureButtons[0]);
const saveButton = await screen.findByRole('button', {
name: /save changes/i,
});
await user.click(saveButton);
await waitFor(() => {
expect(screen.getByText(/domain is required/i)).toBeInTheDocument();
});
});
});
describe('Google Auth Provider', () => {
it('shows Google Auth form fields', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
render(<CreateEdit isCreate onClose={mockOnClose} />);
const configureButtons = await screen.findAllByRole('button', {
name: /configure/i,
});
await user.click(configureButtons[0]);
await waitFor(() => {
expect(screen.getByText(/edit google authentication/i)).toBeInTheDocument();
expect(screen.getByLabelText(/domain/i)).toBeInTheDocument();
expect(screen.getByLabelText(/client id/i)).toBeInTheDocument();
expect(screen.getByLabelText(/client secret/i)).toBeInTheDocument();
expect(screen.getByText(/skip email verification/i)).toBeInTheDocument();
});
});
it('shows workspace groups section when expanded', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
render(
<CreateEdit
isCreate={false}
record={mockGoogleAuthWithWorkspaceGroups}
onClose={mockOnClose}
/>,
);
const workspaceHeader = screen.getByText(/google workspace groups/i);
await user.click(workspaceHeader);
await waitFor(() => {
expect(screen.getByText(/fetch groups/i)).toBeInTheDocument();
expect(screen.getByText(/service account json/i)).toBeInTheDocument();
});
});
});
describe('SAML Provider', () => {
it('shows SAML-specific fields when editing SAML domain', () => {
render(
<CreateEdit
isCreate={false}
record={mockSamlAuthDomain}
onClose={mockOnClose}
/>,
);
expect(screen.getByText(/edit saml authentication/i)).toBeInTheDocument();
expect(
screen.getByDisplayValue('https://idp.example.com/sso'),
).toBeInTheDocument();
expect(screen.getByDisplayValue('urn:example:idp')).toBeInTheDocument();
});
it('shows attribute mapping section for SAML', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
render(
<CreateEdit
isCreate={false}
record={mockSamlWithAttributeMapping}
onClose={mockOnClose}
/>,
);
expect(
screen.getByText(/attribute mapping \(advanced\)/i),
).toBeInTheDocument();
const attributeHeader = screen.getByText(/attribute mapping \(advanced\)/i);
await user.click(attributeHeader);
await waitFor(() => {
expect(screen.getByLabelText(/name attribute/i)).toBeInTheDocument();
expect(screen.getByLabelText(/groups attribute/i)).toBeInTheDocument();
expect(screen.getByLabelText(/role attribute/i)).toBeInTheDocument();
});
});
});
describe('OIDC Provider', () => {
it('shows OIDC-specific fields when editing OIDC domain', () => {
render(
<CreateEdit
isCreate={false}
record={mockOidcAuthDomain}
onClose={mockOnClose}
/>,
);
expect(screen.getByText(/edit oidc authentication/i)).toBeInTheDocument();
expect(screen.getByDisplayValue('https://oidc.corp.io')).toBeInTheDocument();
expect(screen.getByDisplayValue('oidc-client-id')).toBeInTheDocument();
});
it('shows claim mapping section for OIDC', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
render(
<CreateEdit
isCreate={false}
record={mockOidcWithClaimMapping}
onClose={mockOnClose}
/>,
);
expect(screen.getByText(/claim mapping \(advanced\)/i)).toBeInTheDocument();
const claimHeader = screen.getByText(/claim mapping \(advanced\)/i);
await user.click(claimHeader);
await waitFor(() => {
expect(screen.getByLabelText(/email claim/i)).toBeInTheDocument();
expect(screen.getByLabelText(/name claim/i)).toBeInTheDocument();
expect(screen.getByLabelText(/groups claim/i)).toBeInTheDocument();
expect(screen.getByLabelText(/role claim/i)).toBeInTheDocument();
});
});
it('shows OIDC options checkboxes', () => {
render(
<CreateEdit
isCreate={false}
record={mockOidcAuthDomain}
onClose={mockOnClose}
/>,
);
expect(screen.getByText(/skip email verification/i)).toBeInTheDocument();
expect(screen.getByText(/get user info/i)).toBeInTheDocument();
});
});
describe('Role Mapping', () => {
it('shows role mapping section in provider forms', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
render(<CreateEdit isCreate onClose={mockOnClose} />);
const configureButtons = await screen.findAllByRole('button', {
name: /configure/i,
});
await user.click(configureButtons[0]);
await waitFor(() => {
expect(screen.getByText(/role mapping \(advanced\)/i)).toBeInTheDocument();
});
});
it('expands role mapping section to show default role selector', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
render(
<CreateEdit
isCreate={false}
record={mockDomainWithRoleMapping}
onClose={mockOnClose}
/>,
);
const roleMappingHeader = screen.getByText(/role mapping \(advanced\)/i);
await user.click(roleMappingHeader);
await waitFor(() => {
expect(screen.getByText(/default role/i)).toBeInTheDocument();
expect(
screen.getByText(/use role attribute directly/i),
).toBeInTheDocument();
});
});
it('shows group mappings section when useRoleAttribute is false', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
render(
<CreateEdit
isCreate={false}
record={mockDomainWithRoleMapping}
onClose={mockOnClose}
/>,
);
const roleMappingHeader = screen.getByText(/role mapping \(advanced\)/i);
await user.click(roleMappingHeader);
await waitFor(() => {
expect(screen.getByText(/group to role mappings/i)).toBeInTheDocument();
expect(
screen.getByRole('button', { name: /add group mapping/i }),
).toBeInTheDocument();
});
});
});
describe('Modal Actions', () => {
it('calls onClose when cancel button is clicked', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
render(
<CreateEdit
isCreate={false}
record={mockGoogleAuthDomain}
onClose={mockOnClose}
/>,
);
const cancelButton = screen.getByRole('button', { name: /cancel/i });
await user.click(cancelButton);
expect(mockOnClose).toHaveBeenCalled();
});
});
});

View File

@@ -1,115 +0,0 @@
import { rest, server } from 'mocks-server/server';
import { render, screen, userEvent, waitFor } from 'tests/test-utils';
import Toggle from '../Toggle';
import {
AUTH_DOMAINS_UPDATE_ENDPOINT,
mockErrorResponse,
mockGoogleAuthDomain,
mockUpdateSuccessResponse,
} from './mocks';
describe('Toggle', () => {
beforeEach(() => {
jest.clearAllMocks();
});
afterEach(() => {
server.resetHandlers();
});
it('renders switch with correct initial state', () => {
render(<Toggle isDefaultChecked={true} record={mockGoogleAuthDomain} />);
const switchElement = screen.getByRole('switch');
expect(switchElement).toBeChecked();
});
it('renders unchecked switch when SSO is disabled', () => {
render(
<Toggle
isDefaultChecked={false}
record={{ ...mockGoogleAuthDomain, ssoEnabled: false }}
/>,
);
const switchElement = screen.getByRole('switch');
expect(switchElement).not.toBeChecked();
});
it('calls update API when toggle is clicked', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
const mockUpdateAPI = jest.fn();
server.use(
rest.put(AUTH_DOMAINS_UPDATE_ENDPOINT, async (req, res, ctx) => {
const body = await req.json();
mockUpdateAPI(body);
return res(ctx.status(200), ctx.json(mockUpdateSuccessResponse));
}),
);
render(<Toggle isDefaultChecked={true} record={mockGoogleAuthDomain} />);
const switchElement = screen.getByRole('switch');
await user.click(switchElement);
await waitFor(() => {
expect(switchElement).not.toBeChecked();
});
expect(mockUpdateAPI).toHaveBeenCalledTimes(1);
expect(mockUpdateAPI).toHaveBeenCalledWith(
expect.objectContaining({
config: expect.objectContaining({
ssoEnabled: false,
}),
}),
);
});
it('shows error modal when update fails', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
server.use(
rest.put(AUTH_DOMAINS_UPDATE_ENDPOINT, (_, res, ctx) =>
res(ctx.status(500), ctx.json(mockErrorResponse)),
),
);
render(<Toggle isDefaultChecked={true} record={mockGoogleAuthDomain} />);
const switchElement = screen.getByRole('switch');
await user.click(switchElement);
await waitFor(() => {
expect(screen.getByText(/failed to perform operation/i)).toBeInTheDocument();
});
});
it('does not call API when record has no id', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
let apiCalled = false;
server.use(
rest.put(AUTH_DOMAINS_UPDATE_ENDPOINT, (_, res, ctx) => {
apiCalled = true;
return res(ctx.status(200), ctx.json(mockUpdateSuccessResponse));
}),
);
render(
<Toggle
isDefaultChecked={true}
record={{ ...mockGoogleAuthDomain, id: undefined }}
/>,
);
const switchElement = screen.getByRole('switch');
await user.click(switchElement);
// Wait a bit to ensure no API call was made
await new Promise((resolve) => setTimeout(resolve, 100));
expect(apiCalled).toBe(false);
});
});

View File

@@ -1,220 +0,0 @@
import { AuthtypesGettableAuthDomainDTO } from 'api/generated/services/sigNoz.schemas';
// API Endpoints
export const AUTH_DOMAINS_LIST_ENDPOINT = '*/api/v1/domains';
export const AUTH_DOMAINS_CREATE_ENDPOINT = '*/api/v1/domains';
export const AUTH_DOMAINS_UPDATE_ENDPOINT = '*/api/v1/domains/:id';
export const AUTH_DOMAINS_DELETE_ENDPOINT = '*/api/v1/domains/:id';
// Mock Auth Domain with Google Auth
export const mockGoogleAuthDomain: AuthtypesGettableAuthDomainDTO = {
id: 'domain-1',
name: 'signoz.io',
ssoEnabled: true,
ssoType: 'google_auth',
googleAuthConfig: {
clientId: 'test-client-id',
clientSecret: 'test-client-secret',
},
authNProviderInfo: {
relayStatePath: 'api/v1/sso/relay/domain-1',
},
};
// Mock Auth Domain with SAML
export const mockSamlAuthDomain: AuthtypesGettableAuthDomainDTO = {
id: 'domain-2',
name: 'example.com',
ssoEnabled: false,
ssoType: 'saml',
samlConfig: {
samlIdp: 'https://idp.example.com/sso',
samlEntity: 'urn:example:idp',
samlCert: 'MOCK_CERTIFICATE',
},
authNProviderInfo: {
relayStatePath: 'api/v1/sso/relay/domain-2',
},
};
// Mock Auth Domain with OIDC
export const mockOidcAuthDomain: AuthtypesGettableAuthDomainDTO = {
id: 'domain-3',
name: 'corp.io',
ssoEnabled: true,
ssoType: 'oidc',
oidcConfig: {
issuer: 'https://oidc.corp.io',
clientId: 'oidc-client-id',
clientSecret: 'oidc-client-secret',
},
authNProviderInfo: {
relayStatePath: 'api/v1/sso/relay/domain-3',
},
};
// Mock Auth Domain with Role Mapping
export const mockDomainWithRoleMapping: AuthtypesGettableAuthDomainDTO = {
id: 'domain-4',
name: 'enterprise.com',
ssoEnabled: true,
ssoType: 'saml',
samlConfig: {
samlIdp: 'https://idp.enterprise.com/sso',
samlEntity: 'urn:enterprise:idp',
samlCert: 'MOCK_CERTIFICATE',
},
roleMapping: {
defaultRole: 'EDITOR',
useRoleAttribute: false,
groupMappings: {
'admin-group': 'ADMIN',
'dev-team': 'EDITOR',
viewers: 'VIEWER',
},
},
authNProviderInfo: {
relayStatePath: 'api/v1/sso/relay/domain-4',
},
};
// Mock Auth Domain with useRoleAttribute enabled
export const mockDomainWithDirectRoleAttribute: AuthtypesGettableAuthDomainDTO = {
id: 'domain-5',
name: 'direct-role.com',
ssoEnabled: true,
ssoType: 'oidc',
oidcConfig: {
issuer: 'https://oidc.direct-role.com',
clientId: 'direct-role-client-id',
clientSecret: 'direct-role-client-secret',
},
roleMapping: {
defaultRole: 'VIEWER',
useRoleAttribute: true,
},
authNProviderInfo: {
relayStatePath: 'api/v1/sso/relay/domain-5',
},
};
// Mock OIDC domain with claim mapping
export const mockOidcWithClaimMapping: AuthtypesGettableAuthDomainDTO = {
id: 'domain-6',
name: 'oidc-claims.com',
ssoEnabled: true,
ssoType: 'oidc',
oidcConfig: {
issuer: 'https://oidc.claims.com',
issuerAlias: 'https://alias.claims.com',
clientId: 'claims-client-id',
clientSecret: 'claims-client-secret',
insecureSkipEmailVerified: true,
getUserInfo: true,
claimMapping: {
email: 'user_email',
name: 'display_name',
groups: 'user_groups',
role: 'user_role',
},
},
authNProviderInfo: {
relayStatePath: 'api/v1/sso/relay/domain-6',
},
};
// Mock SAML domain with attribute mapping
export const mockSamlWithAttributeMapping: AuthtypesGettableAuthDomainDTO = {
id: 'domain-7',
name: 'saml-attrs.com',
ssoEnabled: true,
ssoType: 'saml',
samlConfig: {
samlIdp: 'https://idp.saml-attrs.com/sso',
samlEntity: 'urn:saml-attrs:idp',
samlCert: 'MOCK_CERTIFICATE_ATTRS',
insecureSkipAuthNRequestsSigned: true,
attributeMapping: {
name: 'user_display_name',
groups: 'member_of',
role: 'signoz_role',
},
},
authNProviderInfo: {
relayStatePath: 'api/v1/sso/relay/domain-7',
},
};
// Mock Google Auth with workspace groups
export const mockGoogleAuthWithWorkspaceGroups: AuthtypesGettableAuthDomainDTO = {
id: 'domain-8',
name: 'google-groups.com',
ssoEnabled: true,
ssoType: 'google_auth',
googleAuthConfig: {
clientId: 'google-groups-client-id',
clientSecret: 'google-groups-client-secret',
insecureSkipEmailVerified: false,
fetchGroups: true,
serviceAccountJson: '{"type": "service_account"}',
domainToAdminEmail: {
'google-groups.com': 'admin@google-groups.com',
},
fetchTransitiveGroupMembership: true,
allowedGroups: ['allowed-group-1', 'allowed-group-2'],
},
authNProviderInfo: {
relayStatePath: 'api/v1/sso/relay/domain-8',
},
};
// Mock empty list response
export const mockEmptyDomainsResponse = {
status: 'success',
data: [],
};
// Mock list response with domains
export const mockDomainsListResponse = {
status: 'success',
data: [mockGoogleAuthDomain, mockSamlAuthDomain, mockOidcAuthDomain],
};
// Mock single domain list response
export const mockSingleDomainResponse = {
status: 'success',
data: [mockGoogleAuthDomain],
};
// Mock success responses
export const mockCreateSuccessResponse = {
status: 'success',
data: mockGoogleAuthDomain,
};
export const mockUpdateSuccessResponse = {
status: 'success',
data: { ...mockGoogleAuthDomain, ssoEnabled: false },
};
export const mockDeleteSuccessResponse = {
status: 'success',
data: 'Domain deleted successfully',
};
// Mock error responses
export const mockErrorResponse = {
error: {
code: 'internal_error',
message: 'Failed to perform operation',
url: '',
},
};
export const mockValidationErrorResponse = {
error: {
code: 'invalid_input',
message: 'Domain name is required',
url: '',
},
};

View File

@@ -1,193 +1,151 @@
import { useCallback, useMemo, useState } from 'react';
import { useState } from 'react';
import { useQuery } from 'react-query';
import { PlusOutlined } from '@ant-design/icons';
import { Button } from '@signozhq/button';
import { Table } from 'antd';
import { Button, Table, Typography } from 'antd';
import { ColumnsType } from 'antd/lib/table';
import { ErrorResponseHandlerV2 } from 'api/ErrorResponseHandlerV2';
import {
useDeleteAuthDomain,
useListAuthDomains,
} from 'api/generated/services/authdomains';
import {
AuthtypesGettableAuthDomainDTO,
RenderErrorResponseDTO,
} from 'api/generated/services/sigNoz.schemas';
import { AxiosError } from 'axios';
import deleteDomain from 'api/v1/domains/id/delete';
import listAllDomain from 'api/v1/domains/list';
import ErrorContent from 'components/ErrorModal/components/ErrorContent';
import { useNotifications } from 'hooks/useNotifications';
import CopyToClipboard from 'periscope/components/CopyToClipboard';
import { useErrorModal } from 'providers/ErrorModalProvider';
import { ErrorV2Resp } from 'types/api';
import APIError from 'types/api/error';
import { GettableAuthDomain, SSOType } from 'types/api/v1/domains/list';
import CreateEdit from './CreateEdit/CreateEdit';
import Toggle from './Toggle';
import './AuthDomain.styles.scss';
export const SSOType = new Map<string, string>([
['google_auth', 'Google Auth'],
['saml', 'SAML'],
['email_password', 'Email Password'],
['oidc', 'OIDC'],
]);
const columns: ColumnsType<GettableAuthDomain> = [
{
title: 'Domain',
dataIndex: 'name',
key: 'name',
width: 100,
render: (val): JSX.Element => <Typography.Text>{val}</Typography.Text>,
},
{
title: 'Enforce SSO',
dataIndex: 'ssoEnabled',
key: 'ssoEnabled',
width: 80,
render: (value: boolean, record: GettableAuthDomain): JSX.Element => (
<Toggle isDefaultChecked={value} record={record} />
),
},
{
title: 'IDP Initiated SSO URL',
dataIndex: 'relayState',
key: 'relayState',
width: 80,
render: (_, record: GettableAuthDomain): JSX.Element => {
const relayPath = record.authNProviderInfo.relayStatePath;
if (!relayPath) {
return (
<Typography.Text style={{ paddingLeft: '6px' }}>N/A</Typography.Text>
);
}
const href = `${window.location.origin}/${relayPath}`;
return <CopyToClipboard textToCopy={href} />;
},
},
{
title: 'Action',
dataIndex: 'action',
key: 'action',
width: 100,
render: (_, record: GettableAuthDomain): JSX.Element => (
<section className="auth-domain-list-column-action">
<Typography.Link data-column-action="configure">
Configure {SSOType.get(record.ssoType)}
</Typography.Link>
<Typography.Link type="danger" data-column-action="delete">
Delete
</Typography.Link>
</section>
),
},
];
async function deleteDomainById(
id: string,
showErrorModal: (error: APIError) => void,
refetchAuthDomainListResponse: () => void,
): Promise<void> {
try {
await deleteDomain(id);
refetchAuthDomainListResponse();
} catch (error) {
showErrorModal(error as APIError);
}
}
function AuthDomain(): JSX.Element {
const [record, setRecord] = useState<AuthtypesGettableAuthDomainDTO>();
const [record, setRecord] = useState<GettableAuthDomain>();
const [addDomain, setAddDomain] = useState<boolean>(false);
const { notifications } = useNotifications();
const { showErrorModal } = useErrorModal();
const {
data: authDomainListResponse,
isLoading: isLoadingAuthDomainListResponse,
isFetching: isFetchingAuthDomainListResponse,
error: errorFetchingAuthDomainListResponse,
refetch: refetchAuthDomainListResponse,
} = useListAuthDomains();
const { mutate: deleteAuthDomain } = useDeleteAuthDomain<
AxiosError<RenderErrorResponseDTO>
>();
const handleDeleteDomain = useCallback(
(id: string): void => {
deleteAuthDomain(
{ pathParams: { id } },
{
onSuccess: () => {
notifications.success({
message: 'Domain deleted successfully',
});
refetchAuthDomainListResponse();
},
onError: (error) => {
try {
ErrorResponseHandlerV2(error as AxiosError<ErrorV2Resp>);
} catch (apiError) {
showErrorModal(apiError as APIError);
}
},
},
);
},
[
deleteAuthDomain,
notifications,
refetchAuthDomainListResponse,
showErrorModal,
],
);
const formattedError = useMemo(() => {
if (!errorFetchingAuthDomainListResponse) {
return null;
}
let errorResult: APIError | null = null;
try {
ErrorResponseHandlerV2(
errorFetchingAuthDomainListResponse as AxiosError<ErrorV2Resp>,
);
} catch (error) {
errorResult = error as APIError;
}
return errorResult;
}, [errorFetchingAuthDomainListResponse]);
const columns: ColumnsType<AuthtypesGettableAuthDomainDTO> = useMemo(
() => [
{
title: 'Domain',
dataIndex: 'name',
key: 'name',
width: 100,
render: (val): JSX.Element => <span>{val}</span>,
},
{
title: 'Enforce SSO',
dataIndex: 'ssoEnabled',
key: 'ssoEnabled',
width: 80,
render: (
value: boolean,
record: AuthtypesGettableAuthDomainDTO,
): JSX.Element => <Toggle isDefaultChecked={value} record={record} />,
},
{
title: 'IDP Initiated SSO URL',
dataIndex: 'relayState',
key: 'relayState',
width: 80,
render: (_, record: AuthtypesGettableAuthDomainDTO): JSX.Element => {
const relayPath = record.authNProviderInfo?.relayStatePath;
if (!relayPath) {
return <span className="auth-domain-list-na">N/A</span>;
}
const href = `${window.location.origin}/${relayPath}`;
return <CopyToClipboard textToCopy={href} />;
},
},
{
title: 'Action',
dataIndex: 'action',
key: 'action',
width: 100,
render: (_, record: AuthtypesGettableAuthDomainDTO): JSX.Element => (
<section className="auth-domain-list-column-action">
<Button
className="auth-domain-list-action-link"
onClick={(): void => setRecord(record)}
variant="link"
>
Configure {SSOType.get(record.ssoType || '')}
</Button>
<Button
className="auth-domain-list-action-link delete"
onClick={(): void => {
if (record.id) {
handleDeleteDomain(record.id);
}
}}
variant="link"
>
Delete
</Button>
</section>
),
},
],
[handleDeleteDomain],
);
} = useQuery({
queryFn: listAllDomain,
queryKey: ['/api/v1/domains', 'list'],
enabled: true,
});
return (
<div className="auth-domain">
<section className="auth-domain-header">
<h3 className="auth-domain-title">Authenticated Domains</h3>
<Typography.Title level={3}>Authenticated Domains</Typography.Title>
<Button
prefixIcon={<PlusOutlined />}
type="primary"
icon={<PlusOutlined />}
onClick={(): void => {
setAddDomain(true);
}}
variant="solid"
size="sm"
color="primary"
className="button"
>
Add Domain
</Button>
</section>
{formattedError && <ErrorContent error={formattedError} />}
{!errorFetchingAuthDomainListResponse && (
{(errorFetchingAuthDomainListResponse as APIError) && (
<ErrorContent error={errorFetchingAuthDomainListResponse as APIError} />
)}
{!(errorFetchingAuthDomainListResponse as APIError) && (
<Table
columns={columns}
dataSource={authDomainListResponse?.data?.data}
onRow={undefined}
dataSource={authDomainListResponse?.data}
onRow={(record): any => ({
onClick: (
event: React.SyntheticEvent<HTMLLinkElement, MouseEvent>,
): void => {
const target = event.target as HTMLLinkElement;
const { columnAction } = target.dataset;
switch (columnAction) {
case 'configure':
setRecord(record);
break;
case 'delete':
deleteDomainById(
record.id,
showErrorModal,
refetchAuthDomainListResponse,
);
break;
default:
console.error('Unknown action:', columnAction);
}
},
})}
loading={
isLoadingAuthDomainListResponse || isFetchingAuthDomainListResponse
}
className="auth-domain-list"
rowKey="id"
/>
)}
{(addDomain || record) && (

View File

@@ -1,8 +1,8 @@
import { PANEL_TYPES } from 'constants/queryBuilder';
import BarPanel from 'container/DashboardContainer/visualization/panels/BarPanel/BarPanel';
import HistogramPanel from 'container/DashboardContainer/visualization/panels/HistogramPanel/HistogramPanel';
import TimeSeriesPanel from '../DashboardContainer/visualization/panels/TimeSeriesPanel/TimeSeriesPanel';
import HistogramPanelWrapper from './HistogramPanelWrapper';
import ListPanelWrapper from './ListPanelWrapper';
import PiePanelWrapper from './PiePanelWrapper';
import TablePanelWrapper from './TablePanelWrapper';
@@ -17,7 +17,7 @@ export const PanelTypeVsPanelWrapper = {
[PANEL_TYPES.EMPTY_WIDGET]: null,
[PANEL_TYPES.PIE]: PiePanelWrapper,
[PANEL_TYPES.BAR]: BarPanel,
[PANEL_TYPES.HISTOGRAM]: HistogramPanelWrapper,
[PANEL_TYPES.HISTOGRAM]: HistogramPanel,
};
export const DEFAULT_BUCKET_COUNT = 30;

View File

@@ -0,0 +1,8 @@
import { HistogramTooltipProps } from '../types';
import Tooltip from './Tooltip';
export default function HistogramTooltip(
props: HistogramTooltipProps,
): JSX.Element {
return <Tooltip {...props} showTooltipHeader={false} />;
}

View File

@@ -16,12 +16,16 @@ export default function Tooltip({
uPlotInstance,
timezone,
content,
showTooltipHeader = true,
}: TooltipProps): JSX.Element {
const isDarkMode = useIsDarkMode();
const tooltipContent = content ?? [];
const headerTitle = useMemo(() => {
if (!showTooltipHeader) {
return null;
}
const data = uPlotInstance.data;
const cursorIdx = uPlotInstance.cursor.idx;
if (cursorIdx == null) {
@@ -30,7 +34,12 @@ export default function Tooltip({
return dayjs(data[0][cursorIdx] * 1000)
.tz(timezone)
.format(DATE_TIME_FORMATS.MONTH_DATETIME_SECONDS);
}, [timezone, uPlotInstance.data, uPlotInstance.cursor.idx]);
}, [
timezone,
uPlotInstance.data,
uPlotInstance.cursor.idx,
showTooltipHeader,
]);
return (
<div
@@ -39,9 +48,11 @@ export default function Tooltip({
isDarkMode ? 'darkMode' : 'lightMode',
)}
>
<div className="uplot-tooltip-header">
<span>{headerTitle}</span>
</div>
{showTooltipHeader && (
<div className="uplot-tooltip-header">
<span>{headerTitle}</span>
</div>
)}
<div
style={{
height: Math.min(

View File

@@ -60,6 +60,7 @@ export interface TooltipRenderArgs {
}
export interface BaseTooltipProps {
showTooltipHeader?: boolean;
timezone: string;
yAxisUnit?: string;
decimalPrecision?: PrecisionOption;
@@ -74,7 +75,14 @@ export interface BarTooltipProps extends BaseTooltipProps, TooltipRenderArgs {
isStackedBarChart?: boolean;
}
export type TooltipProps = TimeSeriesTooltipProps | BarTooltipProps;
export interface HistogramTooltipProps
extends BaseTooltipProps,
TooltipRenderArgs {}
export type TooltipProps =
| TimeSeriesTooltipProps
| BarTooltipProps
| HistogramTooltipProps;
export enum LegendPosition {
BOTTOM = 'bottom',

View File

@@ -387,7 +387,7 @@ export class UPlotConfigBuilder extends ConfigBuilder<
} = this.getVisibilityResolutionState();
config.series = [
{ value: (): string => '' }, // Base series for timestamp
{ value: (): string => '', label: 'Timestamp' }, // Base series for timestamp
...this.series.map((s) => {
const series = s.getConfig();
// Stored visibility[0] is x-axis/time; data series start at visibility[1]

View File

@@ -49,7 +49,7 @@ export class UPlotSeriesBuilder extends ConfigBuilder<SeriesProps, Series> {
}: {
resolvedLineColor: string;
}): Partial<Series> {
const { lineWidth, lineStyle, lineCap } = this.props;
const { lineWidth, lineStyle, lineCap, fillColor } = this.props;
const lineConfig: Partial<Series> = {
stroke: resolvedLineColor,
width: lineWidth ?? 2,
@@ -63,8 +63,12 @@ export class UPlotSeriesBuilder extends ConfigBuilder<SeriesProps, Series> {
lineConfig.cap = lineCap;
}
if (this.props.panelType === PANEL_TYPES.BAR) {
if (fillColor) {
lineConfig.fill = fillColor;
} else if (this.props.panelType === PANEL_TYPES.BAR) {
lineConfig.fill = resolvedLineColor;
} else if (this.props.panelType === PANEL_TYPES.HISTOGRAM) {
lineConfig.fill = `${resolvedLineColor}40`;
}
return lineConfig;
@@ -147,6 +151,8 @@ export class UPlotSeriesBuilder extends ConfigBuilder<SeriesProps, Series> {
pointsConfig.show = false;
} else if (showPoints === VisibilityMode.Always) {
pointsConfig.show = true;
} else {
pointsConfig.show = false; // default to hidden
}
return pointsConfig;

View File

@@ -175,6 +175,7 @@ export interface SeriesProps extends LineConfig, PointsConfig, BarConfig {
pointsBuilder?: Series.Points.Show;
show?: boolean;
spanGaps?: boolean;
fillColor?: string;
isDarkMode?: boolean;
stepInterval?: number;
}

View File

@@ -11,22 +11,6 @@ import { Threshold } from '../hooks/types';
import { findMinMaxThresholdValues } from './threshold';
import { LogScaleLimits, RangeFunctionParams } from './types';
/**
* Rounds a number down to the nearest multiple of incr.
* Used for linear scale min so the axis starts on a clean tick.
*/
export function incrRoundDn(num: number, incr: number): number {
return Math.floor(num / incr) * incr;
}
/**
* Rounds a number up to the nearest multiple of incr.
* Used for linear scale max so the axis ends on a clean tick.
*/
export function incrRoundUp(num: number, incr: number): number {
return Math.ceil(num / incr) * incr;
}
/**
* Snaps min/max/softMin/softMax to valid log-scale values (powers of logBase).
* Only applies when distribution is logarithmic; otherwise returns limits unchanged.
@@ -213,25 +197,6 @@ function getLogScaleRange(
);
}
/**
* Snaps linear scale min down and max up to whole numbers so axis bounds are clean.
*/
function roundLinearRange(minMax: Range.MinMax): Range.MinMax {
const [currentMin, currentMax] = minMax;
let roundedMin = currentMin;
let roundedMax = currentMax;
if (roundedMin != null) {
roundedMin = incrRoundDn(roundedMin, 1);
}
if (roundedMax != null) {
roundedMax = incrRoundUp(roundedMax, 1);
}
return [roundedMin, roundedMax];
}
/**
* Snaps log-scale [min, max] to exact powers of logBase (nearest magnitude below/above).
* If min and max would be equal after snapping, max is increased by one magnitude so the range is valid.
@@ -330,7 +295,6 @@ export function createRangeFunction(
if (scale.distr === 1) {
minMax = getLinearScaleRange(minMax, params, dataMin, dataMax);
minMax = roundLinearRange(minMax);
} else if (scale.distr === 3) {
minMax = getLogScaleRange(minMax, params, dataMin, dataMax, logBase);
const logFn = scale.log === 2 ? Math.log2 : Math.log10;

View File

@@ -3,3 +3,33 @@
* Example: 1.5 → 2, 1.49 → 1
*/
export const roundHalfUp = (value: number): number => Math.floor(value + 0.5);
/**
* Rounds a number down to the nearest multiple of incr.
* Used for linear scale min so the axis starts on a clean tick.
*/
export function incrRoundDn(num: number, incr: number): number {
return Math.floor(num / incr) * incr;
}
/**
* Rounds a number up to the nearest multiple of incr.
* Used for linear scale max so the axis ends on a clean tick.
*/
export function incrRoundUp(num: number, incr: number): number {
return Math.ceil(num / incr) * incr;
}
/**
* Rounds a number to the nearest multiple of 10^dec.
* Used for decimal precision.
*/
export function roundDecimals(val: number, dec = 0): number {
if (Number.isInteger(val)) {
return val;
}
const p = 10 ** dec;
const n = val * p * (1 + Number.EPSILON);
return Math.round(n) / p;
}

View File

@@ -4479,19 +4479,6 @@
dependencies:
"@radix-ui/react-compose-refs" "1.1.2"
"@radix-ui/react-switch@^1.1.4":
version "1.2.6"
resolved "https://registry.yarnpkg.com/@radix-ui/react-switch/-/react-switch-1.2.6.tgz#ff79acb831f0d5ea9216cfcc5b939912571358e3"
integrity sha512-bByzr1+ep1zk4VubeEVViV592vu2lHE2BZY5OnzehZqOOgogN80+mNtCqPkhn2gklJqOpxWgPoYTSnhBCqpOXQ==
dependencies:
"@radix-ui/primitive" "1.1.3"
"@radix-ui/react-compose-refs" "1.1.2"
"@radix-ui/react-context" "1.1.2"
"@radix-ui/react-primitive" "2.1.3"
"@radix-ui/react-use-controllable-state" "1.2.2"
"@radix-ui/react-use-previous" "1.1.1"
"@radix-ui/react-use-size" "1.1.1"
"@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"
@@ -5177,20 +5164,6 @@
tailwind-merge "^2.5.2"
tailwindcss-animate "^1.0.7"
"@signozhq/switch@0.0.2":
version "0.0.2"
resolved "https://registry.yarnpkg.com/@signozhq/switch/-/switch-0.0.2.tgz#58003ce9c0cd1f2ad8266a7045182607ce51df76"
integrity sha512-3B3Y5dzIyepO6EQJ7agx97bPmwg1dcOY46q2lqviHnMxNk3Sv079nSNCaztjQlo0VR0qu2JgVXhWi5Lw9WBN8A==
dependencies:
"@radix-ui/react-icons" "^1.3.0"
"@radix-ui/react-slot" "^1.1.0"
"@radix-ui/react-switch" "^1.1.4"
class-variance-authority "^0.7.0"
clsx "^2.1.1"
lucide-react "^0.445.0"
tailwind-merge "^2.5.2"
tailwindcss-animate "^1.0.7"
"@signozhq/table@0.3.7":
version "0.3.7"
resolved "https://registry.yarnpkg.com/@signozhq/table/-/table-0.3.7.tgz#895b710c02af124dfb5117e02bbc6d80ce062063"

View File

@@ -22,6 +22,7 @@ import (
"github.com/SigNoz/signoz/pkg/modules/user"
"github.com/SigNoz/signoz/pkg/types"
"github.com/SigNoz/signoz/pkg/types/ctxtypes"
"github.com/SigNoz/signoz/pkg/zeus"
"github.com/gorilla/mux"
)
@@ -44,6 +45,7 @@ type provider struct {
gatewayHandler gateway.Handler
fieldsHandler fields.Handler
authzHandler authz.Handler
zeusHandler zeus.Handler
}
func NewFactory(
@@ -63,6 +65,7 @@ func NewFactory(
gatewayHandler gateway.Handler,
fieldsHandler fields.Handler,
authzHandler authz.Handler,
zeusHandler zeus.Handler,
) factory.ProviderFactory[apiserver.APIServer, apiserver.Config] {
return factory.NewProviderFactory(factory.MustNewName("signoz"), func(ctx context.Context, providerSettings factory.ProviderSettings, config apiserver.Config) (apiserver.APIServer, error) {
return newProvider(
@@ -85,6 +88,7 @@ func NewFactory(
gatewayHandler,
fieldsHandler,
authzHandler,
zeusHandler,
)
})
}
@@ -109,6 +113,7 @@ func newProvider(
gatewayHandler gateway.Handler,
fieldsHandler fields.Handler,
authzHandler authz.Handler,
zeusHandler zeus.Handler,
) (apiserver.APIServer, error) {
settings := factory.NewScopedProviderSettings(providerSettings, "github.com/SigNoz/signoz/pkg/apiserver/signozapiserver")
router := mux.NewRouter().UseEncodedPath()
@@ -131,6 +136,7 @@ func newProvider(
gatewayHandler: gatewayHandler,
fieldsHandler: fieldsHandler,
authzHandler: authzHandler,
zeusHandler: zeusHandler,
}
provider.authZ = middleware.NewAuthZ(settings.Logger(), orgGetter, authz)
@@ -199,6 +205,10 @@ func (provider *provider) AddToRouter(router *mux.Router) error {
return err
}
if err := provider.addZeusRoutes(router); err != nil {
return err
}
return nil
}

View File

@@ -0,0 +1,48 @@
package signozapiserver
import (
"net/http"
"github.com/SigNoz/signoz/pkg/http/handler"
"github.com/SigNoz/signoz/pkg/types"
"github.com/SigNoz/signoz/pkg/types/zeustypes"
"github.com/gorilla/mux"
)
func (provider *provider) addZeusRoutes(router *mux.Router) error {
if err := router.Handle("/api/v2/zeus/deployments/profile", handler.New(provider.authZ.AdminAccess(provider.zeusHandler.PutProfile), handler.OpenAPIDef{
ID: "PutProfileInZeus",
Tags: []string{"zeus"},
Summary: "Put profile in Zeus for a deployment.",
Description: "This endpoint saves the profile of a deployment to zeus.",
Request: new(zeustypes.PostableProfile),
RequestContentType: "application/json",
Response: nil,
ResponseContentType: "",
SuccessStatusCode: http.StatusNoContent,
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusUnauthorized, http.StatusForbidden, http.StatusNotFound},
Deprecated: false,
SecuritySchemes: newSecuritySchemes(types.RoleAdmin),
})).Methods(http.MethodPut).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v2/zeus/deployments/host", handler.New(provider.authZ.AdminAccess(provider.zeusHandler.PutHost), handler.OpenAPIDef{
ID: "PutHostInZeus",
Tags: []string{"zeus"},
Summary: "Put host in Zeus for a deployment.",
Description: "This endpoint saves the host of a deployment to zeus.",
Request: new(zeustypes.PostableHost),
RequestContentType: "application/json",
Response: nil,
ResponseContentType: "",
SuccessStatusCode: http.StatusNoContent,
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusUnauthorized, http.StatusForbidden, http.StatusNotFound},
Deprecated: false,
SecuritySchemes: newSecuritySchemes(types.RoleAdmin),
})).Methods(http.MethodPut).GetError(); err != nil {
return err
}
return nil
}

View File

@@ -3,9 +3,8 @@ package flagger
import "github.com/SigNoz/signoz/pkg/types/featuretypes"
var (
FeatureUseSpanMetrics = featuretypes.MustNewName("use_span_metrics")
FeatureInterpolationEnabled = featuretypes.MustNewName("interpolation_enabled")
FeatureKafkaSpanEval = featuretypes.MustNewName("kafka_span_eval")
FeatureUseSpanMetrics = featuretypes.MustNewName("use_span_metrics")
FeatureKafkaSpanEval = featuretypes.MustNewName("kafka_span_eval")
)
func MustNewRegistry() featuretypes.Registry {
@@ -18,14 +17,6 @@ func MustNewRegistry() featuretypes.Registry {
DefaultVariant: featuretypes.MustNewName("disabled"),
Variants: featuretypes.NewBooleanVariants(),
},
&featuretypes.Feature{
Name: FeatureInterpolationEnabled,
Kind: featuretypes.KindBoolean,
Stage: featuretypes.StageExperimental,
Description: "Controls whether to enable interpolation",
DefaultVariant: featuretypes.MustNewName("disabled"),
Variants: featuretypes.NewBooleanVariants(),
},
&featuretypes.Feature{
Name: FeatureKafkaSpanEval,
Kind: featuretypes.KindBoolean,

View File

@@ -30,3 +30,7 @@ func (module *getter) ListByOwnedKeyRange(ctx context.Context) ([]*types.Organiz
return module.store.ListByKeyRange(ctx, start, end)
}
func (module *getter) GetByName(ctx context.Context, name string) (*types.Organization, error) {
return module.store.GetByName(ctx, name)
}

View File

@@ -47,6 +47,22 @@ func (store *store) Get(ctx context.Context, id valuer.UUID) (*types.Organizatio
return organization, nil
}
func (store *store) GetByName(ctx context.Context, name string) (*types.Organization, error) {
organization := new(types.Organization)
err := store.
sqlstore.
BunDB().
NewSelect().
Model(organization).
Where("name = ?", name).
Scan(ctx)
if err != nil {
return nil, store.sqlstore.WrapNotFoundErrf(err, types.ErrOrganizationNotFound, "organization with name %s does not exist", name)
}
return organization, nil
}
func (store *store) GetAll(ctx context.Context) ([]*types.Organization, error) {
organizations := make([]*types.Organization, 0)
err := store.

View File

@@ -14,6 +14,9 @@ type Getter interface {
// ListByOwnedKeyRange gets all the organizations owned by the instance
ListByOwnedKeyRange(context.Context) ([]*types.Organization, error)
// Gets the organization by name
GetByName(context.Context, string) (*types.Organization, error)
}
type Setter interface {

View File

@@ -151,6 +151,10 @@ func (module *module) CreateCallbackAuthNSession(ctx context.Context, authNProvi
return "", err
}
if err := user.ErrIfRoot(); err != nil {
return "", errors.WithAdditionalf(err, "root user can only authenticate via password")
}
token, err := module.tokenizer.CreateToken(ctx, authtypes.NewIdentity(user.ID, user.OrgID, user.Email, user.Role), map[string]string{})
if err != nil {
return "", err

View File

@@ -5,11 +5,26 @@ import (
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/factory"
"github.com/SigNoz/signoz/pkg/types"
"github.com/SigNoz/signoz/pkg/valuer"
)
type Config struct {
Password PasswordConfig `mapstructure:"password"`
Root RootConfig `mapstructure:"root"`
}
type RootConfig struct {
Enabled bool `mapstructure:"enabled"`
Email valuer.Email `mapstructure:"email"`
Password string `mapstructure:"password"`
Org OrgConfig `mapstructure:"org"`
}
type OrgConfig struct {
Name string `mapstructure:"name"`
}
type PasswordConfig struct {
Reset ResetConfig `mapstructure:"reset"`
}
@@ -31,6 +46,12 @@ func newConfig() factory.Config {
MaxTokenLifetime: 6 * time.Hour,
},
},
Root: RootConfig{
Enabled: false,
Org: OrgConfig{
Name: "default",
},
},
}
}
@@ -39,5 +60,17 @@ func (c Config) Validate() error {
return errors.New(errors.TypeInvalidInput, errors.CodeInvalidInput, "user::password::reset::max_token_lifetime must be positive")
}
if c.Root.Enabled {
if c.Root.Email.IsZero() {
return errors.New(errors.TypeInvalidInput, errors.CodeInvalidInput, "user::root::email is required when root user is enabled")
}
if c.Root.Password == "" {
return errors.New(errors.TypeInvalidInput, errors.CodeInvalidInput, "user::root::password is required when root user is enabled")
}
if !types.IsPasswordValid(c.Root.Password) {
return errors.New(errors.TypeInvalidInput, errors.CodeInvalidInput, "user::root::password does not meet password requirements")
}
}
return nil
}

View File

@@ -16,6 +16,10 @@ func NewGetter(store types.UserStore) user.Getter {
return &getter{store: store}
}
func (module *getter) GetRootUserByOrgID(ctx context.Context, orgID valuer.UUID) (*types.User, error) {
return module.store.GetRootUserByOrgID(ctx, orgID)
}
func (module *getter) ListByOrgID(ctx context.Context, orgID valuer.UUID) ([]*types.User, error) {
users, err := module.store.ListUsersByOrgID(ctx, orgID)
if err != nil {

View File

@@ -103,6 +103,12 @@ func (m *Module) CreateBulkInvite(ctx context.Context, orgID valuer.UUID, userID
return nil, err
}
if existingUser != nil {
if err := existingUser.ErrIfRoot(); err != nil {
return nil, errors.WithAdditionalf(err, "cannot send invite to root user")
}
}
if existingUser != nil {
return nil, errors.New(errors.TypeAlreadyExists, errors.CodeAlreadyExists, "User already exists with the same email")
}
@@ -202,27 +208,21 @@ func (m *Module) UpdateUser(ctx context.Context, orgID valuer.UUID, id string, u
return nil, err
}
if err := existingUser.ErrIfRoot(); err != nil {
return nil, errors.WithAdditionalf(err, "cannot update root user")
}
requestor, err := m.store.GetUser(ctx, valuer.MustNewUUID(updatedBy))
if err != nil {
return nil, err
}
// only displayName, role can be updated
if user.DisplayName == "" {
user.DisplayName = existingUser.DisplayName
}
if user.Role == "" {
user.Role = existingUser.Role
}
if user.Role != existingUser.Role && requestor.Role != types.RoleAdmin {
if user.Role != "" && user.Role != existingUser.Role && requestor.Role != types.RoleAdmin {
return nil, errors.New(errors.TypeForbidden, errors.CodeForbidden, "only admins can change roles")
}
// Make sure that th e request is not demoting the last admin user.
// also an admin user can only change role of their own or other user
if user.Role != existingUser.Role && existingUser.Role == types.RoleAdmin {
// Make sure that the request is not demoting the last admin user.
if user.Role != "" && user.Role != existingUser.Role && existingUser.Role == types.RoleAdmin {
adminUsers, err := m.store.GetUsersByRoleAndOrgID(ctx, types.RoleAdmin, orgID)
if err != nil {
return nil, err
@@ -233,7 +233,7 @@ func (m *Module) UpdateUser(ctx context.Context, orgID valuer.UUID, id string, u
}
}
if user.Role != existingUser.Role {
if user.Role != "" && user.Role != existingUser.Role {
err = m.authz.ModifyGrant(ctx,
orgID,
roletypes.MustGetSigNozManagedRoleFromExistingRole(existingUser.Role),
@@ -245,23 +245,28 @@ func (m *Module) UpdateUser(ctx context.Context, orgID valuer.UUID, id string, u
}
}
user.UpdatedAt = time.Now()
updatedUser, err := m.store.UpdateUser(ctx, orgID, id, user)
if err != nil {
existingUser.Update(user.DisplayName, user.Role)
if err := m.UpdateAnyUser(ctx, orgID, existingUser); err != nil {
return nil, err
}
traits := types.NewTraitsFromUser(updatedUser)
m.analytics.IdentifyUser(ctx, user.OrgID.String(), user.ID.String(), traits)
return existingUser, nil
}
traits["updated_by"] = updatedBy
m.analytics.TrackUser(ctx, user.OrgID.String(), user.ID.String(), "User Updated", traits)
if err := m.tokenizer.DeleteIdentity(ctx, valuer.MustNewUUID(id)); err != nil {
return nil, err
func (module *Module) UpdateAnyUser(ctx context.Context, orgID valuer.UUID, user *types.User) error {
if err := module.store.UpdateUser(ctx, orgID, user); err != nil {
return err
}
return updatedUser, nil
traits := types.NewTraitsFromUser(user)
module.analytics.IdentifyUser(ctx, user.OrgID.String(), user.ID.String(), traits)
module.analytics.TrackUser(ctx, user.OrgID.String(), user.ID.String(), "User Updated", traits)
if err := module.tokenizer.DeleteIdentity(ctx, user.ID); err != nil {
return err
}
return nil
}
func (module *Module) DeleteUser(ctx context.Context, orgID valuer.UUID, id string, deletedBy string) error {
@@ -270,6 +275,10 @@ func (module *Module) DeleteUser(ctx context.Context, orgID valuer.UUID, id stri
return err
}
if err := user.ErrIfRoot(); err != nil {
return errors.WithAdditionalf(err, "cannot delete root user")
}
if slices.Contains(types.AllIntegrationUserEmails, types.IntegrationUserEmail(user.Email.String())) {
return errors.New(errors.TypeForbidden, errors.CodeForbidden, "integration user cannot be deleted")
}
@@ -302,6 +311,15 @@ func (module *Module) DeleteUser(ctx context.Context, orgID valuer.UUID, id stri
}
func (module *Module) GetOrCreateResetPasswordToken(ctx context.Context, userID valuer.UUID) (*types.ResetPasswordToken, error) {
user, err := module.store.GetUser(ctx, userID)
if err != nil {
return nil, err
}
if err := user.ErrIfRoot(); err != nil {
return nil, errors.WithAdditionalf(err, "cannot reset password for root user")
}
password, err := module.store.GetPasswordByUserID(ctx, userID)
if err != nil {
if !errors.Ast(err, errors.TypeNotFound) {
@@ -364,6 +382,10 @@ func (module *Module) ForgotPassword(ctx context.Context, orgID valuer.UUID, ema
return err
}
if err := user.ErrIfRoot(); err != nil {
return errors.WithAdditionalf(err, "cannot reset password for root user")
}
token, err := module.GetOrCreateResetPasswordToken(ctx, user.ID)
if err != nil {
module.settings.Logger().ErrorContext(ctx, "failed to create reset password token", "error", err)
@@ -407,6 +429,15 @@ func (module *Module) UpdatePasswordByResetPasswordToken(ctx context.Context, to
return err
}
user, err := module.store.GetUser(ctx, valuer.MustNewUUID(password.UserID))
if err != nil {
return err
}
if err := user.ErrIfRoot(); err != nil {
return errors.WithAdditionalf(err, "cannot reset password for root user")
}
if err := password.Update(passwd); err != nil {
return err
}
@@ -415,6 +446,15 @@ func (module *Module) UpdatePasswordByResetPasswordToken(ctx context.Context, to
}
func (module *Module) UpdatePassword(ctx context.Context, userID valuer.UUID, oldpasswd string, passwd string) error {
user, err := module.store.GetUser(ctx, userID)
if err != nil {
return err
}
if err := user.ErrIfRoot(); err != nil {
return errors.WithAdditionalf(err, "cannot change password for root user")
}
password, err := module.store.GetPasswordByUserID(ctx, userID)
if err != nil {
return err
@@ -476,7 +516,7 @@ func (m *Module) RevokeAPIKey(ctx context.Context, id, removedByUserID valuer.UU
}
func (module *Module) CreateFirstUser(ctx context.Context, organization *types.Organization, name string, email valuer.Email, passwd string) (*types.User, error) {
user, err := types.NewUser(name, email, types.RoleAdmin, organization.ID)
user, err := types.NewRootUser(name, email, organization.ID)
if err != nil {
return nil, err
}

View File

@@ -0,0 +1,187 @@
package impluser
import (
"context"
"time"
"github.com/SigNoz/signoz/pkg/authz"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/factory"
"github.com/SigNoz/signoz/pkg/modules/organization"
"github.com/SigNoz/signoz/pkg/modules/user"
"github.com/SigNoz/signoz/pkg/types"
"github.com/SigNoz/signoz/pkg/types/authtypes"
"github.com/SigNoz/signoz/pkg/types/roletypes"
"github.com/SigNoz/signoz/pkg/valuer"
)
type service struct {
settings factory.ScopedProviderSettings
store types.UserStore
module user.Module
orgGetter organization.Getter
authz authz.AuthZ
config user.RootConfig
stopC chan struct{}
}
func NewService(
providerSettings factory.ProviderSettings,
store types.UserStore,
module user.Module,
orgGetter organization.Getter,
authz authz.AuthZ,
config user.RootConfig,
) user.Service {
return &service{
settings: factory.NewScopedProviderSettings(providerSettings, "go.signoz.io/pkg/modules/user"),
store: store,
module: module,
orgGetter: orgGetter,
authz: authz,
config: config,
stopC: make(chan struct{}),
}
}
func (s *service) Start(ctx context.Context) error {
if !s.config.Enabled {
<-s.stopC
return nil
}
ticker := time.NewTicker(10 * time.Second)
defer ticker.Stop()
for {
err := s.reconcile(ctx)
if err == nil {
s.settings.Logger().InfoContext(ctx, "root user reconciliation completed successfully")
<-s.stopC
return nil
}
s.settings.Logger().WarnContext(ctx, "root user reconciliation failed, retrying", "error", err)
select {
case <-s.stopC:
return nil
case <-ticker.C:
continue
}
}
}
func (s *service) Stop(ctx context.Context) error {
close(s.stopC)
return nil
}
func (s *service) reconcile(ctx context.Context) error {
org, err := s.orgGetter.GetByName(ctx, s.config.Org.Name)
if err != nil {
if errors.Ast(err, errors.TypeNotFound) {
newOrg := types.NewOrganization(s.config.Org.Name, s.config.Org.Name)
_, err := s.module.CreateFirstUser(ctx, newOrg, s.config.Email.String(), s.config.Email, s.config.Password)
return err
}
return err
}
return s.reconcileRootUser(ctx, org.ID)
}
func (s *service) reconcileRootUser(ctx context.Context, orgID valuer.UUID) error {
existingRoot, err := s.store.GetRootUserByOrgID(ctx, orgID)
if err != nil && !errors.Ast(err, errors.TypeNotFound) {
return err
}
if existingRoot == nil {
return s.createOrPromoteRootUser(ctx, orgID)
}
return s.updateExistingRootUser(ctx, orgID, existingRoot)
}
func (s *service) createOrPromoteRootUser(ctx context.Context, orgID valuer.UUID) error {
existingUser, err := s.store.GetUserByEmailAndOrgID(ctx, s.config.Email, orgID)
if err != nil && !errors.Ast(err, errors.TypeNotFound) {
return err
}
if existingUser != nil {
oldRole := existingUser.Role
existingUser.PromoteToRoot()
if err := s.module.UpdateAnyUser(ctx, orgID, existingUser); err != nil {
return err
}
if oldRole != types.RoleAdmin {
if err := s.authz.ModifyGrant(ctx,
orgID,
roletypes.MustGetSigNozManagedRoleFromExistingRole(oldRole),
roletypes.MustGetSigNozManagedRoleFromExistingRole(types.RoleAdmin),
authtypes.MustNewSubject(authtypes.TypeableUser, existingUser.ID.StringValue(), orgID, nil),
); err != nil {
return err
}
}
return s.setPassword(ctx, existingUser.ID)
}
// Create new root user
newUser, err := types.NewRootUser(s.config.Email.String(), s.config.Email, orgID)
if err != nil {
return err
}
factorPassword, err := types.NewFactorPassword(s.config.Password, newUser.ID.StringValue())
if err != nil {
return err
}
return s.module.CreateUser(ctx, newUser, user.WithFactorPassword(factorPassword))
}
func (s *service) updateExistingRootUser(ctx context.Context, orgID valuer.UUID, existingRoot *types.User) error {
existingRoot.PromoteToRoot()
if existingRoot.Email != s.config.Email {
existingRoot.UpdateEmail(s.config.Email)
if err := s.module.UpdateAnyUser(ctx, orgID, existingRoot); err != nil {
return err
}
}
return s.setPassword(ctx, existingRoot.ID)
}
func (s *service) setPassword(ctx context.Context, userID valuer.UUID) error {
password, err := s.store.GetPasswordByUserID(ctx, userID)
if err != nil {
if !errors.Ast(err, errors.TypeNotFound) {
return err
}
factorPassword, err := types.NewFactorPassword(s.config.Password, userID.StringValue())
if err != nil {
return err
}
return s.store.CreatePassword(ctx, factorPassword)
}
if !password.Equals(s.config.Password) {
if err := password.Update(s.config.Password); err != nil {
return err
}
return s.store.UpdatePassword(ctx, password)
}
return nil
}

View File

@@ -210,20 +210,24 @@ func (store *store) GetUsersByRoleAndOrgID(ctx context.Context, role types.Role,
return users, nil
}
func (store *store) UpdateUser(ctx context.Context, orgID valuer.UUID, id string, user *types.User) (*types.User, error) {
user.UpdatedAt = time.Now()
_, err := store.sqlstore.BunDB().NewUpdate().
func (store *store) UpdateUser(ctx context.Context, orgID valuer.UUID, user *types.User) error {
_, err := store.
sqlstore.
BunDBCtx(ctx).
NewUpdate().
Model(user).
Column("display_name").
Column("email").
Column("role").
Column("is_root").
Column("updated_at").
Where("id = ?", id).
Where("org_id = ?", orgID).
Where("id = ?", user.ID).
Exec(ctx)
if err != nil {
return nil, store.sqlstore.WrapNotFoundErrf(err, types.ErrCodeUserNotFound, "user with id: %s does not exist in org: %s", id, orgID)
return store.sqlstore.WrapNotFoundErrf(err, types.ErrCodeUserNotFound, "user does not exist in org: %s", orgID)
}
return user, nil
return nil
}
func (store *store) ListUsersByOrgID(ctx context.Context, orgID valuer.UUID) ([]*types.GettableUser, error) {
@@ -602,6 +606,22 @@ func (store *store) RunInTx(ctx context.Context, cb func(ctx context.Context) er
})
}
func (store *store) GetRootUserByOrgID(ctx context.Context, orgID valuer.UUID) (*types.User, error) {
user := new(types.User)
err := store.
sqlstore.
BunDBCtx(ctx).
NewSelect().
Model(user).
Where("org_id = ?", orgID).
Where("is_root = ?", true).
Scan(ctx)
if err != nil {
return nil, store.sqlstore.WrapNotFoundErrf(err, types.ErrCodeUserNotFound, "root user for org %s not found", orgID)
}
return user, nil
}
func (store *store) ListUsersByEmailAndOrgIDs(ctx context.Context, email valuer.Email, orgIDs []valuer.UUID) ([]*types.User, error) {
users := []*types.User{}
err := store.

View File

@@ -0,0 +1,7 @@
package user
import "github.com/SigNoz/signoz/pkg/factory"
type Service interface {
factory.Service
}

View File

@@ -34,6 +34,9 @@ type Module interface {
ForgotPassword(ctx context.Context, orgID valuer.UUID, email valuer.Email, frontendBaseURL string) error
UpdateUser(ctx context.Context, orgID valuer.UUID, id string, user *types.User, updatedBy string) (*types.User, error)
// UpdateAnyUser updates a user and persists the changes to the database along with the analytics and identity deletion.
UpdateAnyUser(ctx context.Context, orgID valuer.UUID, user *types.User) error
DeleteUser(ctx context.Context, orgID valuer.UUID, id string, deletedBy string) error
// invite
@@ -54,6 +57,9 @@ type Module interface {
}
type Getter interface {
// Get root user by org id.
GetRootUserByOrgID(context.Context, valuer.UUID) (*types.User, error)
// Get gets the users based on the given id
ListByOrgID(context.Context, valuer.UUID) ([]*types.User, error)

View File

@@ -183,7 +183,7 @@ type APIHandlerOpts struct {
}
// NewAPIHandler returns an APIHandler
func NewAPIHandler(opts APIHandlerOpts) (*APIHandler, error) {
func NewAPIHandler(opts APIHandlerOpts, config signoz.Config) (*APIHandler, error) {
querierOpts := querier.QuerierOptions{
Reader: opts.Reader,
Cache: opts.Signoz.Cache,
@@ -270,6 +270,11 @@ func NewAPIHandler(opts APIHandlerOpts) (*APIHandler, error) {
}
}
// If the root user is enabled, the setup is complete
if config.User.Root.Enabled {
aH.SetupCompleted = true
}
aH.Upgrader = &websocket.Upgrader{
CheckOrigin: func(r *http.Request) bool {
return true
@@ -2045,7 +2050,7 @@ func (aH *APIHandler) registerUser(w http.ResponseWriter, r *http.Request) {
return
}
organization := types.NewOrganization(req.OrgDisplayName)
organization := types.NewOrganization(req.OrgDisplayName, req.OrgName)
user, errv2 := aH.Signoz.Modules.User.CreateFirstUser(r.Context(), organization, req.Name, req.Email, req.Password)
if errv2 != nil {
render.Error(w, errv2)

View File

@@ -135,7 +135,7 @@ func NewServer(config signoz.Config, signoz *signoz.SigNoz) (*Server, error) {
Signoz: signoz,
QuerierAPI: querierAPI.NewAPI(signoz.Instrumentation.ToProviderSettings(), signoz.Querier, signoz.Analytics),
QueryParserAPI: queryparser.NewAPI(signoz.Instrumentation.ToProviderSettings(), signoz.QueryParser),
})
}, config)
if err != nil {
return nil, err
}

View File

@@ -43,11 +43,11 @@ var (
// FromUnit returns a converter for the given unit
func FromUnit(u Unit) Converter {
switch u {
case "ns", "us", "µs", "ms", "s", "m", "h", "d", "min":
case "ns", "us", "µs", "ms", "s", "m", "h", "d", "min", "w", "wk":
return DurationConverter
case "bytes", "decbytes", "bits", "decbits", "kbytes", "decKbytes", "deckbytes", "mbytes", "decMbytes", "decmbytes", "gbytes", "decGbytes", "decgbytes", "tbytes", "decTbytes", "dectbytes", "pbytes", "decPbytes", "decpbytes", "By", "kBy", "MBy", "GBy", "TBy", "PBy":
case "bytes", "decbytes", "bits", "bit", "decbits", "kbytes", "decKbytes", "deckbytes", "mbytes", "decMbytes", "decmbytes", "gbytes", "decGbytes", "decgbytes", "tbytes", "decTbytes", "dectbytes", "pbytes", "decPbytes", "decpbytes", "By", "kBy", "MBy", "GBy", "TBy", "PBy", "EBy", "ZBy", "YBy", "KiBy", "MiBy", "GiBy", "TiBy", "PiBy", "EiBy", "ZiBy", "YiBy", "kbit", "Mbit", "Gbit", "Tbit", "Pbit", "Ebit", "Zbit", "Ybit", "Kibit", "Mibit", "Gibit", "Tibit", "Pibit":
return DataConverter
case "binBps", "Bps", "binbps", "bps", "KiBs", "Kibits", "KBs", "Kbits", "MiBs", "Mibits", "MBs", "Mbits", "GiBs", "Gibits", "GBs", "Gbits", "TiBs", "Tibits", "TBs", "Tbits", "PiBs", "Pibits", "PBs", "Pbits", "By/s", "kBy/s", "MBy/s", "GBy/s", "TBy/s", "PBy/s", "bit/s", "kbit/s", "Mbit/s", "Gbit/s", "Tbit/s", "Pbit/s":
case "binBps", "Bps", "binbps", "bps", "KiBs", "Kibits", "KBs", "Kbits", "MiBs", "Mibits", "MBs", "Mbits", "GiBs", "Gibits", "GBs", "Gbits", "TiBs", "Tibits", "TBs", "Tbits", "PiBs", "Pibits", "PBs", "Pbits", "By/s", "kBy/s", "MBy/s", "GBy/s", "TBy/s", "PBy/s", "EBy/s", "ZBy/s", "YBy/s", "bit/s", "kbit/s", "Mbit/s", "Gbit/s", "Tbit/s", "Pbit/s", "Ebit/s", "Zbit/s", "Ybit/s", "KiBy/s", "MiBy/s", "GiBy/s", "TiBy/s", "PiBy/s", "EiBy/s", "ZiBy/s", "YiBy/s", "Kibit/s", "Mibit/s", "Gibit/s", "Tibit/s", "Pibit/s", "Eibit/s", "Zibit/s", "Yibit/s":
return DataRateConverter
case "percent", "percentunit", "%":
return PercentConverter

View File

@@ -58,36 +58,80 @@ func (*dataConverter) Name() string {
return "data"
}
// Notation followed by UCUM:
// https://ucum.org/ucum
// kibi = Ki, mebi = Mi, gibi = Gi, tebi = Ti, pibi = Pi
// kilo = k, mega = M, giga = G, tera = T, peta = P
// exa = E, zetta = Z, yotta = Y
// byte = By, bit = bit
func FromDataUnit(u Unit) float64 {
switch u {
case "bytes", "By": // base 2
return Byte
case "decbytes": // base 10
return Byte
case "bits": // base 2
case "bits", "bit": // base 2
return Bit
case "decbits": // base 10
return Bit
case "kbytes", "kBy": // base 2
case "kbytes", "KiBy": // base 2
return Kibibyte
case "decKbytes", "deckbytes": // base 10
case "decKbytes", "deckbytes", "kBy": // base 10
return Kilobyte
case "mbytes", "MBy": // base 2
case "mbytes", "MiBy": // base 2
return Mebibyte
case "decMbytes", "decmbytes": // base 10
case "decMbytes", "decmbytes", "MBy": // base 10
return Megabyte
case "gbytes", "GBy": // base 2
case "gbytes", "GiBy": // base 2
return Gibibyte
case "decGbytes", "decgbytes": // base 10
case "decGbytes", "decgbytes", "GBy": // base 10
return Gigabyte
case "tbytes", "TBy": // base 2
case "tbytes", "TiBy": // base 2
return Tebibyte
case "decTbytes", "dectbytes": // base 10
case "decTbytes", "dectbytes", "TBy": // base 10
return Terabyte
case "pbytes", "PBy": // base 2
case "pbytes", "PiBy": // base 2
return Pebibyte
case "decPbytes", "decpbytes": // base 10
case "decPbytes", "decpbytes", "PBy": // base 10
return Petabyte
case "EBy": // base 10
return Exabyte
case "ZBy": // base 10
return Zettabyte
case "YBy": // base 10
return Yottabyte
case "Kibit": // base 2
return Kibibit
case "Mibit": // base 2
return Mebibit
case "Gibit": // base 2
return Gibibit
case "Tibit": // base 2
return Tebibit
case "Pibit": // base 2
return Pebibit
case "EiBy": // base 2
return Exbibyte
case "ZiBy": // base 2
return Zebibyte
case "YiBy": // base 2
return Yobibyte
case "kbit": // base 10
return Kilobit
case "Mbit": // base 10
return Megabit
case "Gbit": // base 10
return Gigabit
case "Tbit": // base 10
return Terabit
case "Pbit": // base 10
return Petabit
case "Ebit": // base 10
return Exabit
case "Zbit": // base 10
return Zettabit
case "Ybit": // base 10
return Yottabit
default:
return 1
}

View File

@@ -54,6 +54,12 @@ func (*dataRateConverter) Name() string {
return "data_rate"
}
// Notation followed by UCUM:
// https://ucum.org/ucum
// kibi = Ki, mebi = Mi, gibi = Gi, tebi = Ti, pibi = Pi
// kilo = k, mega = M, giga = G, tera = T, peta = P
// exa = E, zetta = Z, yotta = Y
// byte = By, bit = bit
func FromDataRateUnit(u Unit) float64 {
// See https://github.com/SigNoz/signoz/blob/5a81f5f90b34845f5b4b3bdd46acf29d04bf3987/frontend/src/container/NewWidget/RightContainer/dataFormatCategories.ts#L62-L85
switch u {
@@ -65,46 +71,70 @@ func FromDataRateUnit(u Unit) float64 {
return BitPerSecond
case "bps", "bit/s": // bits/sec(SI)
return BitPerSecond
case "KiBs": // kibibytes/sec
case "KiBs", "KiBy/s": // kibibytes/sec
return KibibytePerSecond
case "Kibits": // kibibits/sec
case "Kibits", "Kibit/s": // kibibits/sec
return KibibitPerSecond
case "KBs", "kBy/s": // kilobytes/sec
return KilobytePerSecond
case "Kbits", "kbit/s": // kilobits/sec
return KilobitPerSecond
case "MiBs": // mebibytes/sec
case "MiBs", "MiBy/s": // mebibytes/sec
return MebibytePerSecond
case "Mibits": // mebibits/sec
case "Mibits", "Mibit/s": // mebibits/sec
return MebibitPerSecond
case "MBs", "MBy/s": // megabytes/sec
return MegabytePerSecond
case "Mbits", "Mbit/s": // megabits/sec
return MegabitPerSecond
case "GiBs": // gibibytes/sec
case "GiBs", "GiBy/s": // gibibytes/sec
return GibibytePerSecond
case "Gibits": // gibibits/sec
case "Gibits", "Gibit/s": // gibibits/sec
return GibibitPerSecond
case "GBs", "GBy/s": // gigabytes/sec
return GigabytePerSecond
case "Gbits", "Gbit/s": // gigabits/sec
return GigabitPerSecond
case "TiBs": // tebibytes/sec
case "TiBs", "TiBy/s": // tebibytes/sec
return TebibytePerSecond
case "Tibits": // tebibits/sec
case "Tibits", "Tibit/s": // tebibits/sec
return TebibitPerSecond
case "TBs", "TBy/s": // terabytes/sec
return TerabytePerSecond
case "Tbits", "Tbit/s": // terabits/sec
return TerabitPerSecond
case "PiBs": // pebibytes/sec
case "PiBs", "PiBy/s": // pebibytes/sec
return PebibytePerSecond
case "Pibits": // pebibits/sec
case "Pibits", "Pibit/s": // pebibits/sec
return PebibitPerSecond
case "PBs", "PBy/s": // petabytes/sec
return PetabytePerSecond
case "Pbits", "Pbit/s": // petabits/sec
return PetabitPerSecond
case "EBy/s": // exabytes/sec
return ExabytePerSecond
case "Ebit/s": // exabits/sec
return ExabitPerSecond
case "EiBy/s": // exbibytes/sec
return ExbibytePerSecond
case "Eibit/s": // exbibits/sec
return ExbibitPerSecond
case "ZBy/s": // zettabytes/sec
return ZettabytePerSecond
case "Zbit/s": // zettabits/sec
return ZettabitPerSecond
case "ZiBy/s": // zebibytes/sec
return ZebibytePerSecond
case "Zibit/s": // zebibits/sec
return ZebibitPerSecond
case "YBy/s": // yottabytes/sec
return YottabytePerSecond
case "Ybit/s": // yottabits/sec
return YottabitPerSecond
case "YiBy/s": // yobibytes/sec
return YobibytePerSecond
case "Yibit/s": // yobibits/sec
return YobibitPerSecond
default:
return 1
}

View File

@@ -75,3 +75,83 @@ func TestDataRate(t *testing.T) {
// 1024 * 1024 * 1024 bytes = 1 gbytes
assert.Equal(t, Value{F: 1, U: "GiBs"}, dataRateConverter.Convert(Value{F: 1024 * 1024 * 1024, U: "binBps"}, "GiBs"))
}
func TestDataRateConversionUCUMUnit(t *testing.T) {
dataRateConverter := NewDataRateConverter()
tests := []struct {
name string
input Value
toUnit Unit
expected Value
}{
// Binary byte scaling
{name: "Binary byte scaling: 1024 By/s = 1 KiBy/s", input: Value{F: 1024, U: "By/s"}, toUnit: "KiBy/s", expected: Value{F: 1, U: "KiBy/s"}},
{name: "Kibibyte to bytes: 1 KiBy/s = 1024 By/s", input: Value{F: 1, U: "KiBy/s"}, toUnit: "By/s", expected: Value{F: 1024, U: "By/s"}},
{name: "Binary byte scaling: 1024 KiBy/s = 1 MiBy/s", input: Value{F: 1024, U: "KiBy/s"}, toUnit: "MiBy/s", expected: Value{F: 1, U: "MiBy/s"}},
{name: "Gibibyte to bytes: 1 GiBy/s = 1073741824 By/s", input: Value{F: 1, U: "GiBy/s"}, toUnit: "By/s", expected: Value{F: 1024 * 1024 * 1024, U: "By/s"}},
{name: "Binary byte scaling: 1024 MiBy/s = 1 GiBy/s", input: Value{F: 1024, U: "MiBy/s"}, toUnit: "GiBy/s", expected: Value{F: 1, U: "GiBy/s"}},
{name: "Gibibyte to mebibyte: 1 GiBy/s = 1024 MiBy/s", input: Value{F: 1, U: "GiBy/s"}, toUnit: "MiBy/s", expected: Value{F: 1024, U: "MiBy/s"}},
{name: "Binary byte scaling: 1024 GiBy/s = 1 TiBy/s", input: Value{F: 1024, U: "GiBy/s"}, toUnit: "TiBy/s", expected: Value{F: 1, U: "TiBy/s"}},
{name: "Tebibyte to bytes: 1 TiBy/s = 1099511627776 By/s", input: Value{F: 1, U: "TiBy/s"}, toUnit: "By/s", expected: Value{F: 1024 * 1024 * 1024 * 1024, U: "By/s"}},
{name: "Binary byte scaling: 1024 TiBy/s = 1 PiBy/s", input: Value{F: 1024, U: "TiBy/s"}, toUnit: "PiBy/s", expected: Value{F: 1, U: "PiBy/s"}},
{name: "Pebibyte to tebibyte: 1 PiBy/s = 1024 TiBy/s", input: Value{F: 1, U: "PiBy/s"}, toUnit: "TiBy/s", expected: Value{F: 1024, U: "TiBy/s"}},
// Binary bit scaling
{name: "Binary bit scaling: 1024 bit/s = 1 Kibit/s", input: Value{F: 1024, U: "bit/s"}, toUnit: "Kibit/s", expected: Value{F: 1, U: "Kibit/s"}},
{name: "Kibibit to bits: 1 Kibit/s = 1024 bit/s", input: Value{F: 1, U: "Kibit/s"}, toUnit: "bit/s", expected: Value{F: 1024, U: "bit/s"}},
{name: "Binary bit scaling: 1024 Kibit/s = 1 Mibit/s", input: Value{F: 1024, U: "Kibit/s"}, toUnit: "Mibit/s", expected: Value{F: 1, U: "Mibit/s"}},
{name: "Gibibit to bits: 1 Gibit/s = 1073741824 bit/s", input: Value{F: 1, U: "Gibit/s"}, toUnit: "bit/s", expected: Value{F: 1024 * 1024 * 1024, U: "bit/s"}},
{name: "Binary bit scaling: 1024 Mibit/s = 1 Gibit/s", input: Value{F: 1024, U: "Mibit/s"}, toUnit: "Gibit/s", expected: Value{F: 1, U: "Gibit/s"}},
{name: "Gibibit to mebibit: 1 Gibit/s = 1024 Mibit/s", input: Value{F: 1, U: "Gibit/s"}, toUnit: "Mibit/s", expected: Value{F: 1024, U: "Mibit/s"}},
{name: "Binary bit scaling: 1024 Gibit/s = 1 Tibit/s", input: Value{F: 1024, U: "Gibit/s"}, toUnit: "Tibit/s", expected: Value{F: 1, U: "Tibit/s"}},
{name: "Tebibit to gibibit: 1 Tibit/s = 1024 Gibit/s", input: Value{F: 1, U: "Tibit/s"}, toUnit: "Gibit/s", expected: Value{F: 1024, U: "Gibit/s"}},
{name: "Binary bit scaling: 1024 Tibit/s = 1 Pibit/s", input: Value{F: 1024, U: "Tibit/s"}, toUnit: "Pibit/s", expected: Value{F: 1, U: "Pibit/s"}},
{name: "Pebibit to tebibit: 1 Pibit/s = 1024 Tibit/s", input: Value{F: 1, U: "Pibit/s"}, toUnit: "Tibit/s", expected: Value{F: 1024, U: "Tibit/s"}},
// Bytes to bits
{name: "Bytes to bits: 1 KiBy/s = 8 Kibit/s", input: Value{F: 1, U: "KiBy/s"}, toUnit: "Kibit/s", expected: Value{F: 8, U: "Kibit/s"}},
{name: "Bytes to bits: 1 MiBy/s = 8 Mibit/s", input: Value{F: 1, U: "MiBy/s"}, toUnit: "Mibit/s", expected: Value{F: 8, U: "Mibit/s"}},
{name: "Bytes to bits: 1 GiBy/s = 8 Gibit/s", input: Value{F: 1, U: "GiBy/s"}, toUnit: "Gibit/s", expected: Value{F: 8, U: "Gibit/s"}},
// Unit alias
{name: "Unit alias: 1 KiBs = 1 KiBy/s", input: Value{F: 1, U: "KiBs"}, toUnit: "KiBy/s", expected: Value{F: 1, U: "KiBy/s"}},
{name: "Unit alias: 1 Kibits = 1 Kibit/s", input: Value{F: 1, U: "Kibits"}, toUnit: "Kibit/s", expected: Value{F: 1, U: "Kibit/s"}},
// SI byte scaling (Exa, Zetta, Yotta)
{name: "SI byte scaling: 1000 PBy/s = 1 EBy/s", input: Value{F: 1000, U: "PBy/s"}, toUnit: "EBy/s", expected: Value{F: 1, U: "EBy/s"}},
{name: "Exabyte to bytes: 1 EBy/s = 1e18 By/s", input: Value{F: 1, U: "EBy/s"}, toUnit: "By/s", expected: Value{F: 1e18, U: "By/s"}},
{name: "SI byte scaling: 1000 EBy/s = 1 ZBy/s", input: Value{F: 1000, U: "EBy/s"}, toUnit: "ZBy/s", expected: Value{F: 1, U: "ZBy/s"}},
{name: "Zettabyte to petabytes: 1 ZBy/s = 1000000 PBy/s", input: Value{F: 1, U: "ZBy/s"}, toUnit: "PBy/s", expected: Value{F: 1e6, U: "PBy/s"}},
{name: "SI byte scaling: 1000 ZBy/s = 1 YBy/s", input: Value{F: 1000, U: "ZBy/s"}, toUnit: "YBy/s", expected: Value{F: 1, U: "YBy/s"}},
{name: "Yottabyte to zettabyte: 1 YBy/s = 1000 ZBy/s", input: Value{F: 1, U: "YBy/s"}, toUnit: "ZBy/s", expected: Value{F: 1000, U: "ZBy/s"}},
// Binary byte scaling (Exbi, Zebi, Yobi)
{name: "Binary byte scaling: 1024 PiBy/s = 1 EiBy/s", input: Value{F: 1024, U: "PiBy/s"}, toUnit: "EiBy/s", expected: Value{F: 1, U: "EiBy/s"}},
{name: "Exbibyte to tebibytes: 1 EiBy/s = 1048576 TiBy/s", input: Value{F: 1, U: "EiBy/s"}, toUnit: "TiBy/s", expected: Value{F: 1024 * 1024, U: "TiBy/s"}},
{name: "Binary byte scaling: 1024 EiBy/s = 1 ZiBy/s", input: Value{F: 1024, U: "EiBy/s"}, toUnit: "ZiBy/s", expected: Value{F: 1, U: "ZiBy/s"}},
{name: "Zebibyte to exbibyte: 1 ZiBy/s = 1024 EiBy/s", input: Value{F: 1, U: "ZiBy/s"}, toUnit: "EiBy/s", expected: Value{F: 1024, U: "EiBy/s"}},
{name: "Binary byte scaling: 1024 ZiBy/s = 1 YiBy/s", input: Value{F: 1024, U: "ZiBy/s"}, toUnit: "YiBy/s", expected: Value{F: 1, U: "YiBy/s"}},
{name: "Yobibyte to zebibyte: 1 YiBy/s = 1024 ZiBy/s", input: Value{F: 1, U: "YiBy/s"}, toUnit: "ZiBy/s", expected: Value{F: 1024, U: "ZiBy/s"}},
// SI bit scaling (Exa, Zetta, Yotta)
{name: "SI bit scaling: 1000 Pbit/s = 1 Ebit/s", input: Value{F: 1000, U: "Pbit/s"}, toUnit: "Ebit/s", expected: Value{F: 1, U: "Ebit/s"}},
{name: "Exabit to gigabits: 1 Ebit/s = 1e9 Gbit/s", input: Value{F: 1, U: "Ebit/s"}, toUnit: "Gbit/s", expected: Value{F: 1e9, U: "Gbit/s"}},
{name: "SI bit scaling: 1000 Ebit/s = 1 Zbit/s", input: Value{F: 1000, U: "Ebit/s"}, toUnit: "Zbit/s", expected: Value{F: 1, U: "Zbit/s"}},
{name: "Zettabit to exabit: 1 Zbit/s = 1000 Ebit/s", input: Value{F: 1, U: "Zbit/s"}, toUnit: "Ebit/s", expected: Value{F: 1000, U: "Ebit/s"}},
{name: "SI bit scaling: 1000 Zbit/s = 1 Ybit/s", input: Value{F: 1000, U: "Zbit/s"}, toUnit: "Ybit/s", expected: Value{F: 1, U: "Ybit/s"}},
{name: "Yottabit to zettabit: 1 Ybit/s = 1000 Zbit/s", input: Value{F: 1, U: "Ybit/s"}, toUnit: "Zbit/s", expected: Value{F: 1000, U: "Zbit/s"}},
// Binary bit scaling (Exbi, Zebi, Yobi)
{name: "Binary bit scaling: 1024 Pibit/s = 1 Eibit/s", input: Value{F: 1024, U: "Pibit/s"}, toUnit: "Eibit/s", expected: Value{F: 1, U: "Eibit/s"}},
{name: "Exbibit to pebibit: 1 Eibit/s = 1024 Pibit/s", input: Value{F: 1, U: "Eibit/s"}, toUnit: "Pibit/s", expected: Value{F: 1024, U: "Pibit/s"}},
{name: "Binary bit scaling: 1024 Eibit/s = 1 Zibit/s", input: Value{F: 1024, U: "Eibit/s"}, toUnit: "Zibit/s", expected: Value{F: 1, U: "Zibit/s"}},
{name: "Zebibit to exbibit: 1 Zibit/s = 1024 Eibit/s", input: Value{F: 1, U: "Zibit/s"}, toUnit: "Eibit/s", expected: Value{F: 1024, U: "Eibit/s"}},
{name: "Binary bit scaling: 1024 Zibit/s = 1 Yibit/s", input: Value{F: 1024, U: "Zibit/s"}, toUnit: "Yibit/s", expected: Value{F: 1, U: "Yibit/s"}},
{name: "Yobibit to zebibit: 1 Yibit/s = 1024 Zibit/s", input: Value{F: 1, U: "Yibit/s"}, toUnit: "Zibit/s", expected: Value{F: 1024, U: "Zibit/s"}},
// Bytes to bits (Exbi, Zebi, Yobi)
{name: "Bytes to bits: 1 EiBy/s = 8 Eibit/s", input: Value{F: 1, U: "EiBy/s"}, toUnit: "Eibit/s", expected: Value{F: 8, U: "Eibit/s"}},
{name: "Bytes to bits: 1 ZiBy/s = 8 Zibit/s", input: Value{F: 1, U: "ZiBy/s"}, toUnit: "Zibit/s", expected: Value{F: 8, U: "Zibit/s"}},
{name: "Bytes to bits: 1 YiBy/s = 8 Yibit/s", input: Value{F: 1, U: "YiBy/s"}, toUnit: "Yibit/s", expected: Value{F: 8, U: "Yibit/s"}},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := dataRateConverter.Convert(tt.input, tt.toUnit)
assert.Equal(t, tt.expected, got)
})
}
}

View File

@@ -13,7 +13,7 @@ func TestData(t *testing.T) {
assert.Equal(t, Value{F: 1, U: "By"}, dataConverter.Convert(Value{F: 8, U: "bits"}, "By"))
// 1024 bytes = 1 kbytes
assert.Equal(t, Value{F: 1, U: "kbytes"}, dataConverter.Convert(Value{F: 1024, U: "bytes"}, "kbytes"))
assert.Equal(t, Value{F: 1, U: "kBy"}, dataConverter.Convert(Value{F: 1024, U: "bytes"}, "kBy"))
assert.Equal(t, Value{F: 1, U: "kBy"}, dataConverter.Convert(Value{F: 1000, U: "bytes"}, "kBy"))
// 1 byte = 8 bits
assert.Equal(t, Value{F: 8, U: "bits"}, dataConverter.Convert(Value{F: 1, U: "bytes"}, "bits"))
// 1 mbytes = 1024 kbytes
@@ -22,7 +22,7 @@ func TestData(t *testing.T) {
assert.Equal(t, Value{F: 1024, U: "bytes"}, dataConverter.Convert(Value{F: 1, U: "kbytes"}, "bytes"))
// 1024 kbytes = 1 mbytes
assert.Equal(t, Value{F: 1, U: "mbytes"}, dataConverter.Convert(Value{F: 1024, U: "kbytes"}, "mbytes"))
assert.Equal(t, Value{F: 1, U: "MBy"}, dataConverter.Convert(Value{F: 1024, U: "kbytes"}, "MBy"))
assert.Equal(t, Value{F: 1, U: "MBy"}, dataConverter.Convert(Value{F: 1000, U: "kBy"}, "MBy"))
// 1 mbytes = 1024 * 1024 bytes
assert.Equal(t, Value{F: 1024 * 1024, U: "bytes"}, dataConverter.Convert(Value{F: 1, U: "mbytes"}, "bytes"))
// 1024 mbytes = 1 gbytes
@@ -45,10 +45,90 @@ func TestData(t *testing.T) {
assert.Equal(t, Value{F: 1024 * 1024 * 1024 * 1024, U: "bytes"}, dataConverter.Convert(Value{F: 1, U: "tbytes"}, "bytes"))
// 1024 tbytes = 1 pbytes
assert.Equal(t, Value{F: 1, U: "pbytes"}, dataConverter.Convert(Value{F: 1024, U: "tbytes"}, "pbytes"))
// 1024 tbytes = 1 pbytes
assert.Equal(t, Value{F: 1, U: "PBy"}, dataConverter.Convert(Value{F: 1024, U: "tbytes"}, "PBy"))
// 1024 tbytes = 1 PiBy
assert.Equal(t, Value{F: 1, U: "PiBy"}, dataConverter.Convert(Value{F: 1024, U: "tbytes"}, "PiBy"))
// 1 pbytes = 1024 tbytes
assert.Equal(t, Value{F: 1024, U: "tbytes"}, dataConverter.Convert(Value{F: 1, U: "pbytes"}, "tbytes"))
// 1024 pbytes = 1 tbytes
assert.Equal(t, Value{F: 1024, U: "TBy"}, dataConverter.Convert(Value{F: 1, U: "pbytes"}, "TBy"))
// 1024 TiBy = 1 pbytes
assert.Equal(t, Value{F: 1024, U: "TiBy"}, dataConverter.Convert(Value{F: 1, U: "pbytes"}, "TiBy"))
}
func TestDataConversionUCUMUnit(t *testing.T) {
dataConverter := NewDataConverter()
tests := []struct {
name string
input Value
toUnit Unit
expected Value
}{
// Bits to bytes
{name: "Bits to bytes: 8 bit = 1 By", input: Value{F: 8, U: "bit"}, toUnit: "By", expected: Value{F: 1, U: "By"}},
{name: "Byte to bits: 1 By = 8 bit", input: Value{F: 1, U: "By"}, toUnit: "bit", expected: Value{F: 8, U: "bit"}},
// Binary byte scaling
{name: "Binary byte scaling: 1024 By = 1 KiBy", input: Value{F: 1024, U: "By"}, toUnit: "KiBy", expected: Value{F: 1, U: "KiBy"}},
{name: "Kibibyte to bytes: 1 KiBy = 1024 By", input: Value{F: 1, U: "KiBy"}, toUnit: "By", expected: Value{F: 1024, U: "By"}},
{name: "Binary byte scaling: 1024 KiBy = 1 MiBy", input: Value{F: 1024, U: "KiBy"}, toUnit: "MiBy", expected: Value{F: 1, U: "MiBy"}},
{name: "Binary byte scaling: 1024 MiBy = 1 GiBy", input: Value{F: 1024, U: "MiBy"}, toUnit: "GiBy", expected: Value{F: 1, U: "GiBy"}},
{name: "Gibibyte to mebibyte: 1 GiBy = 1024 MiBy", input: Value{F: 1, U: "GiBy"}, toUnit: "MiBy", expected: Value{F: 1024, U: "MiBy"}},
{name: "Binary byte scaling: 1024 GiBy = 1 TiBy", input: Value{F: 1024, U: "GiBy"}, toUnit: "TiBy", expected: Value{F: 1, U: "TiBy"}},
{name: "Binary byte scaling: 1024 TiBy = 1 PiBy", input: Value{F: 1024, U: "TiBy"}, toUnit: "PiBy", expected: Value{F: 1, U: "PiBy"}},
{name: "Pebibyte to tebibyte: 1 PiBy = 1024 TiBy", input: Value{F: 1, U: "PiBy"}, toUnit: "TiBy", expected: Value{F: 1024, U: "TiBy"}},
{name: "Gibibyte to bytes: 1 GiBy = 1073741824 By", input: Value{F: 1, U: "GiBy"}, toUnit: "By", expected: Value{F: 1024 * 1024 * 1024, U: "By"}},
{name: "Tebibyte to bytes: 1 TiBy = 1099511627776 By", input: Value{F: 1, U: "TiBy"}, toUnit: "By", expected: Value{F: 1024 * 1024 * 1024 * 1024, U: "By"}},
// SI bit scaling
{name: "SI bit scaling: 1000 bit = 1 kbit", input: Value{F: 1000, U: "bit"}, toUnit: "kbit", expected: Value{F: 1, U: "kbit"}},
{name: "Kilobit to bits: 1 kbit = 1000 bit", input: Value{F: 1, U: "kbit"}, toUnit: "bit", expected: Value{F: 1000, U: "bit"}},
{name: "SI bit scaling: 1000 kbit = 1 Mbit", input: Value{F: 1000, U: "kbit"}, toUnit: "Mbit", expected: Value{F: 1, U: "Mbit"}},
{name: "Gigabit to bits: 1 Gbit = 1000000000 bit", input: Value{F: 1, U: "Gbit"}, toUnit: "bit", expected: Value{F: 1000 * 1000 * 1000, U: "bit"}},
{name: "SI bit scaling: 1000 Mbit = 1 Gbit", input: Value{F: 1000, U: "Mbit"}, toUnit: "Gbit", expected: Value{F: 1, U: "Gbit"}},
{name: "Gigabit to megabit: 1 Gbit = 1000 Mbit", input: Value{F: 1, U: "Gbit"}, toUnit: "Mbit", expected: Value{F: 1000, U: "Mbit"}},
{name: "SI bit scaling: 1000 Gbit = 1 Tbit", input: Value{F: 1000, U: "Gbit"}, toUnit: "Tbit", expected: Value{F: 1, U: "Tbit"}},
{name: "Terabit to gigabit: 1 Tbit = 1000 Gbit", input: Value{F: 1, U: "Tbit"}, toUnit: "Gbit", expected: Value{F: 1000, U: "Gbit"}},
{name: "SI bit scaling: 1000 Tbit = 1 Pbit", input: Value{F: 1000, U: "Tbit"}, toUnit: "Pbit", expected: Value{F: 1, U: "Pbit"}},
{name: "Petabit to terabit: 1 Pbit = 1000 Tbit", input: Value{F: 1, U: "Pbit"}, toUnit: "Tbit", expected: Value{F: 1000, U: "Tbit"}},
// Binary bit scaling
{name: "Binary bit scaling: 1024 bit = 1 Kibit", input: Value{F: 1024, U: "bit"}, toUnit: "Kibit", expected: Value{F: 1, U: "Kibit"}},
{name: "Kibibit to bits: 1 Kibit = 1024 bit", input: Value{F: 1, U: "Kibit"}, toUnit: "bit", expected: Value{F: 1024, U: "bit"}},
{name: "Binary bit scaling: 1024 Kibit = 1 Mibit", input: Value{F: 1024, U: "Kibit"}, toUnit: "Mibit", expected: Value{F: 1, U: "Mibit"}},
{name: "Mebibit to kibibit: 1 Mibit = 1024 Kibit", input: Value{F: 1, U: "Mibit"}, toUnit: "Kibit", expected: Value{F: 1024, U: "Kibit"}},
{name: "Binary bit scaling: 1024 Mibit = 1 Gibit", input: Value{F: 1024, U: "Mibit"}, toUnit: "Gibit", expected: Value{F: 1, U: "Gibit"}},
{name: "Gibibit to mebibit: 1 Gibit = 1024 Mibit", input: Value{F: 1, U: "Gibit"}, toUnit: "Mibit", expected: Value{F: 1024, U: "Mibit"}},
{name: "Binary bit scaling: 1024 Gibit = 1 Tibit", input: Value{F: 1024, U: "Gibit"}, toUnit: "Tibit", expected: Value{F: 1, U: "Tibit"}},
{name: "Tebibit to gibibit: 1 Tibit = 1024 Gibit", input: Value{F: 1, U: "Tibit"}, toUnit: "Gibit", expected: Value{F: 1024, U: "Gibit"}},
{name: "Binary bit scaling: 1024 Tibit = 1 Pibit", input: Value{F: 1024, U: "Tibit"}, toUnit: "Pibit", expected: Value{F: 1, U: "Pibit"}},
{name: "Pebibit to tebibit: 1 Pibit = 1024 Tibit", input: Value{F: 1, U: "Pibit"}, toUnit: "Tibit", expected: Value{F: 1024, U: "Tibit"}},
// Bytes to bits
{name: "Bytes to bits: 1 KiBy = 8 Kibit", input: Value{F: 1, U: "KiBy"}, toUnit: "Kibit", expected: Value{F: 8, U: "Kibit"}},
{name: "Bytes to bits: 1 MiBy = 8 Mibit", input: Value{F: 1, U: "MiBy"}, toUnit: "Mibit", expected: Value{F: 8, U: "Mibit"}},
{name: "Bytes to bits: 1 GiBy = 8 Gibit", input: Value{F: 1, U: "GiBy"}, toUnit: "Gibit", expected: Value{F: 8, U: "Gibit"}},
// SI byte scaling (Exa, Zetta, Yotta)
{name: "SI byte scaling: 1000 PBy = 1 EBy", input: Value{F: 1000, U: "PBy"}, toUnit: "EBy", expected: Value{F: 1, U: "EBy"}},
{name: "Exabyte to bytes: 1 EBy = 1e18 By", input: Value{F: 1, U: "EBy"}, toUnit: "By", expected: Value{F: 1e18, U: "By"}},
{name: "SI byte scaling: 1000 EBy = 1 ZBy", input: Value{F: 1000, U: "EBy"}, toUnit: "ZBy", expected: Value{F: 1, U: "ZBy"}},
{name: "Zettabyte to petabytes: 1 ZBy = 1000000 PBy", input: Value{F: 1, U: "ZBy"}, toUnit: "PBy", expected: Value{F: 1e6, U: "PBy"}},
{name: "SI byte scaling: 1000 ZBy = 1 YBy", input: Value{F: 1000, U: "ZBy"}, toUnit: "YBy", expected: Value{F: 1, U: "YBy"}},
{name: "Yottabyte to zettabyte: 1 YBy = 1000 ZBy", input: Value{F: 1, U: "YBy"}, toUnit: "ZBy", expected: Value{F: 1000, U: "ZBy"}},
// Binary byte scaling (Exbi, Zebi, Yobi)
{name: "Binary byte scaling: 1024 PiBy = 1 EiBy", input: Value{F: 1024, U: "PiBy"}, toUnit: "EiBy", expected: Value{F: 1, U: "EiBy"}},
{name: "Exbibyte to tebibytes: 1 EiBy = 1048576 TiBy", input: Value{F: 1, U: "EiBy"}, toUnit: "TiBy", expected: Value{F: 1024 * 1024, U: "TiBy"}},
{name: "Binary byte scaling: 1024 EiBy = 1 ZiBy", input: Value{F: 1024, U: "EiBy"}, toUnit: "ZiBy", expected: Value{F: 1, U: "ZiBy"}},
{name: "Zebibyte to exbibyte: 1 ZiBy = 1024 EiBy", input: Value{F: 1, U: "ZiBy"}, toUnit: "EiBy", expected: Value{F: 1024, U: "EiBy"}},
{name: "Binary byte scaling: 1024 ZiBy = 1 YiBy", input: Value{F: 1024, U: "ZiBy"}, toUnit: "YiBy", expected: Value{F: 1, U: "YiBy"}},
{name: "Yobibyte to zebibyte: 1 YiBy = 1024 ZiBy", input: Value{F: 1, U: "YiBy"}, toUnit: "ZiBy", expected: Value{F: 1024, U: "ZiBy"}},
// SI bit scaling (Exa, Zetta, Yotta)
{name: "SI bit scaling: 1000 Pbit = 1 Ebit", input: Value{F: 1000, U: "Pbit"}, toUnit: "Ebit", expected: Value{F: 1, U: "Ebit"}},
{name: "Exabit to gigabits: 1 Ebit = 1e9 Gbit", input: Value{F: 1, U: "Ebit"}, toUnit: "Gbit", expected: Value{F: 1e9, U: "Gbit"}},
{name: "SI bit scaling: 1000 Ebit = 1 Zbit", input: Value{F: 1000, U: "Ebit"}, toUnit: "Zbit", expected: Value{F: 1, U: "Zbit"}},
{name: "Zettabit to exabit: 1 Zbit = 1000 Ebit", input: Value{F: 1, U: "Zbit"}, toUnit: "Ebit", expected: Value{F: 1000, U: "Ebit"}},
{name: "SI bit scaling: 1000 Zbit = 1 Ybit", input: Value{F: 1000, U: "Zbit"}, toUnit: "Ybit", expected: Value{F: 1, U: "Ybit"}},
{name: "Yottabit to zettabit: 1 Ybit = 1000 Zbit", input: Value{F: 1, U: "Ybit"}, toUnit: "Zbit", expected: Value{F: 1000, U: "Zbit"}},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := dataConverter.Convert(tt.input, tt.toUnit)
assert.Equal(t, tt.expected, got)
})
}
}

View File

@@ -47,7 +47,7 @@ func FromTimeUnit(u Unit) Duration {
return Hour
case "d":
return Day
case "w":
case "w", "wk":
return Week
default:
return Second

View File

@@ -54,4 +54,13 @@ func TestDurationConvert(t *testing.T) {
assert.Equal(t, Value{F: 1, U: "ms"}, timeConverter.Convert(Value{F: 1000, U: "us"}, "ms"))
// 1000000000 ns = 1 s
assert.Equal(t, Value{F: 1, U: "s"}, timeConverter.Convert(Value{F: 1000000000, U: "ns"}, "s"))
// 7 d = 1 wk
assert.Equal(t, Value{F: 1, U: "wk"}, timeConverter.Convert(Value{F: 7, U: "d"}, "wk"))
// 1 wk = 7 d
assert.Equal(t, Value{F: 7, U: "d"}, timeConverter.Convert(Value{F: 1, U: "wk"}, "d"))
// 1 wk = 168 h
assert.Equal(t, Value{F: 168, U: "h"}, timeConverter.Convert(Value{F: 1, U: "wk"}, "h"))
// 604800 s = 1 wk
assert.Equal(t, Value{F: 1, U: "wk"}, timeConverter.Convert(Value{F: 604800, U: "s"}, "wk"))
}

View File

@@ -24,30 +24,62 @@ func (f *dataFormatter) Format(value float64, unit string) string {
return humanize.IBytes(uint64(value))
case "decbytes":
return humanize.Bytes(uint64(value))
case "bits":
return humanize.IBytes(uint64(value * converter.Bit))
case "bits", "bit":
// humanize.IBytes/Bytes doesn't support bits
// and returns 0 B for values less than a byte
if value < 8 {
return fmt.Sprintf("%v b", value)
}
return humanize.IBytes(uint64(value / 8))
case "decbits":
return humanize.Bytes(uint64(value * converter.Bit))
case "kbytes", "kBy":
if value < 8 {
return fmt.Sprintf("%v b", value)
}
return humanize.Bytes(uint64(value / 8))
case "kbytes", "KiBy":
return humanize.IBytes(uint64(value * converter.Kibibit))
case "decKbytes", "deckbytes":
return humanize.IBytes(uint64(value * converter.Kilobit))
case "mbytes", "MBy":
case "Kibit":
return humanize.IBytes(uint64(value * converter.Kibibit / 8))
case "decKbytes", "deckbytes", "kBy":
return humanize.Bytes(uint64(value * converter.Kilobit))
case "kbit":
return humanize.Bytes(uint64(value * converter.Kilobit / 8))
case "mbytes", "MiBy":
return humanize.IBytes(uint64(value * converter.Mebibit))
case "decMbytes", "decmbytes":
case "Mibit":
return humanize.IBytes(uint64(value * converter.Mebibit / 8))
case "decMbytes", "decmbytes", "MBy":
return humanize.Bytes(uint64(value * converter.Megabit))
case "gbytes", "GBy":
case "Mbit":
return humanize.Bytes(uint64(value * converter.Megabit / 8))
case "gbytes", "GiBy":
return humanize.IBytes(uint64(value * converter.Gibibit))
case "decGbytes", "decgbytes":
case "Gibit":
return humanize.IBytes(uint64(value * converter.Gibibit / 8))
case "decGbytes", "decgbytes", "GBy":
return humanize.Bytes(uint64(value * converter.Gigabit))
case "tbytes", "TBy":
case "Gbit":
return humanize.Bytes(uint64(value * converter.Gigabit / 8))
case "tbytes", "TiBy":
return humanize.IBytes(uint64(value * converter.Tebibit))
case "decTbytes", "dectbytes":
case "Tibit":
return humanize.IBytes(uint64(value * converter.Tebibit / 8))
case "decTbytes", "dectbytes", "TBy":
return humanize.Bytes(uint64(value * converter.Terabit))
case "pbytes", "PBy":
case "Tbit":
return humanize.Bytes(uint64(value * converter.Terabit / 8))
case "pbytes", "PiBy":
return humanize.IBytes(uint64(value * converter.Pebibit))
case "decPbytes", "decpbytes":
case "Pbit":
return humanize.Bytes(uint64(value * converter.Petabit / 8))
case "decPbytes", "decpbytes", "PBy":
return humanize.Bytes(uint64(value * converter.Petabit))
case "EiBy":
return humanize.IBytes(uint64(value * converter.Exbibit))
case "Ebit":
return humanize.Bytes(uint64(value * converter.Exabit / 8))
case "EBy":
return humanize.Bytes(uint64(value * converter.Exabit))
}
// When unit is not matched, return the value as it is.
return fmt.Sprintf("%v", value)

View File

@@ -25,49 +25,66 @@ func (f *dataRateFormatter) Format(value float64, unit string) string {
case "Bps", "By/s":
return humanize.Bytes(uint64(value)) + "/s"
case "binbps":
return humanize.IBytes(uint64(value*converter.BitPerSecond)) + "/s"
// humanize.IBytes/Bytes doesn't support bits
// and returns 0 B for values less than a byte
if value < 8 {
return fmt.Sprintf("%v b/s", value)
}
return humanize.IBytes(uint64(value/8)) + "/s"
case "bps", "bit/s":
return humanize.Bytes(uint64(value*converter.BitPerSecond)) + "/s"
case "KiBs":
if value < 8 {
return fmt.Sprintf("%v b/s", value)
}
return humanize.Bytes(uint64(value/8)) + "/s"
case "KiBs", "KiBy/s":
return humanize.IBytes(uint64(value*converter.KibibitPerSecond)) + "/s"
case "Kibits":
return humanize.IBytes(uint64(value*converter.KibibytePerSecond)) + "/s"
case "Kibits", "Kibit/s":
return humanize.IBytes(uint64(value*converter.KibibitPerSecond/8)) + "/s"
case "KBs", "kBy/s":
return humanize.IBytes(uint64(value*converter.KilobitPerSecond)) + "/s"
case "Kbits", "kbit/s":
return humanize.IBytes(uint64(value*converter.KilobytePerSecond)) + "/s"
case "MiBs":
return humanize.Bytes(uint64(value*converter.KilobitPerSecond/8)) + "/s"
case "MiBs", "MiBy/s":
return humanize.IBytes(uint64(value*converter.MebibitPerSecond)) + "/s"
case "Mibits":
return humanize.IBytes(uint64(value*converter.MebibytePerSecond)) + "/s"
case "Mibits", "Mibit/s":
return humanize.IBytes(uint64(value*converter.MebibitPerSecond/8)) + "/s"
case "MBs", "MBy/s":
return humanize.IBytes(uint64(value*converter.MegabitPerSecond)) + "/s"
case "Mbits", "Mbit/s":
return humanize.IBytes(uint64(value*converter.MegabytePerSecond)) + "/s"
case "GiBs":
return humanize.Bytes(uint64(value*converter.MegabitPerSecond/8)) + "/s"
case "GiBs", "GiBy/s":
return humanize.IBytes(uint64(value*converter.GibibitPerSecond)) + "/s"
case "Gibits":
return humanize.IBytes(uint64(value*converter.GibibytePerSecond)) + "/s"
case "Gibits", "Gibit/s":
return humanize.IBytes(uint64(value*converter.GibibitPerSecond/8)) + "/s"
case "GBs", "GBy/s":
return humanize.IBytes(uint64(value*converter.GigabitPerSecond)) + "/s"
case "Gbits", "Gbit/s":
return humanize.IBytes(uint64(value*converter.GigabytePerSecond)) + "/s"
case "TiBs":
return humanize.Bytes(uint64(value*converter.GigabitPerSecond/8)) + "/s"
case "TiBs", "TiBy/s":
return humanize.IBytes(uint64(value*converter.TebibitPerSecond)) + "/s"
case "Tibits":
return humanize.IBytes(uint64(value*converter.TebibytePerSecond)) + "/s"
case "Tibits", "Tibit/s":
return humanize.IBytes(uint64(value*converter.TebibitPerSecond/8)) + "/s"
case "TBs", "TBy/s":
return humanize.IBytes(uint64(value*converter.TerabitPerSecond)) + "/s"
case "Tbits", "Tbit/s":
return humanize.IBytes(uint64(value*converter.TerabytePerSecond)) + "/s"
case "PiBs":
return humanize.Bytes(uint64(value*converter.TerabitPerSecond/8)) + "/s"
case "PiBs", "PiBy/s":
return humanize.IBytes(uint64(value*converter.PebibitPerSecond)) + "/s"
case "Pibits":
return humanize.IBytes(uint64(value*converter.PebibytePerSecond)) + "/s"
case "Pibits", "Pibit/s":
return humanize.IBytes(uint64(value*converter.PebibitPerSecond/8)) + "/s"
case "PBs", "PBy/s":
return humanize.IBytes(uint64(value*converter.PetabitPerSecond)) + "/s"
case "Pbits", "Pbit/s":
return humanize.IBytes(uint64(value*converter.PetabytePerSecond)) + "/s"
return humanize.Bytes(uint64(value*converter.PetabitPerSecond/8)) + "/s"
// Exa units
case "EBy/s":
return humanize.Bytes(uint64(value*converter.ExabitPerSecond)) + "/s"
case "Ebit/s":
return humanize.Bytes(uint64(value*converter.ExabitPerSecond/8)) + "/s"
case "EiBy/s":
return humanize.IBytes(uint64(value*converter.ExbibitPerSecond)) + "/s"
case "Eibit/s":
return humanize.IBytes(uint64(value*converter.ExbibitPerSecond/8)) + "/s"
}
// When unit is not matched, return the value as it is.
return fmt.Sprintf("%v", value)

View File

@@ -0,0 +1,150 @@
package formatter
import (
"testing"
"github.com/stretchr/testify/assert"
)
func TestDataRateFormatterComprehensive(t *testing.T) {
dataRateFormatter := NewDataRateFormatter()
tests := []struct {
name string
value float64
unit string
expected string
}{
// IEC Bits/sec - binbps, bps
{name: "binbps as Bps", value: 7, unit: "binbps", expected: "7 b/s"},
{name: "100 binbps as 12 Bps", value: 100, unit: "binbps", expected: "12 B/s"},
{name: "binbps as 23 GiBs", value: 8 * 1024 * 1024 * 1024 * 23, unit: "binbps", expected: "23 GiB/s"},
// SI Bits/sec - bps, bit/s
{name: "bps as Bps", value: 5, unit: "bps", expected: "5 b/s"},
{name: "200 bitps as 25 Bps", value: 200, unit: "bit/s", expected: "25 B/s"},
{name: "bitps as MBs", value: 8 * 1000 * 1000 * 7, unit: "bit/s", expected: "7.0 MB/s"},
// IEC Base bytes/sec - binBps
{name: "binBps as Bps", value: 0, unit: "binBps", expected: "0 B/s"},
{name: "1 binBps as 1 Bps", value: 1, unit: "binBps", expected: "1 B/s"},
{name: "binBps as Kibps", value: 1024, unit: "binBps", expected: "1.0 KiB/s"},
{name: "binBps as Mibps", value: 1024 * 1024, unit: "binBps", expected: "1.0 MiB/s"},
{name: "binBps as Gibps", value: 1024 * 1024 * 1024, unit: "binBps", expected: "1.0 GiB/s"},
// SI Base bytes/sec - Bps, By/s
{name: "Bps as Bps", value: 1, unit: "Bps", expected: "1 B/s"},
{name: "Bps as kbps", value: 1000, unit: "Bps", expected: "1.0 kB/s"},
{name: "Bps as Mbps", value: 1000 * 1000, unit: "Bps", expected: "1.0 MB/s"},
{name: "Byps as kbps", value: 1000, unit: "By/s", expected: "1.0 kB/s"},
// Kibibytes/sec - KiBs, KiBy/s
{name: "Kibs as Bps", value: 0, unit: "KiBs", expected: "0 B/s"},
{name: "Kibs as Kibps", value: 1, unit: "KiBs", expected: "1.0 KiB/s"},
{name: "Kibs as Mibps", value: 1024, unit: "KiBs", expected: "1.0 MiB/s"},
{name: "Kibs as Gibps", value: 3 * 1024 * 1024, unit: "KiBs", expected: "3.0 GiB/s"},
{name: "KiByps as Kibps", value: 1, unit: "KiBy/s", expected: "1.0 KiB/s"},
{name: "KiByps as Mibps", value: 1024, unit: "KiBy/s", expected: "1.0 MiB/s"},
// Kibibits/sec - Kibits, Kibit/s
{name: "Kibitps as Kibps", value: 1, unit: "Kibits", expected: "128 B/s"},
{name: "Kibitps as Mibps", value: 42 * 1024, unit: "Kibits", expected: "5.3 MiB/s"},
{name: "Kibitps as Kibps 10", value: 10, unit: "Kibit/s", expected: "1.3 KiB/s"},
// Kilobytes/sec (SI) - KBs, kBy/s
{name: "Kbs as Bps", value: 0.5, unit: "KBs", expected: "500 B/s"},
{name: "Kbs as Mibps", value: 1048.6, unit: "KBs", expected: "1.0 MiB/s"},
{name: "kByps as Bps", value: 1, unit: "kBy/s", expected: "1000 B/s"},
// Kilobits/sec (SI) - Kbits, kbit/s
{name: "Kbitps as Bps", value: 1, unit: "Kbits", expected: "125 B/s"},
{name: "kbitps as Bps", value: 1, unit: "kbit/s", expected: "125 B/s"},
// Mebibytes/sec - MiBs, MiBy/s
{name: "Mibs as Mibps", value: 1, unit: "MiBs", expected: "1.0 MiB/s"},
{name: "Mibs as Gibps", value: 1024, unit: "MiBs", expected: "1.0 GiB/s"},
{name: "Mibs as Tibps", value: 1024 * 1024, unit: "MiBs", expected: "1.0 TiB/s"},
{name: "MiByps as Mibps", value: 1, unit: "MiBy/s", expected: "1.0 MiB/s"},
// Mebibits/sec - Mibits, Mibit/s
{name: "Mibitps as Mibps", value: 40, unit: "Mibits", expected: "5.0 MiB/s"},
{name: "Mibitps as Mibps per second variant", value: 10, unit: "Mibit/s", expected: "1.3 MiB/s"},
// Megabytes/sec (SI) - MBs, MBy/s
{name: "Mbs as Kibps", value: 1, unit: "MBs", expected: "977 KiB/s"},
{name: "MByps as Kibps", value: 1, unit: "MBy/s", expected: "977 KiB/s"},
// Megabits/sec (SI) - Mbits, Mbit/s
{name: "Mbitps as Kibps", value: 1, unit: "Mbits", expected: "125 kB/s"},
{name: "Mbitps as Kibps per second variant", value: 1, unit: "Mbit/s", expected: "125 kB/s"},
// Gibibytes/sec - GiBs, GiBy/s
{name: "Gibs as Gibps", value: 1, unit: "GiBs", expected: "1.0 GiB/s"},
{name: "Gibs as Tibps", value: 1024, unit: "GiBs", expected: "1.0 TiB/s"},
{name: "GiByps as Tibps", value: 42 * 1024, unit: "GiBy/s", expected: "42 TiB/s"},
// Gibibits/sec - Gibits, Gibit/s
{name: "Gibitps as Tibps", value: 42 * 1024, unit: "Gibits", expected: "5.3 TiB/s"},
{name: "Gibitps as Tibps per second variant", value: 42 * 1024, unit: "Gibit/s", expected: "5.3 TiB/s"},
// Gigabytes/sec (SI) - GBs, GBy/s
{name: "Gbs as Tibps", value: 42 * 1000, unit: "GBs", expected: "38 TiB/s"},
{name: "GByps as Tibps", value: 42 * 1000, unit: "GBy/s", expected: "38 TiB/s"},
// Gigabits/sec (SI) - Gbits, Gbit/s
{name: "Gbitps as Tibps", value: 42 * 1000, unit: "Gbits", expected: "5.3 TB/s"},
{name: "Gbitps as Tibps per second variant", value: 42 * 1000, unit: "Gbit/s", expected: "5.3 TB/s"},
// Tebibytes/sec - TiBs, TiBy/s
{name: "Tibs as Tibps", value: 1, unit: "TiBs", expected: "1.0 TiB/s"},
{name: "Tibs as Pibps", value: 1024, unit: "TiBs", expected: "1.0 PiB/s"},
{name: "TiByps as Pibps", value: 42 * 1024, unit: "TiBy/s", expected: "42 PiB/s"},
// Tebibits/sec - Tibits, Tibit/s
{name: "Tibitps as Pibps", value: 42 * 1024, unit: "Tibits", expected: "5.3 PiB/s"},
{name: "Tibitps as Pibps per second variant", value: 42 * 1024, unit: "Tibit/s", expected: "5.3 PiB/s"},
// Terabytes/sec (SI) - TBs, TBy/s
{name: "Tbs as Pibps", value: 42 * 1000, unit: "TBs", expected: "37 PiB/s"},
{name: "TByps as Pibps", value: 42 * 1000, unit: "TBy/s", expected: "37 PiB/s"},
// Terabits/sec (SI) - Tbits, Tbit/s
{name: "Tbitps as Pibps", value: 42 * 1000, unit: "Tbits", expected: "5.3 PB/s"},
{name: "Tbitps as Pibps per second variant", value: 42 * 1000, unit: "Tbit/s", expected: "5.3 PB/s"},
// Pebibytes/sec - PiBs, PiBy/s
{name: "Pibs as Eibps", value: 10 * 1024, unit: "PiBs", expected: "10 EiB/s"},
{name: "PiByps as Eibps", value: 10 * 1024, unit: "PiBy/s", expected: "10 EiB/s"},
// Pebibits/sec - Pibits, Pibit/s
{name: "Pibitps as Eibps", value: 10 * 1024, unit: "Pibits", expected: "1.3 EiB/s"},
{name: "Pibitps as Eibps per second variant", value: 10 * 1024, unit: "Pibit/s", expected: "1.3 EiB/s"},
// Petabytes/sec (SI) - PBs, PBy/s
{name: "Pbs as Pibps", value: 42, unit: "PBs", expected: "37 PiB/s"},
{name: "PByps as Pibps", value: 42, unit: "PBy/s", expected: "37 PiB/s"},
// Petabits/sec (SI) - Pbits, Pbit/s
{name: "Pbitps as Pibps", value: 42, unit: "Pbits", expected: "5.3 PB/s"},
{name: "Pbitps as Pibps per second variant", value: 42, unit: "Pbit/s", expected: "5.3 PB/s"},
// Exabytes/sec (SI) - EBy/s
{name: "EByps as Ebps", value: 10, unit: "EBy/s", expected: "10 EB/s"},
// Exabits/sec (SI) - Ebit/s
{name: "Ebitps as Ebps", value: 10, unit: "Ebit/s", expected: "1.3 EB/s"},
// Exbibytes/sec (IEC) - EiBy/s
{name: "EiByps as Eibps", value: 10, unit: "EiBy/s", expected: "10 EiB/s"},
// Exbibits/sec (IEC) - Eibit/s
{name: "Eibitps as Eibps", value: 10, unit: "Eibit/s", expected: "1.3 EiB/s"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := dataRateFormatter.Format(tt.value, tt.unit)
assert.Equal(t, tt.expected, got)
})
}
}

View File

@@ -14,21 +14,174 @@ func TestData(t *testing.T) {
assert.Equal(t, "1.0 KiB", dataFormatter.Format(1024, "bytes"))
assert.Equal(t, "1.0 KiB", dataFormatter.Format(1024, "By"))
assert.Equal(t, "2.3 GiB", dataFormatter.Format(2.3*1024, "mbytes"))
assert.Equal(t, "2.3 GiB", dataFormatter.Format(2.3*1024, "MBy"))
assert.Equal(t, "2.3 GiB", dataFormatter.Format(2.3*1024, "MiBy"))
assert.Equal(t, "1.0 MiB", dataFormatter.Format(1024*1024, "bytes"))
assert.Equal(t, "1.0 MiB", dataFormatter.Format(1024*1024, "By"))
assert.Equal(t, "69 TiB", dataFormatter.Format(69*1024*1024, "mbytes"))
assert.Equal(t, "69 TiB", dataFormatter.Format(69*1024*1024, "MBy"))
assert.Equal(t, "69 TiB", dataFormatter.Format(69*1024*1024, "MiBy"))
assert.Equal(t, "102 KiB", dataFormatter.Format(102*1024, "bytes"))
assert.Equal(t, "102 KiB", dataFormatter.Format(102*1024, "By"))
assert.Equal(t, "240 MiB", dataFormatter.Format(240*1024, "kbytes"))
assert.Equal(t, "240 MiB", dataFormatter.Format(240*1024, "kBy"))
assert.Equal(t, "240 MiB", dataFormatter.Format(240*1024, "KiBy"))
assert.Equal(t, "1.0 GiB", dataFormatter.Format(1024*1024, "kbytes"))
assert.Equal(t, "1.0 GiB", dataFormatter.Format(1024*1024, "kBy"))
assert.Equal(t, "1.0 GiB", dataFormatter.Format(1024*1024, "KiBy"))
assert.Equal(t, "23 GiB", dataFormatter.Format(23*1024*1024, "kbytes"))
assert.Equal(t, "23 GiB", dataFormatter.Format(23*1024*1024, "kBy"))
assert.Equal(t, "23 GiB", dataFormatter.Format(23*1024*1024, "KiBy"))
assert.Equal(t, "32 TiB", dataFormatter.Format(32*1024*1024*1024, "kbytes"))
assert.Equal(t, "32 TiB", dataFormatter.Format(32*1024*1024*1024, "kBy"))
assert.Equal(t, "32 TiB", dataFormatter.Format(32*1024*1024*1024, "KiBy"))
assert.Equal(t, "24 MiB", dataFormatter.Format(24, "mbytes"))
assert.Equal(t, "24 MiB", dataFormatter.Format(24, "MBy"))
assert.Equal(t, "24 MiB", dataFormatter.Format(24, "MiBy"))
}
func TestDataFormatterComprehensive(t *testing.T) {
dataFormatter := NewDataFormatter()
tests := []struct {
name string
value float64
unit string
expected string
}{
// IEC Bits - bits, bit
{name: "bits: 1", value: 1, unit: "bits", expected: "1 b"},
{name: "bits: 7", value: 7, unit: "bit", expected: "7 b"},
{name: "bits: to MiB = 8 * 1024 * 1024 * 9", value: 8 * 1024 * 1024 * 9, unit: "bits", expected: "9.0 MiB"},
// SI Bits - decbits, bit
{name: "decbits: 1", value: 1, unit: "decbits", expected: "1 b"},
{name: "decbits: 7", value: 7, unit: "decbits", expected: "7 b"},
{name: "decbits: to MB = 8 * 1000 * 1000 * 4", value: 8 * 1000 * 1000 * 4, unit: "decbits", expected: "4.0 MB"},
// IEC Base bytes - bytes, By
{name: "bytes: 0", value: 0, unit: "bytes", expected: "0 B"},
{name: "bytes: 1", value: 1, unit: "bytes", expected: "1 B"},
{name: "bytes: 512", value: 512, unit: "bytes", expected: "512 B"},
{name: "bytes: 1023", value: 1023, unit: "bytes", expected: "1023 B"},
{name: "bytes: 1024 = 1 KiB", value: 1024, unit: "bytes", expected: "1.0 KiB"},
{name: "bytes: 1536", value: 1536, unit: "bytes", expected: "1.5 KiB"},
{name: "bytes: 1024*1024 = 1 MiB", value: 1024 * 1024, unit: "bytes", expected: "1.0 MiB"},
{name: "bytes: 1024*1024*1024 = 1 GiB", value: 1024 * 1024 * 1024, unit: "bytes", expected: "1.0 GiB"},
{name: "By: same as bytes", value: 1024, unit: "By", expected: "1.0 KiB"},
// SI Base bytes - decbytes
{name: "decbytes: 1", value: 1, unit: "decbytes", expected: "1 B"},
{name: "decbytes: 1000 = 1 kB", value: 1000, unit: "decbytes", expected: "1.0 kB"},
{name: "decbytes: 1000*1000 = 1 MB", value: 1000 * 1000, unit: "decbytes", expected: "1.0 MB"},
{name: "decbytes: 1000*1000*1000 = 1 GB", value: 1000 * 1000 * 1000, unit: "decbytes", expected: "1.0 GB"},
// Kibibytes - kbytes, KiBy (IEC)
{name: "kbytes: 0", value: 0, unit: "kbytes", expected: "0 B"},
{name: "kbytes: 1 = 1 KiB", value: 1, unit: "kbytes", expected: "1.0 KiB"},
{name: "kbytes: 512", value: 512, unit: "kbytes", expected: "512 KiB"},
{name: "kbytes: 1024 = 1 MiB", value: 1024, unit: "kbytes", expected: "1.0 MiB"},
{name: "kbytes: 1024*1024 = 1 GiB", value: 1024 * 1024, unit: "kbytes", expected: "1.0 GiB"},
{name: "kbytes: 2.3*1024 = 2.3 MiB", value: 2.3 * 1024, unit: "kbytes", expected: "2.3 MiB"},
{name: "KiBy: 1 = 1 KiB", value: 1, unit: "KiBy", expected: "1.0 KiB"},
{name: "KiBy: 1024 = 1 MiB", value: 1024, unit: "KiBy", expected: "1.0 MiB"},
{name: "kbytes and KiBy alias", value: 240 * 1024, unit: "KiBy", expected: "240 MiB"},
// SI Kilobytes - decKbytes, deckbytes, kBy
{name: "decKbytes: 1 = 1 kB", value: 1, unit: "decKbytes", expected: "1.0 kB"},
{name: "decKbytes: 1000 = 1 MB", value: 1000, unit: "decKbytes", expected: "1.0 MB"},
{name: "deckbytes: 1 = 1 kB", value: 1, unit: "deckbytes", expected: "1.0 kB"},
{name: "kBy: 1 = 1 kB", value: 1, unit: "kBy", expected: "1.0 kB"},
{name: "kBy: 1000 = 1 MB", value: 1000, unit: "kBy", expected: "1.0 MB"},
// Mebibytes - mbytes, MiBy (IEC)
{name: "mbytes: 1 = 1 MiB", value: 1, unit: "mbytes", expected: "1.0 MiB"},
{name: "mbytes: 24", value: 24, unit: "mbytes", expected: "24 MiB"},
{name: "mbytes: 1024 = 1 GiB", value: 1024, unit: "mbytes", expected: "1.0 GiB"},
{name: "mbytes: 1024*1024 = 1 TiB", value: 1024 * 1024, unit: "mbytes", expected: "1.0 TiB"},
{name: "mbytes: 69*1024 = 69 GiB", value: 69 * 1024, unit: "mbytes", expected: "69 GiB"},
{name: "mbytes: 69*1024*1024 = 69 TiB", value: 69 * 1024 * 1024, unit: "mbytes", expected: "69 TiB"},
{name: "MiBy: 1 = 1 MiB", value: 1, unit: "MiBy", expected: "1.0 MiB"},
{name: "MiBy: 1024 = 1 GiB", value: 1024, unit: "MiBy", expected: "1.0 GiB"},
// SI Megabytes - decMbytes, decmbytes, MBy
{name: "decMbytes: 1 = 1 MB", value: 1, unit: "decMbytes", expected: "1.0 MB"},
{name: "decMbytes: 1000 = 1 GB", value: 1000, unit: "decMbytes", expected: "1.0 GB"},
{name: "decmbytes: 1 = 1 MB", value: 1, unit: "decmbytes", expected: "1.0 MB"},
{name: "MBy: 1 = 1 MB", value: 1, unit: "MBy", expected: "1.0 MB"},
// Gibibytes - gbytes, GiBy (IEC)
{name: "gbytes: 1 = 1 GiB", value: 1, unit: "gbytes", expected: "1.0 GiB"},
{name: "gbytes: 1024 = 1 TiB", value: 1024, unit: "gbytes", expected: "1.0 TiB"},
{name: "GiBy: 42*1024 = 42 TiB", value: 42 * 1024, unit: "GiBy", expected: "42 TiB"},
// SI Gigabytes - decGbytes, decgbytes, GBy
{name: "decGbytes: 42*1000 = 42 TB", value: 42 * 1000, unit: "decGbytes", expected: "42 TB"},
{name: "GBy: 42*1000 = 42 TB", value: 42 * 1000, unit: "GBy", expected: "42 TB"},
// Tebibytes - tbytes, TiBy (IEC)
{name: "tbytes: 1 = 1 TiB", value: 1, unit: "tbytes", expected: "1.0 TiB"},
{name: "tbytes: 1024 = 1 PiB", value: 1024, unit: "tbytes", expected: "1.0 PiB"},
{name: "TiBy: 42*1024 = 42 PiB", value: 42 * 1024, unit: "TiBy", expected: "42 PiB"},
// SI Terabytes - decTbytes, dectbytes, TBy
{name: "decTbytes: 42*1000 = 42 PB", value: 42 * 1000, unit: "decTbytes", expected: "42 PB"},
{name: "dectbytes: 42*1000 = 42 PB", value: 42 * 1000, unit: "dectbytes", expected: "42 PB"},
{name: "TBy: 42*1000 = 42 PB", value: 42 * 1000, unit: "TBy", expected: "42 PB"},
// Pebibytes - pbytes, PiBy (IEC)
{name: "pbytes: 10*1024 = 10 EiB", value: 10 * 1024, unit: "pbytes", expected: "10 EiB"},
{name: "PiBy: 10*1024 = 10 EiB", value: 10 * 1024, unit: "PiBy", expected: "10 EiB"},
// SI Petabytes - decPbytes, decpbytes, PBy
{name: "decPbytes: 42 = 42 PB", value: 42, unit: "decPbytes", expected: "42 PB"},
{name: "decpbytes: 42 = 42 PB", value: 42, unit: "decpbytes", expected: "42 PB"},
{name: "PBy: 42 = 42 PB", value: 42, unit: "PBy", expected: "42 PB"},
// Exbibytes - EiBy (IEC)
{name: "EiBy: 10 = 10 EiB", value: 10, unit: "EiBy", expected: "10 EiB"},
// Exabytes - EBy (SI)
{name: "EBy: 10 = 10 EB", value: 10, unit: "EBy", expected: "10 EB"},
// Kibibits - Kibit (IEC): 1 Kibit = 1024 bits = 128 bytes
{name: "Kibit: 1 = 128 B", value: 1, unit: "Kibit", expected: "128 B"},
{name: "Kibit: 1024 = 128 KiB", value: 1024, unit: "Kibit", expected: "128 KiB"},
// Mebibits - Mibit (IEC): 1 Mibit = 1024 Kibit = 128 KiB
{name: "Mibit: 1 = 128 KiB", value: 1, unit: "Mibit", expected: "128 KiB"},
{name: "Mibit: 1024 = 128 MiB", value: 1024, unit: "Mibit", expected: "128 MiB"},
// Gibibits - Gibit (IEC): 1 Gibit = 1024 Mibit = 128 MiB
{name: "Gibit: 1 = 128 MiB", value: 1, unit: "Gibit", expected: "128 MiB"},
{name: "Gibit: 42*1024 = 5.3 TiB", value: 42 * 1024, unit: "Gibit", expected: "5.3 TiB"},
// Tebibits - Tibit (IEC): 1 Tibit = 1024 Gibit = 128 GiB
{name: "Tibit: 1 = 128 GiB", value: 1, unit: "Tibit", expected: "128 GiB"},
{name: "Tibit: 42*1024 = 5.3 PiB", value: 42 * 1024, unit: "Tibit", expected: "5.3 PiB"},
// Kilobits - kbit (SI): 1 kbit = 1000 bits = 125 bytes
{name: "kbit: 1 = 125 B", value: 1, unit: "kbit", expected: "125 B"},
{name: "kbit: 1000 = 125 kB", value: 1000, unit: "kbit", expected: "125 kB"},
// Megabits - Mbit (SI): 1 Mbit = 1000 kbit = 125 kB
{name: "Mbit: 1 = 125 kB", value: 1, unit: "Mbit", expected: "125 kB"},
{name: "Mbit: 1000 = 125 MB", value: 1000, unit: "Mbit", expected: "125 MB"},
// Gigabits - Gbit (SI): 1 Gbit = 1000 Mbit = 125 MB
{name: "Gbit: 1 = 125 MB", value: 1, unit: "Gbit", expected: "125 MB"},
{name: "Gbit: 42*1000 = 5.3 TB", value: 42 * 1000, unit: "Gbit", expected: "5.3 TB"},
// Terabits - Tbit (SI): 1 Tbit = 1000 Gbit = 125 GB
{name: "Tbit: 1 = 125 GB", value: 1, unit: "Tbit", expected: "125 GB"},
{name: "Tbit: 42*1000 = 5.3 PB", value: 42 * 1000, unit: "Tbit", expected: "5.3 PB"},
// Petabits - Pbit (SI): 1 Pbit = 1000 Tbit = 125 TB
{name: "Pbit: 1 = 125 TB", value: 1, unit: "Pbit", expected: "125 TB"},
{name: "Pbit: 42 = 5.3 PB", value: 42, unit: "Pbit", expected: "5.3 PB"},
// Exabits - Ebit (SI): 1 Ebit = 1000 Pbit = 125 PB
{name: "Ebit: 1 = 125 PB", value: 1, unit: "Ebit", expected: "125 PB"},
{name: "Ebit: 10 = 1.3 EB", value: 10, unit: "Ebit", expected: "1.3 EB"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := dataFormatter.Format(tt.value, tt.unit)
assert.Equal(t, tt.expected, got)
})
}
}

View File

@@ -18,11 +18,11 @@ var (
func FromUnit(u string) Formatter {
switch u {
case "ns", "us", "µs", "ms", "s", "m", "h", "d", "min":
case "ns", "us", "µs", "ms", "s", "m", "h", "d", "min", "w", "wk":
return DurationFormatter
case "bytes", "decbytes", "bits", "decbits", "kbytes", "decKbytes", "deckbytes", "mbytes", "decMbytes", "decmbytes", "gbytes", "decGbytes", "decgbytes", "tbytes", "decTbytes", "dectbytes", "pbytes", "decPbytes", "decpbytes", "By", "kBy", "MBy", "GBy", "TBy", "PBy":
case "bytes", "decbytes", "bits", "bit", "decbits", "kbytes", "decKbytes", "deckbytes", "mbytes", "decMbytes", "decmbytes", "gbytes", "decGbytes", "decgbytes", "tbytes", "decTbytes", "dectbytes", "pbytes", "decPbytes", "decpbytes", "By", "kBy", "MBy", "GBy", "TBy", "PBy", "EBy", "KiBy", "MiBy", "GiBy", "TiBy", "PiBy", "EiBy", "kbit", "Mbit", "Gbit", "Tbit", "Pbit", "Ebit", "Kibit", "Mibit", "Gibit", "Tibit", "Pibit":
return DataFormatter
case "binBps", "Bps", "binbps", "bps", "KiBs", "Kibits", "KBs", "Kbits", "MiBs", "Mibits", "MBs", "Mbits", "GiBs", "Gibits", "GBs", "Gbits", "TiBs", "Tibits", "TBs", "Tbits", "PiBs", "Pibits", "PBs", "Pbits", "By/s", "kBy/s", "MBy/s", "GBy/s", "TBy/s", "PBy/s", "bit/s", "kbit/s", "Mbit/s", "Gbit/s", "Tbit/s", "Pbit/s":
case "binBps", "Bps", "binbps", "bps", "KiBs", "Kibits", "KBs", "Kbits", "MiBs", "Mibits", "MBs", "Mbits", "GiBs", "Gibits", "GBs", "Gbits", "TiBs", "Tibits", "TBs", "Tbits", "PiBs", "Pibits", "PBs", "Pbits", "By/s", "kBy/s", "MBy/s", "GBy/s", "TBy/s", "PBy/s", "EBy/s", "bit/s", "kbit/s", "Mbit/s", "Gbit/s", "Tbit/s", "Pbit/s", "Ebit/s", "KiBy/s", "MiBy/s", "GiBy/s", "TiBy/s", "PiBy/s", "EiBy/s", "Kibit/s", "Mibit/s", "Gibit/s", "Tibit/s", "Pibit/s", "Eibit/s":
return DataRateFormatter
case "percent", "percentunit", "%":
return PercentFormatter

View File

@@ -32,7 +32,7 @@ func (f *durationFormatter) Format(value float64, unit string) string {
return toHours(value)
case "d":
return toDays(value)
case "w":
case "w", "wk":
return toWeeks(value)
}
// When unit is not matched, return the value as it is.

View File

@@ -205,7 +205,7 @@ func AdjustKey(key *telemetrytypes.TelemetryFieldKey, keys map[string][]*telemet
key.Indexes = matchingKey.Indexes
key.Materialized = matchingKey.Materialized
key.JSONPlan = matchingKey.JSONPlan
return actions
} else {
// multiple matching keys, set materialized only if all the keys are materialized

View File

@@ -483,6 +483,22 @@ func (v *filterExpressionVisitor) VisitComparison(ctx *grammar.ComparisonContext
value1 := v.Visit(values[0])
value2 := v.Visit(values[1])
switch value1.(type) {
case float64:
if _, ok := value2.(float64); !ok {
v.errors = append(v.errors, fmt.Sprintf("value type mismatch for key %s: expected number for both operands", keys[0].Name))
return ""
}
case string:
if _, ok := value2.(string); !ok {
v.errors = append(v.errors, fmt.Sprintf("value type mismatch for key %s: expected string for both operands", keys[0].Name))
return ""
}
default:
v.errors = append(v.errors, fmt.Sprintf("value type mismatch for key %s: operands must be number or string", keys[0].Name))
return ""
}
var conds []string
for _, key := range keys {
condition, err := v.conditionBuilder.ConditionFor(context.Background(), key, op, []any{value1, value2}, v.builder, v.startNs, v.endNs)
@@ -855,7 +871,7 @@ func (v *filterExpressionVisitor) VisitKey(ctx *grammar.KeyContext) any {
// 1. either user meant key ( this is already handled above in fieldKeysForName )
// 2. or user meant `attribute.key` we look up in the map for all possible field keys with name 'attribute.key'
// Note:
// Note:
// If user only wants to search `attribute.key`, then they have to use `attribute.attribute.key`
// If user only wants to search `key`, then they have to use `key`
// If user wants to search both, they can use `attribute.key` and we will resolve the ambiguity

View File

@@ -375,13 +375,6 @@ func mergeAndEnsureBackwardCompatibility(ctx context.Context, logger *slog.Logge
config.Flagger.Config.Boolean[flagger.FeatureKafkaSpanEval.String()] = os.Getenv("KAFKA_SPAN_EVAL") == "true"
}
if os.Getenv("INTERPOLATION_ENABLED") != "" {
logger.WarnContext(ctx, "[Deprecated] env INTERPOLATION_ENABLED is deprecated and scheduled for removal. Please use SIGNOZ_FLAGGER_CONFIG_BOOLEAN_INTERPOLATION__ENABLED instead.")
if config.Flagger.Config.Boolean == nil {
config.Flagger.Config.Boolean = make(map[string]bool)
}
config.Flagger.Config.Boolean[flagger.FeatureInterpolationEnabled.String()] = os.Getenv("INTERPOLATION_ENABLED") == "true"
}
}
func (config Config) Collect(_ context.Context, _ valuer.UUID) (map[string]any, error) {

View File

@@ -31,6 +31,7 @@ import (
"github.com/SigNoz/signoz/pkg/modules/tracefunnel/impltracefunnel"
"github.com/SigNoz/signoz/pkg/querier"
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
"github.com/SigNoz/signoz/pkg/zeus"
)
type Handlers struct {
@@ -48,6 +49,7 @@ type Handlers struct {
GatewayHandler gateway.Handler
Fields fields.Handler
AuthzHandler authz.Handler
ZeusHandler zeus.Handler
}
func NewHandlers(
@@ -60,6 +62,7 @@ func NewHandlers(
gatewayService gateway.Gateway,
telemetryMetadataStore telemetrytypes.MetadataStore,
authz authz.AuthZ,
zeusService zeus.Zeus,
) Handlers {
return Handlers{
SavedView: implsavedview.NewHandler(modules.SavedView),
@@ -76,5 +79,6 @@ func NewHandlers(
GatewayHandler: gateway.NewHandler(gatewayService),
Fields: implfields.NewHandler(providerSettings, telemetryMetadataStore),
AuthzHandler: signozauthzapi.NewHandler(authz),
ZeusHandler: zeus.NewHandler(zeusService, licensing),
}
}

Some files were not shown because too many files have changed in this diff Show More