Compare commits

..

14 Commits

Author SHA1 Message Date
Abhi kumar
0b1aba04f4 Merge branch 'main' into feat/service-map 2026-05-05 17:10:46 +05:30
Abhi Kumar
5c8d33290b chore: updated particle color + added service icon 2026-05-05 17:08:40 +05:30
Abhi kumar
18d5e92ae2 fix: added fix for panel sync mode in non-view panels (#11187) 2026-05-05 10:06:28 +00:00
Vikrant Gupta
5eaca31759 chore(service-account): remove api keys deprecation banner (#11188) 2026-05-05 09:53:26 +00:00
Abhi kumar
30424a3829 Merge branch 'main' into feat/service-map 2026-05-05 14:13:01 +05:30
Abhi Kumar
d2ede7bb16 chore: added changes related to edge color and node size 2026-05-05 14:04:54 +05:30
Abhi kumar
8b0ccc8ddc feat: user dashboard preference (#11159)
Some checks failed
build-staging / js-build (push) Has been cancelled
build-staging / go-build (push) Has been cancelled
build-staging / staging (push) Has been cancelled
build-staging / prepare (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
* feat: user dashboard preference

* chore: moved to module css

* chore: pr review changes

* chore: minor fixes

* feat: added changes for synced tooltip modes (#11175)

* feat: added changes for synced tooltip modes

* chore: pr review changes

* chore: minor fix

* chore: added changes for deleting dashboard preferences on dashboard delete
2026-05-05 08:07:33 +00:00
SagarRajput-7
1118136b69 feat: updated the cancel subscription banner styles and message (#11181)
* feat: updated the cancel subscription banner styles and message

* feat: cancel button update

* feat: updated the confirmation dialog styles and added 'cancel' input

* feat: added test cases

* feat: added test cases

* feat: updated messages

* feat: updated test cases

* feat: updated styles as per feedback
2026-05-05 07:41:59 +00:00
Abhi kumar
ae3f5114c4 chore: minor ui fixes in tooltip (#11099)
* chore: minor ui fixes in tooltip

* chore: preetify

* chore: exposed tooltip + added panelid in events

* chore: fixed and updated tooltip test

* chore: added tooltip footer tests

* chore: updated pr review changes and added support for multi query

* chore: minor fix
2026-05-05 06:45:59 +00:00
Abhi Kumar
c622bbd112 Merge branch 'feat/service-map' of https://github.com/SigNoz/signoz into feat/service-map 2026-05-05 11:15:12 +05:30
Abhi kumar
b92d5e934b Merge branch 'main' into feat/service-map 2026-05-05 02:34:43 +05:30
Abhi Kumar
c20520da5f chore: fixed bgcolor 2026-05-05 02:28:03 +05:30
Pandey
8409a9798d fix(authdomain): nest config response, rename Updateable→Updatable, return Identifiable on create (#11176)
Some checks failed
build-staging / staging (push) Has been cancelled
build-staging / prepare (push) Has been cancelled
build-staging / js-build (push) Has been cancelled
build-staging / go-build (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
* fix(authdomain): nest config response, rename Updateable→Updatable, return Identifiable on create

Three small API-shape corrections on auth_domain:

- GettableAuthDomain previously embedded AuthDomainConfig, which
  flattened sso_enabled / saml_config / oidc_config / google_auth_config /
  role_mapping at the response root and made the response shape
  diverge from the request shape (PostableAuthDomain has them under
  `config`). Move it under a named `Config` field with a `config`
  json tag so request and response carry the same nested object.
- UpdateableAuthDomain → UpdatableAuthDomain (typo fix; aligns with
  UpdatableUser already in the codebase).
- CreateAuthDomain previously returned the full GettableAuthDomain;
  the only field clients actually need from the create response is
  the new ID. Switch to Identifiable so the contract states what the
  endpoint guarantees and clients re-Read for the full domain when
  needed.

Frontend schema and OpenAPI spec regenerated.

* fix(authdomain-frontend): adapt to nested config + Identifiable create response

Regenerate the orval client (`yarn generate:api`) and update the
auth-domain UI for the API shape changes from the previous commit:

- `record.ssoType`, `.ssoEnabled`, `.googleAuthConfig`, `.oidcConfig`,
  `.samlConfig`, `.roleMapping` are now nested under `record.config.*`
  in `AuthtypesGettableAuthDomainDTO` — update SSOEnforcementToggle,
  CreateEdit form initial-values, the list page's Configure button,
  and the auth-domain test mocks.
- `mockCreateSuccessResponse` now returns `{ id }` (Identifiable)
  instead of the full domain.

`yarn generate:api` ran clean: lint OK, tsgo OK.

* fix(authdomain): align CreateAuthDomain success code with handler + adjust integration test

The Create handler returns http.StatusCreated but the OpenAPI
annotation said StatusOK. Sync the annotation to 201, regenerate the
spec + frontend client.

The callbackauthn integration test (01_domain.py) still read
`domain["ssoType"]` off the GET response — now nested under
`domain["config"]["ssoType"]` after the previous shape change. Update
the assertion.
2026-05-04 20:44:41 +00:00
Abhi Kumar
0ccb26cbfc feat: revamped the service map component 2026-05-05 02:14:13 +05:30
108 changed files with 3527 additions and 1920 deletions

View File

@@ -301,34 +301,20 @@ components:
type: string
type: object
AuthtypesGettableAuthDomain:
oneOf:
- $ref: '#/components/schemas/AuthtypesSamlConfig'
- $ref: '#/components/schemas/AuthtypesGoogleConfig'
- $ref: '#/components/schemas/AuthtypesOIDCConfig'
properties:
authNProviderInfo:
$ref: '#/components/schemas/AuthtypesAuthNProviderInfo'
config:
$ref: '#/components/schemas/AuthtypesAuthDomainConfig'
createdAt:
format: date-time
type: string
googleAuthConfig:
$ref: '#/components/schemas/AuthtypesGoogleConfig'
id:
type: string
name:
type: string
oidcConfig:
$ref: '#/components/schemas/AuthtypesOIDCConfig'
orgId:
type: string
roleMapping:
$ref: '#/components/schemas/AuthtypesRoleMapping'
samlConfig:
$ref: '#/components/schemas/AuthtypesSamlConfig'
ssoEnabled:
type: boolean
ssoType:
$ref: '#/components/schemas/AuthtypesAuthNProvider'
updatedAt:
format: date-time
type: string
@@ -589,7 +575,7 @@ components:
- relation
- object
type: object
AuthtypesUpdateableAuthDomain:
AuthtypesUpdatableAuthDomain:
properties:
config:
$ref: '#/components/schemas/AuthtypesAuthDomainConfig'
@@ -4450,8 +4436,6 @@ components:
type: string
kind:
$ref: '#/components/schemas/RuletypesMaintenanceKind'
labelExpression:
type: string
name:
type: string
schedule:
@@ -4479,8 +4463,6 @@ components:
type: array
description:
type: string
labelExpression:
type: string
name:
type: string
schedule:
@@ -4545,6 +4527,10 @@ components:
properties:
duration:
type: string
endTime:
format: date-time
nullable: true
type: string
repeatOn:
items:
$ref: '#/components/schemas/RuletypesRepeatOn'
@@ -4552,7 +4538,11 @@ components:
type: array
repeatType:
$ref: '#/components/schemas/RuletypesRepeatType'
startTime:
format: date-time
type: string
required:
- startTime
- duration
- repeatType
type: object
@@ -4719,7 +4709,6 @@ components:
type: string
required:
- timezone
- startTime
type: object
RuletypesScheduleType:
enum:
@@ -7076,20 +7065,20 @@ paths:
schema:
$ref: '#/components/schemas/AuthtypesPostableAuthDomain'
responses:
"200":
"201":
content:
application/json:
schema:
properties:
data:
$ref: '#/components/schemas/AuthtypesGettableAuthDomain'
$ref: '#/components/schemas/TypesIdentifiable'
status:
type: string
required:
- status
- data
type: object
description: OK
description: Created
"400":
content:
application/json:
@@ -7245,7 +7234,7 @@ paths:
content:
application/json:
schema:
$ref: '#/components/schemas/AuthtypesUpdateableAuthDomain'
$ref: '#/components/schemas/AuthtypesUpdatableAuthDomain'
responses:
"204":
description: No Content

View File

@@ -13,6 +13,7 @@ import (
"github.com/SigNoz/signoz/pkg/errors"
baserules "github.com/SigNoz/signoz/pkg/query-service/rules"
"github.com/SigNoz/signoz/pkg/types/ruletypes"
"github.com/SigNoz/signoz/pkg/valuer"
)
func PrepareTaskFunc(opts baserules.PrepareTaskOptions) (baserules.Task, error) {
@@ -48,7 +49,7 @@ func PrepareTaskFunc(opts baserules.PrepareTaskOptions) (baserules.Task, error)
rules = append(rules, tr)
// create ch rule task for evaluation
task = newTask(baserules.TaskTypeCh, opts.TaskName, evaluation.GetFrequency().Duration(), rules, opts.ManagerOpts, opts.NotifyFunc)
task = newTask(baserules.TaskTypeCh, opts.TaskName, evaluation.GetFrequency().Duration(), rules, opts.ManagerOpts, opts.NotifyFunc, opts.MaintenanceStore, opts.OrgID)
} else if opts.Rule.RuleType == ruletypes.RuleTypeProm {
@@ -72,7 +73,7 @@ func PrepareTaskFunc(opts baserules.PrepareTaskOptions) (baserules.Task, error)
rules = append(rules, pr)
// create promql rule task for evaluation
task = newTask(baserules.TaskTypeProm, opts.TaskName, evaluation.GetFrequency().Duration(), rules, opts.ManagerOpts, opts.NotifyFunc)
task = newTask(baserules.TaskTypeProm, opts.TaskName, evaluation.GetFrequency().Duration(), rules, opts.ManagerOpts, opts.NotifyFunc, opts.MaintenanceStore, opts.OrgID)
} else if opts.Rule.RuleType == ruletypes.RuleTypeAnomaly {
// create anomaly rule
@@ -95,7 +96,7 @@ func PrepareTaskFunc(opts baserules.PrepareTaskOptions) (baserules.Task, error)
rules = append(rules, ar)
// create anomaly rule task for evaluation
task = newTask(baserules.TaskTypeCh, opts.TaskName, evaluation.GetFrequency().Duration(), rules, opts.ManagerOpts, opts.NotifyFunc)
task = newTask(baserules.TaskTypeCh, opts.TaskName, evaluation.GetFrequency().Duration(), rules, opts.ManagerOpts, opts.NotifyFunc, opts.MaintenanceStore, opts.OrgID)
} else {
return nil, errors.NewInvalidInputf(errors.CodeInvalidInput, "unsupported rule type %s. Supported types: %s, %s", opts.Rule.RuleType, ruletypes.RuleTypeProm, ruletypes.RuleTypeThreshold)
@@ -209,9 +210,9 @@ func TestNotification(opts baserules.PrepareTestRuleOptions) (int, error) {
}
// newTask returns an appropriate group for the rule type
func newTask(taskType baserules.TaskType, name string, frequency time.Duration, rules []baserules.Rule, opts *baserules.ManagerOptions, notify baserules.NotifyFunc) baserules.Task {
func newTask(taskType baserules.TaskType, name string, frequency time.Duration, rules []baserules.Rule, opts *baserules.ManagerOptions, notify baserules.NotifyFunc, maintenanceStore ruletypes.MaintenanceStore, orgID valuer.UUID) baserules.Task {
if taskType == baserules.TaskTypeCh {
return baserules.NewRuleTask(name, "", frequency, rules, opts, notify)
return baserules.NewRuleTask(name, "", frequency, rules, opts, notify, maintenanceStore, orgID)
}
return baserules.NewPromRuleTask(name, "", frequency, rules, opts, notify)
return baserules.NewPromRuleTask(name, "", frequency, rules, opts, notify, maintenanceStore, orgID)
}

View File

@@ -36,6 +36,7 @@
"@ant-design/icons": "4.8.0",
"@codemirror/autocomplete": "6.18.6",
"@codemirror/lang-javascript": "6.2.3",
"@dagrejs/dagre": "3.0.0",
"@dnd-kit/core": "6.1.0",
"@dnd-kit/modifiers": "7.0.0",
"@dnd-kit/sortable": "8.0.0",
@@ -63,6 +64,7 @@
"@visx/shape": "3.5.0",
"@visx/tooltip": "3.3.0",
"@vitejs/plugin-react": "5.1.4",
"@xyflow/react": "12.10.2",
"ansi-to-html": "0.7.2",
"antd": "5.11.0",
"antd-table-saveas-excel": "2.2.1",
@@ -115,7 +117,6 @@
"react-dom": "18.2.0",
"react-drag-listview": "2.0.0",
"react-error-boundary": "4.0.11",
"react-force-graph-2d": "^1.29.1",
"react-full-screen": "1.1.1",
"react-grid-layout": "^1.3.4",
"react-helmet-async": "1.3.0",

View File

@@ -19,8 +19,8 @@ import type {
import type {
AuthtypesPostableAuthDomainDTO,
AuthtypesUpdateableAuthDomainDTO,
CreateAuthDomain200,
AuthtypesUpdatableAuthDomainDTO,
CreateAuthDomain201,
DeleteAuthDomainPathParameters,
GetAuthDomain200,
GetAuthDomainPathParameters,
@@ -126,7 +126,7 @@ export const createAuthDomain = (
authtypesPostableAuthDomainDTO: BodyType<AuthtypesPostableAuthDomainDTO>,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<CreateAuthDomain200>({
return GeneratedAPIInstance<CreateAuthDomain201>({
url: `/api/v1/domains`,
method: 'POST',
headers: { 'Content-Type': 'application/json' },
@@ -388,13 +388,13 @@ export const invalidateGetAuthDomain = async (
*/
export const updateAuthDomain = (
{ id }: UpdateAuthDomainPathParameters,
authtypesUpdateableAuthDomainDTO: BodyType<AuthtypesUpdateableAuthDomainDTO>,
authtypesUpdatableAuthDomainDTO: BodyType<AuthtypesUpdatableAuthDomainDTO>,
) => {
return GeneratedAPIInstance<void>({
url: `/api/v1/domains/${id}`,
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
data: authtypesUpdateableAuthDomainDTO,
data: authtypesUpdatableAuthDomainDTO,
});
};
@@ -407,7 +407,7 @@ export const getUpdateAuthDomainMutationOptions = <
TError,
{
pathParams: UpdateAuthDomainPathParameters;
data: BodyType<AuthtypesUpdateableAuthDomainDTO>;
data: BodyType<AuthtypesUpdatableAuthDomainDTO>;
},
TContext
>;
@@ -416,7 +416,7 @@ export const getUpdateAuthDomainMutationOptions = <
TError,
{
pathParams: UpdateAuthDomainPathParameters;
data: BodyType<AuthtypesUpdateableAuthDomainDTO>;
data: BodyType<AuthtypesUpdatableAuthDomainDTO>;
},
TContext
> => {
@@ -433,7 +433,7 @@ export const getUpdateAuthDomainMutationOptions = <
Awaited<ReturnType<typeof updateAuthDomain>>,
{
pathParams: UpdateAuthDomainPathParameters;
data: BodyType<AuthtypesUpdateableAuthDomainDTO>;
data: BodyType<AuthtypesUpdatableAuthDomainDTO>;
}
> = (props) => {
const { pathParams, data } = props ?? {};
@@ -448,7 +448,7 @@ export type UpdateAuthDomainMutationResult = NonNullable<
Awaited<ReturnType<typeof updateAuthDomain>>
>;
export type UpdateAuthDomainMutationBody =
BodyType<AuthtypesUpdateableAuthDomainDTO>;
BodyType<AuthtypesUpdatableAuthDomainDTO>;
export type UpdateAuthDomainMutationError = ErrorType<RenderErrorResponseDTO>;
/**
@@ -463,7 +463,7 @@ export const useUpdateAuthDomain = <
TError,
{
pathParams: UpdateAuthDomainPathParameters;
data: BodyType<AuthtypesUpdateableAuthDomainDTO>;
data: BodyType<AuthtypesUpdatableAuthDomainDTO>;
},
TContext
>;
@@ -472,7 +472,7 @@ export const useUpdateAuthDomain = <
TError,
{
pathParams: UpdateAuthDomainPathParameters;
data: BodyType<AuthtypesUpdateableAuthDomainDTO>;
data: BodyType<AuthtypesUpdatableAuthDomainDTO>;
},
TContext
> => {

View File

@@ -1641,109 +1641,32 @@ export interface AuthtypesCallbackAuthNSupportDTO {
url?: string;
}
export type AuthtypesGettableAuthDomainDTO =
| (AuthtypesSamlConfigDTO & {
authNProviderInfo?: AuthtypesAuthNProviderInfoDTO;
/**
* @type string
* @format date-time
*/
createdAt?: Date;
googleAuthConfig?: AuthtypesGoogleConfigDTO;
/**
* @type string
*/
id: string;
/**
* @type string
*/
name?: string;
oidcConfig?: AuthtypesOIDCConfigDTO;
/**
* @type string
*/
orgId?: string;
roleMapping?: AuthtypesRoleMappingDTO;
samlConfig?: AuthtypesSamlConfigDTO;
/**
* @type boolean
*/
ssoEnabled?: boolean;
ssoType?: AuthtypesAuthNProviderDTO;
/**
* @type string
* @format date-time
*/
updatedAt?: Date;
})
| (AuthtypesGoogleConfigDTO & {
authNProviderInfo?: AuthtypesAuthNProviderInfoDTO;
/**
* @type string
* @format date-time
*/
createdAt?: Date;
googleAuthConfig?: AuthtypesGoogleConfigDTO;
/**
* @type string
*/
id: string;
/**
* @type string
*/
name?: string;
oidcConfig?: AuthtypesOIDCConfigDTO;
/**
* @type string
*/
orgId?: string;
roleMapping?: AuthtypesRoleMappingDTO;
samlConfig?: AuthtypesSamlConfigDTO;
/**
* @type boolean
*/
ssoEnabled?: boolean;
ssoType?: AuthtypesAuthNProviderDTO;
/**
* @type string
* @format date-time
*/
updatedAt?: Date;
})
| (AuthtypesOIDCConfigDTO & {
authNProviderInfo?: AuthtypesAuthNProviderInfoDTO;
/**
* @type string
* @format date-time
*/
createdAt?: Date;
googleAuthConfig?: AuthtypesGoogleConfigDTO;
/**
* @type string
*/
id: string;
/**
* @type string
*/
name?: string;
oidcConfig?: AuthtypesOIDCConfigDTO;
/**
* @type string
*/
orgId?: string;
roleMapping?: AuthtypesRoleMappingDTO;
samlConfig?: AuthtypesSamlConfigDTO;
/**
* @type boolean
*/
ssoEnabled?: boolean;
ssoType?: AuthtypesAuthNProviderDTO;
/**
* @type string
* @format date-time
*/
updatedAt?: Date;
});
export interface AuthtypesGettableAuthDomainDTO {
authNProviderInfo?: AuthtypesAuthNProviderInfoDTO;
config?: AuthtypesAuthDomainConfigDTO;
/**
* @type string
* @format date-time
*/
createdAt?: Date;
/**
* @type string
*/
id: string;
/**
* @type string
*/
name?: string;
/**
* @type string
*/
orgId?: string;
/**
* @type string
* @format date-time
*/
updatedAt?: Date;
}
export interface AuthtypesGettableObjectsDTO {
resource: AuthtypesResourceDTO;
@@ -2067,7 +1990,7 @@ export interface AuthtypesTransactionDTO {
relation: string;
}
export interface AuthtypesUpdateableAuthDomainDTO {
export interface AuthtypesUpdatableAuthDomainDTO {
config?: AuthtypesAuthDomainConfigDTO;
}
@@ -6751,10 +6674,6 @@ export interface RuletypesPlannedMaintenanceDTO {
*/
id: string;
kind: RuletypesMaintenanceKindDTO;
/**
* @type string
*/
labelExpression?: string;
/**
* @type string
*/
@@ -6782,10 +6701,6 @@ export interface RuletypesPostablePlannedMaintenanceDTO {
* @type string
*/
description?: string;
/**
* @type string
*/
labelExpression?: string;
/**
* @type string
*/
@@ -6859,12 +6774,23 @@ export interface RuletypesRecurrenceDTO {
* @type string
*/
duration: string;
/**
* @type string
* @format date-time
* @nullable true
*/
endTime?: Date | null;
/**
* @type array
* @nullable true
*/
repeatOn?: RuletypesRepeatOnDTO[] | null;
repeatType: RuletypesRepeatTypeDTO;
/**
* @type string
* @format date-time
*/
startTime: Date;
}
export interface RuletypesRenotifyDTO {
@@ -7049,7 +6975,7 @@ export interface RuletypesScheduleDTO {
* @type string
* @format date-time
*/
startTime: Date;
startTime?: Date;
/**
* @type string
*/
@@ -8429,8 +8355,8 @@ export type ListAuthDomains200 = {
status: string;
};
export type CreateAuthDomain200 = {
data: AuthtypesGettableAuthDomainDTO;
export type CreateAuthDomain201 = {
data: TypesIdentifiableDTO;
/**
* @type string
*/

View File

@@ -6,6 +6,7 @@ export enum Events {
TOOLTIP_PINNED = 'TOOLTIP_PINNED',
TOOLTIP_UNPINNED = 'TOOLTIP_UNPINNED',
TOOLTIP_CONTENT_SCROLLED = 'TOOLTIP_CONTENT_SCROLLED',
TOOLTIP_SYNC_MODE_CHANGED = 'TOOLTIP_SYNC_MODE_CHANGED',
}
export enum InfraMonitoringEvents {

View File

@@ -38,4 +38,5 @@ export enum LOCALSTORAGE {
DISSMISSED_COST_METER_INFO = 'DISMISSED_COST_METER_INFO',
DISMISSED_API_KEYS_DEPRECATION_BANNER = 'DISMISSED_API_KEYS_DEPRECATION_BANNER',
LICENSE_KEY_CALLOUT_DISMISSED = 'LICENSE_KEY_CALLOUT_DISMISSED',
DASHBOARD_PREFERENCES = 'DASHBOARD_PREFERENCES',
}

View File

@@ -107,7 +107,7 @@ describe('BillingContainer', () => {
).resolves.toBeInTheDocument();
await expect(
screen.findByText('Cancel Subscription', { selector: 'span' }),
screen.findByText('Cancel your subscription', { selector: 'span' }),
).resolves.toBeInTheDocument();
});
@@ -150,7 +150,7 @@ describe('BillingContainer', () => {
expect(dayRemainingInBillingPeriod).toBeInTheDocument();
await expect(
screen.findByText('Cancel Subscription', { selector: 'span' }),
screen.findByText('Cancel your subscription', { selector: 'span' }),
).resolves.toBeInTheDocument();
});
});
@@ -162,7 +162,7 @@ describe('BillingContainer', () => {
it('should render when license is ACTIVATED and platform is CLOUD', async () => {
render(<BillingContainer />);
await expect(
screen.findByText('Cancel Subscription', { selector: 'span' }),
screen.findByText('Cancel your subscription', { selector: 'span' }),
).resolves.toBeInTheDocument();
});
@@ -186,7 +186,7 @@ describe('BillingContainer', () => {
);
await screen.findByText('billing');
expect(
screen.queryByText('Cancel Subscription', { selector: 'span' }),
screen.queryByText('Cancel your subscription', { selector: 'span' }),
).not.toBeInTheDocument();
});
@@ -225,7 +225,7 @@ describe('BillingContainer', () => {
render(<BillingContainer />, {}, { appContextOverrides: overrides });
await screen.findByText('billing');
expect(
screen.queryByText('Cancel Subscription', { selector: 'span' }),
screen.queryByText('Cancel your subscription', { selector: 'span' }),
).not.toBeInTheDocument();
});
});

View File

@@ -1,11 +1,11 @@
.banner {
display: flex;
justify-content: space-between;
align-items: center;
align-items: flex-start;
padding: var(--padding-4);
border-radius: 4px;
border: 1px solid var(--callout-error-border);
background-color: var(--callout-error-background);
border: 1px solid var(--l1-border);
background-color: var(--l2-background);
margin: var(--spacing-4) 0 var(--spacing-12);
}
@@ -15,21 +15,55 @@
gap: var(--spacing-2);
}
.titleRow {
display: flex;
align-items: center;
gap: var(--spacing-3);
}
.title {
font-size: var(--font-size-sm);
font-weight: var(--font-weight-medium);
color: var(--callout-error-title);
font-size: var(--paragraph-base-500-font-size);
font-weight: var(--paragraph-base-500-font-weight);
line-height: var(--paragraph-base-500-line-height);
color: var(--l1-foreground);
}
.subtitle {
font-size: var(--paragraph-base-400-font-size);
font-weight: var(--paragraph-base-400-font-weight);
line-height: var(--paragraph-base-400-line-height);
color: var(--callout-error-icon);
color: var(--l2-foreground);
padding-left: 20px;
}
.dialogBody {
font-size: var(--font-size-sm);
line-height: var(--line-height-relaxed);
color: var(--l2-foreground);
display: flex;
flex-direction: column;
gap: var(--spacing-4);
}
.dialogDescription {
font-size: var(--paragraph-base-400-font-size);
font-weight: var(--paragraph-base-400-font-weight);
line-height: var(--paragraph-base-400-line-height);
color: var(--l2-foreground);
margin: 0;
}
.dialogConfirmLabel {
font-size: var(--paragraph-base-400-font-size);
font-weight: var(--paragraph-base-400-font-weight);
line-height: var(--paragraph-base-400-line-height);
color: var(--l2-foreground);
margin: 0;
code {
font-family: var(--font-mono);
color: var(--l1-foreground);
}
}
.cancelButton {
background: var(--secondary-background);
border: 1px solid var(--l1-border);
}

View File

@@ -1,4 +1,4 @@
import { render, screen, userEvent } from 'tests/test-utils';
import { render, screen, userEvent, waitFor } from 'tests/test-utils';
import CancelSubscriptionBanner from './CancelSubscriptionBanner';
@@ -13,14 +13,16 @@ describe('CancelSubscriptionBanner', () => {
it('renders banner with title and subtitle', () => {
render(<CancelSubscriptionBanner />);
expect(
screen.getByText('Cancel Subscription', { selector: 'span' }),
screen.getByText('Cancel your subscription', { selector: 'span' }),
).toBeInTheDocument();
expect(
screen.getByText('Cancel your SigNoz subscription.'),
screen.getByText(
/When you cancel your SigNoz subscription, all your data will be deleted/i,
),
).toBeInTheDocument();
});
it('opens dialog with correct content when Cancel Subscription is clicked', async () => {
it('opens dialog with content when Cancel Subscription is clicked', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
render(<CancelSubscriptionBanner />);
@@ -30,17 +32,62 @@ describe('CancelSubscriptionBanner', () => {
expect(screen.getByRole('dialog')).toBeInTheDocument();
expect(
screen.getByText(/reach out to our support team/i),
screen.getByText(/Cancelling your subscription would stop your data/i),
).toBeInTheDocument();
expect(screen.getByText(/Type/i)).toBeInTheDocument();
expect(
screen.getByRole('button', { name: /keep subscription/i }),
screen.getByPlaceholderText(/Enter the word cancel/i),
).toBeInTheDocument();
expect(screen.getByRole('button', { name: /go back/i })).toBeInTheDocument();
expect(
screen.getByRole('button', { name: /contact support/i }),
screen.getByRole('button', { name: /cancel subscription/i }),
).toBeInTheDocument();
});
it('sends mailto to cloud-support with correct subject on Contact Support', async () => {
it('keeps Cancel subscription button disabled until "cancel" is typed', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
render(<CancelSubscriptionBanner />);
await user.click(
screen.getByRole('button', { name: /cancel subscription/i }),
);
const confirmButton = screen.getByRole('button', {
name: /cancel subscription/i,
});
expect(confirmButton).toBeDisabled();
const input = screen.getByPlaceholderText(/Enter the word cancel/i);
await user.type(input, 'canc');
expect(confirmButton).toBeDisabled();
await user.type(input, 'el');
expect(confirmButton).toBeEnabled();
});
it('closes dialog and resets input when Go back is clicked', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
render(<CancelSubscriptionBanner />);
await user.click(
screen.getByRole('button', { name: /cancel subscription/i }),
);
const input = screen.getByPlaceholderText(/Enter the word cancel/i);
await user.type(input, 'cancel');
await user.click(screen.getByRole('button', { name: /go back/i }));
await waitFor(() =>
expect(screen.queryByRole('dialog')).not.toBeInTheDocument(),
);
await user.click(
screen.getByRole('button', { name: /cancel subscription/i }),
);
expect(screen.getByPlaceholderText(/Enter the word cancel/i)).toHaveValue('');
});
it('sends mailto to cloud-support with correct subject after typing "cancel"', async () => {
const realCreateElement = document.createElement.bind(document);
const mockClick = jest.fn();
const mockAnchor = { href: '', click: mockClick };
@@ -57,7 +104,13 @@ describe('CancelSubscriptionBanner', () => {
await user.click(
screen.getByRole('button', { name: /cancel subscription/i }),
);
await user.click(screen.getByRole('button', { name: /contact support/i }));
const input = screen.getByPlaceholderText(/Enter the word cancel/i);
await user.type(input, 'cancel');
await user.click(
screen.getByRole('button', { name: /cancel subscription/i }),
);
expect(mockAnchor.href).toContain('mailto:cloud-support@signoz.io');
expect(mockAnchor.href).toContain('Cancel%20My%20SigNoz%20Subscription');

View File

@@ -1,15 +1,17 @@
import { useState } from 'react';
import { X } from '@signozhq/icons';
import { Button, DialogWrapper } from '@signozhq/ui';
import { SolidInfoCircle, Undo2, X } from '@signozhq/icons';
import { Button, DialogWrapper, Input } from '@signozhq/ui';
import logEvent from 'api/common/logEvent';
import { pick } from 'lodash-es';
import { useAppContext } from 'providers/App/App';
import { getBaseUrl } from 'utils/basePath';
import styles from './CancelSubscriptionBanner.module.scss';
import { Color } from '@signozhq/design-tokens';
function CancelSubscriptionBanner(): JSX.Element {
const [open, setOpen] = useState(false);
const [confirmText, setConfirmText] = useState('');
const { user, org } = useAppContext();
const handleOpenCancelDialog = (): void => {
@@ -53,6 +55,12 @@ function CancelSubscriptionBanner(): JSX.Element {
link.href = `mailto:cloud-support@signoz.io?subject=${subject}&body=${body}`;
link.click();
setOpen(false);
setConfirmText('');
};
const handleClose = (): void => {
setOpen(false);
setConfirmText('');
};
const footer = (
@@ -60,12 +68,19 @@ function CancelSubscriptionBanner(): JSX.Element {
<Button
variant="solid"
color="secondary"
onClick={(): void => setOpen(false)}
prefix={<Undo2 size={14} />}
onClick={handleClose}
>
Keep Subscription
Go back
</Button>
<Button variant="solid" color="destructive" onClick={handleContactSupport}>
Contact Support
<Button
variant="solid"
color="destructive"
prefix={<X size={14} />}
disabled={confirmText !== 'cancel'}
onClick={handleContactSupport}
>
Cancel subscription
</Button>
</>
);
@@ -74,30 +89,47 @@ function CancelSubscriptionBanner(): JSX.Element {
<>
<div className={styles.banner}>
<div className={styles.info}>
<span className={styles.title}>Cancel Subscription</span>
<span className={styles.subtitle}>Cancel your SigNoz subscription.</span>
<div className={styles.titleRow}>
<SolidInfoCircle color={Color.BG_SAKURA_500} size={12} />
<span className={styles.title}>Cancel your subscription</span>
</div>
<span className={styles.subtitle}>
When you cancel your SigNoz subscription, all your data will be deleted
immediately and removed from our servers.
</span>
</div>
<Button
variant="solid"
color="destructive"
color="secondary"
prefix={<X size={12} />}
onClick={handleOpenCancelDialog}
className={styles.cancelButton}
>
Cancel Subscription
</Button>
</div>
<DialogWrapper
open={open}
onOpenChange={setOpen}
title="Cancel your subscription"
onOpenChange={handleClose}
title="Cancel your subscription?"
width="narrow"
showCloseButton={false}
footer={footer}
>
<p className={styles.dialogBody}>
To cancel your SigNoz subscription, please reach out to our support team.
We&apos;ll be happy to assist you.
</p>
<div className={styles.dialogBody}>
<p className={styles.dialogDescription}>
Cancelling your subscription would stop your data from being ingested to
SigNoz. All the data that has been already sent will also be deleted.
</p>
<p className={styles.dialogConfirmLabel}>
Type <code>cancel</code> to confirm the cancellation.
</p>
<Input
placeholder="Enter the word cancel..."
value={confirmText}
onChange={(e): void => setConfirmText(e.target.value)}
/>
</div>
</DialogWrapper>
</>
);

View File

@@ -0,0 +1,190 @@
.overviewContent {
display: flex;
flex-direction: column;
gap: 16px;
}
.overviewSettings {
border-radius: 3px;
border: 1px solid var(--l1-border);
padding: 16px !important;
}
.crossPanelSyncGroup {
display: flex;
flex-direction: column;
gap: 16px;
}
.crossPanelSyncSectionTitle {
color: var(--l1-foreground);
font-family: Inter;
font-size: 14px;
font-weight: 500;
line-height: 20px;
}
.crossPanelSyncRow {
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: center;
gap: 16px;
& + & {
padding-top: 16px;
border-top: 1px solid var(--l1-border);
}
}
.crossPanelSyncInfo {
display: flex;
flex-direction: column;
gap: 4px;
}
.crossPanelSyncTitle {
color: var(--l2-foreground);
font-family: Inter;
font-size: 14px;
font-weight: 400;
line-height: 20px;
}
.crossPanelSyncDescription {
color: var(--l3-foreground);
font-family: Inter;
font-size: 13px;
font-weight: 400;
line-height: 20px;
}
.nameIconInput {
display: flex;
}
.dashboardImageInput {
:global(.ant-select-selector) {
display: flex;
width: 32px;
height: 32px;
padding: 6px;
justify-content: center;
align-items: center;
border-radius: 2px 0px 0px 2px;
border: 1px solid var(--l1-border) !important;
background: var(--l3-background) !important;
:global(.ant-select-selection-item) {
display: flex;
align-items: center;
}
}
&:global(.ant-select-dropdown) {
padding: 0px !important;
}
:global(.ant-select-item) {
padding: 0px;
align-items: center;
justify-content: center;
:global(.ant-select-item-option-content) {
display: flex;
align-items: center;
justify-content: center;
}
}
}
.listItemImage {
height: 16px;
width: 16px;
}
.dashboardNameInput {
border-radius: 0px 2px 2px 0px;
border: 1px solid var(--l1-border);
background: var(--l3-background);
}
.dashboardName {
color: var(--l2-foreground);
font-family: Inter;
font-size: 14px;
font-style: normal;
font-weight: 400;
line-height: 20px;
margin-bottom: 0.5rem;
}
.descriptionTextArea {
padding: 6px 6px 6px 8px;
border-radius: 2px;
border: 1px solid var(--l1-border);
background: var(--l3-background);
}
.overviewSettingsFooter {
display: flex;
justify-content: space-between;
align-items: center;
width: -webkit-fill-available;
padding: 12px 16px 12px 0px;
position: fixed;
bottom: 0;
height: 32px;
border-top: 1px solid var(--l1-border);
background: var(--l2-background);
}
.unsaved {
display: flex;
align-items: center;
gap: 8px;
}
.unsavedDot {
width: 6px;
height: 6px;
border-radius: 50px;
background: var(--primary-background);
box-shadow: 0px 0px 6px 0px
color-mix(in srgb, var(--primary-background) 40%, transparent);
}
.unsavedChanges {
color: var(--bg-robin-400);
font-family: Inter;
font-size: 14px;
font-style: normal;
font-weight: 400;
line-height: 24px;
letter-spacing: -0.07px;
}
.footerActionBtns {
display: flex;
gap: 8px;
}
.discardBtn {
margin: '16px 0';
color: var(--l1-foreground);
font-family: Inter;
font-size: 12px;
font-style: normal;
font-weight: 500;
line-height: 24px;
}
.saveBtn {
margin: 0px !important;
color: var(--l1-foreground);
font-family: Inter;
font-size: 12px;
font-style: normal;
font-weight: 500;
line-height: 24px;
}

View File

@@ -1,143 +0,0 @@
.overview-content {
display: flex;
flex-direction: column;
.overview-settings {
border-radius: 3px;
border: 1px solid var(--l1-border);
padding: 16px !important;
.name-icon-input {
display: flex;
.dashboard-image-input {
.ant-select-selector {
display: flex;
width: 32px;
height: 32px;
padding: 6px;
justify-content: center;
align-items: center;
border-radius: 2px 0px 0px 2px;
border: 1px solid var(--l1-border);
background: var(--l3-background);
.ant-select-selection-item {
display: flex;
align-items: center;
.list-item-image {
height: 16px;
width: 16px;
}
}
}
}
.dashboard-name-input {
border-radius: 0px 2px 2px 0px;
border: 1px solid var(--l1-border);
background: var(--l3-background);
}
}
.dashboard-name {
color: var(--l2-foreground);
font-family: Inter;
font-size: 14px;
font-style: normal;
font-weight: 400;
line-height: 20px; /* 142.857% */
}
.description-text-area {
padding: 6px 6px 6px 8px;
border-radius: 2px;
border: 1px solid var(--l1-border);
background: var(--l3-background);
}
}
.overview-settings-footer {
display: flex;
justify-content: space-between;
align-items: center;
width: -webkit-fill-available;
padding: 12px 16px 12px 0px;
position: fixed;
bottom: 0;
height: 32px;
border-top: 1px solid var(--l1-border);
background: var(--l2-background);
.unsaved {
display: flex;
align-items: center;
gap: 8px;
.unsaved-dot {
width: 6px;
height: 6px;
border-radius: 50px;
background: var(--primary-background);
box-shadow: 0px 0px 6px 0px
color-mix(in srgb, var(--primary-background) 40%, transparent);
}
.unsaved-changes {
color: var(--bg-robin-400);
font-family: Inter;
font-size: 14px;
font-style: normal;
font-weight: 400;
line-height: 24px; /* 171.429% */
letter-spacing: -0.07px;
}
}
.footer-action-btns {
display: flex;
gap: 8px;
.discard-btn {
margin: '16px 0';
color: var(--l1-foreground);
font-family: Inter;
font-size: 12px;
font-style: normal;
font-weight: 500;
line-height: 24px;
}
.save-btn {
margin: 0px !important;
color: var(--l1-foreground);
font-family: Inter;
font-size: 12px;
font-style: normal;
font-weight: 500;
line-height: 24px;
}
}
}
}
.dashboard-image-input {
&.ant-select-dropdown {
padding: 0px !important;
}
.ant-select-item {
padding: 0px;
align-items: center;
justify-content: center;
.ant-select-item-option-content {
display: flex;
align-items: center;
justify-content: center;
.list-item-image {
height: 16px;
width: 16px;
}
}
}
}

View File

@@ -1,16 +1,24 @@
import { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Col, Input, Select, Space, Typography } from 'antd';
import { Col, Input, Radio, Select, Space, Typography } from 'antd';
import AddTags from 'container/DashboardContainer/DashboardSettings/General/AddTags';
import { useDashboardCursorSyncMode } from 'hooks/dashboard/useDashboardCursorSyncMode';
import { useSyncTooltipFilterMode } from 'hooks/dashboard/useSyncTooltipFilterMode';
import { useUpdateDashboard } from 'hooks/dashboard/useUpdateDashboard';
import {
DashboardCursorSync,
SyncTooltipFilterMode,
} from 'lib/uPlotV2/plugins/TooltipPlugin/types';
import { isEqual } from 'lodash-es';
import { Check, X } from 'lucide-react';
import { useDashboardStore } from 'providers/Dashboard/store/useDashboardStore';
import styles from './GeneralSettings.module.scss';
import { Button } from './styles';
import { Base64Icons } from './utils';
import './GeneralSettings.styles.scss';
import logEvent from 'api/common/logEvent';
import { Events } from 'constants/events';
import { getAbsoluteUrl } from 'utils/basePath';
const { Option } = Select;
@@ -19,6 +27,13 @@ function GeneralDashboardSettings(): JSX.Element {
const updateDashboardMutation = useUpdateDashboard();
const [cursorSyncMode, setCursorSyncMode] = useDashboardCursorSyncMode(
dashboardData?.id,
);
const [syncTooltipFilterMode, setSyncTooltipFilterMode] =
useSyncTooltipFilterMode(dashboardData?.id);
const selectedData = dashboardData?.data;
const {
@@ -100,8 +115,8 @@ function GeneralDashboardSettings(): JSX.Element {
};
return (
<div className="overview-content">
<Col className="overview-settings">
<div className={styles.overviewContent}>
<Col className={styles.overviewSettings}>
<Space
direction="vertical"
style={{
@@ -112,27 +127,29 @@ function GeneralDashboardSettings(): JSX.Element {
}}
>
<div>
<Typography style={{ marginBottom: '0.5rem' }} className="dashboard-name">
Dashboard Name
</Typography>
<section className="name-icon-input">
<Typography className={styles.dashboardName}>Dashboard Name</Typography>
<section className={styles.nameIconInput}>
<Select
defaultActiveFirstOption
data-testid="dashboard-image"
suffixIcon={null}
rootClassName="dashboard-image-input"
rootClassName={styles.dashboardImageInput}
value={updatedImage}
onChange={(value: string): void => setUpdatedImage(value)}
>
{Base64Icons.map((icon) => (
<Option value={icon} key={icon}>
<img src={icon} alt="dashboard-icon" className="list-item-image" />
<img
src={icon}
alt="dashboard-icon"
className={styles.listItemImage}
/>
</Option>
))}
</Select>
<Input
data-testid="dashboard-name"
className="dashboard-name-input"
className={styles.dashboardNameInput}
value={updatedTitle}
onChange={(e): void => setUpdatedTitle(e.target.value)}
/>
@@ -140,41 +157,92 @@ function GeneralDashboardSettings(): JSX.Element {
</div>
<div>
<Typography style={{ marginBottom: '0.5rem' }} className="dashboard-name">
Description
</Typography>
<Typography className={styles.dashboardName}>Description</Typography>
<Input.TextArea
data-testid="dashboard-desc"
rows={6}
value={updatedDescription}
className="description-text-area"
className={styles.descriptionTextArea}
onChange={(e): void => setUpdatedDescription(e.target.value)}
/>
</div>
<div>
<Typography style={{ marginBottom: '0.5rem' }} className="dashboard-name">
Tags
</Typography>
<Typography className={styles.dashboardName}>Tags</Typography>
<AddTags tags={updatedTags} setTags={setUpdatedTags} />
</div>
</Space>
</Col>
<Col className={`${styles.overviewSettings} ${styles.crossPanelSyncGroup}`}>
<Typography.Text className={styles.crossPanelSyncSectionTitle}>
Cross-Panel Sync
</Typography.Text>
<div className={styles.crossPanelSyncRow}>
<div className={styles.crossPanelSyncInfo}>
<Typography.Text className={styles.crossPanelSyncTitle}>
Sync Mode
</Typography.Text>
<Typography.Text className={styles.crossPanelSyncDescription}>
Sync crosshair and tooltip across all the dashboard panels
</Typography.Text>
</div>
<Radio.Group
value={cursorSyncMode}
onChange={(e): void => {
setCursorSyncMode(e.target.value as DashboardCursorSync);
}}
>
<Radio.Button value={DashboardCursorSync.None}>No Sync</Radio.Button>
<Radio.Button value={DashboardCursorSync.Crosshair}>
Crosshair
</Radio.Button>
<Radio.Button value={DashboardCursorSync.Tooltip}>Tooltip</Radio.Button>
</Radio.Group>
</div>
{cursorSyncMode === DashboardCursorSync.Tooltip && (
<div className={styles.crossPanelSyncRow}>
<div className={styles.crossPanelSyncInfo}>
<Typography.Text className={styles.crossPanelSyncTitle}>
Synced Tooltip Series
</Typography.Text>
<Typography.Text className={styles.crossPanelSyncDescription}>
Show only series that intersect on group-by, or every series with the
matching ones highlighted
</Typography.Text>
</div>
<Radio.Group
value={syncTooltipFilterMode}
onChange={(e): void => {
logEvent(Events.TOOLTIP_SYNC_MODE_CHANGED, {
path: getAbsoluteUrl(window.location.pathname),
mode: e.target.value,
});
setSyncTooltipFilterMode(e.target.value as SyncTooltipFilterMode);
}}
>
<Radio.Button value={SyncTooltipFilterMode.All}>All</Radio.Button>
<Radio.Button value={SyncTooltipFilterMode.Filtered}>
Filtered
</Radio.Button>
</Radio.Group>
</div>
)}
</Col>
{numberOfUnsavedChanges > 0 && (
<div className="overview-settings-footer">
<div className="unsaved">
<div className="unsaved-dot" />
<Typography.Text className="unsaved-changes">
<div className={styles.overviewSettingsFooter}>
<div className={styles.unsaved}>
<div className={styles.unsavedDot} />
<Typography.Text className={styles.unsavedChanges}>
{numberOfUnsavedChanges} unsaved change
{numberOfUnsavedChanges > 1 && 's'}
</Typography.Text>
</div>
<div className="footer-action-btns">
<div className={styles.footerActionBtns}>
<Button
disabled={updateDashboardMutation.isLoading}
icon={<X size={14} />}
onClick={discardHandler}
type="text"
className="discard-btn"
className={styles.discardBtn}
>
Discard
</Button>
@@ -188,7 +256,7 @@ function GeneralDashboardSettings(): JSX.Element {
data-testid="save-dashboard-config"
onClick={onSaveHandler}
type="primary"
className="save-btn"
className={styles.saveBtn}
>
{t('save')}
</Button>

View File

@@ -33,11 +33,13 @@ export default function BarChart(props: BarChartProps): JSX.Element {
}
const tooltipProps: BarTooltipProps = {
...props,
id: config.getId(),
timezone: rest.timezone,
yAxisUnit: rest.yAxisUnit,
decimalPrecision: rest.decimalPrecision,
isStackedBarChart: isStackedBarChart,
canPinTooltip: rest.canPinTooltip,
renderTooltipFooter: rest.renderTooltipFooter,
};
return <BarChartTooltip {...tooltipProps} />;
},
@@ -48,6 +50,7 @@ export default function BarChart(props: BarChartProps): JSX.Element {
rest.decimalPrecision,
isStackedBarChart,
rest.canPinTooltip,
rest.renderTooltipFooter,
],
);

View File

@@ -29,11 +29,12 @@ export default function ChartWrapper({
onClick,
syncMode,
syncKey,
syncFilterMode,
onDestroy = noop,
children,
layoutChildren,
yAxisUnit,
groupBy,
groupByPerQuery,
customTooltip,
pinnedTooltipElement,
'data-testid': testId,
@@ -69,9 +70,10 @@ export default function ChartWrapper({
const syncMetadata = useMemo(
() => ({
yAxisUnit,
groupBy,
groupByPerQuery,
filterMode: syncFilterMode,
}),
[yAxisUnit, groupBy],
[yAxisUnit, groupByPerQuery, syncFilterMode],
);
return (

View File

@@ -24,13 +24,21 @@ export default function Histogram(props: HistogramChartProps): JSX.Element {
}
const tooltipProps: HistogramTooltipProps = {
...props,
id: rest.config.getId(),
yAxisUnit: rest.yAxisUnit,
decimalPrecision: rest.decimalPrecision,
canPinTooltip: rest.canPinTooltip,
renderTooltipFooter: rest.renderTooltipFooter,
};
return <HistogramTooltip {...tooltipProps} />;
},
[customTooltip, rest.yAxisUnit, rest.decimalPrecision, rest.canPinTooltip],
[
customTooltip,
rest.yAxisUnit,
rest.decimalPrecision,
rest.canPinTooltip,
rest.renderTooltipFooter,
],
);
return (

View File

@@ -9,7 +9,7 @@ import {
import { TimeSeriesChartProps } from '../types';
export default function TimeSeries(props: TimeSeriesChartProps): JSX.Element {
const { children, customTooltip, pinnedTooltipElement, ...rest } = props;
const { children, customTooltip, ...rest } = props;
const renderTooltip = useCallback(
(props: TooltipRenderArgs): React.ReactNode => {
@@ -18,10 +18,12 @@ export default function TimeSeries(props: TimeSeriesChartProps): JSX.Element {
}
const tooltipProps: TimeSeriesTooltipProps = {
...props,
id: rest.config.getId(),
timezone: rest.timezone,
yAxisUnit: rest.yAxisUnit,
decimalPrecision: rest.decimalPrecision,
canPinTooltip: rest.canPinTooltip,
renderTooltipFooter: rest.renderTooltipFooter,
};
return <TimeSeriesTooltip {...tooltipProps} />;
},
@@ -31,15 +33,12 @@ export default function TimeSeries(props: TimeSeriesChartProps): JSX.Element {
rest.yAxisUnit,
rest.decimalPrecision,
rest.canPinTooltip,
rest.renderTooltipFooter,
],
);
return (
<ChartWrapper
{...rest}
customTooltip={renderTooltip}
pinnedTooltipElement={pinnedTooltipElement}
>
<ChartWrapper {...rest} customTooltip={renderTooltip}>
{children}
</ChartWrapper>
);

View File

@@ -1,9 +1,14 @@
import { Timezone } from 'components/CustomTimePicker/timezoneUtils';
import { PrecisionOption } from 'components/Graph/types';
import { LegendConfig, TooltipRenderArgs } from 'lib/uPlotV2/components/types';
import {
IRenderTooltipFooterArgs,
LegendConfig,
TooltipRenderArgs,
} from 'lib/uPlotV2/components/types';
import { UPlotConfigBuilder } from 'lib/uPlotV2/config/UPlotConfigBuilder';
import {
DashboardCursorSync,
SyncTooltipFilterMode,
TooltipClickData,
} from 'lib/uPlotV2/plugins/TooltipPlugin/types';
import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
@@ -21,6 +26,7 @@ interface BaseChartProps {
yAxisUnit?: string;
decimalPrecision?: PrecisionOption;
pinnedTooltipElement?: (clickData: TooltipClickData) => React.ReactNode;
renderTooltipFooter?: (args: IRenderTooltipFooterArgs) => React.ReactNode;
customTooltip?: (props: TooltipRenderArgs) => React.ReactNode;
'data-testid'?: string;
}
@@ -30,6 +36,7 @@ interface UPlotBasedChartProps {
legendConfig: LegendConfig;
syncMode?: DashboardCursorSync;
syncKey?: string;
syncFilterMode?: SyncTooltipFilterMode;
plotRef?: (plot: uPlot | null) => void;
onDestroy?: (plot: uPlot) => void;
children?: React.ReactNode;
@@ -39,7 +46,7 @@ interface UPlotBasedChartProps {
interface UPlotChartDataProps {
yAxisUnit?: string;
decimalPrecision?: PrecisionOption;
groupBy?: BaseAutocompleteData[];
groupByPerQuery?: Record<string, BaseAutocompleteData[]>;
}
export interface TimeSeriesChartProps

View File

@@ -1,9 +1,15 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { PanelWrapperProps } from 'container/PanelWrapper/panelWrapper.types';
import { useDashboardCursorSyncMode } from 'hooks/dashboard/useDashboardCursorSyncMode';
import { useSyncTooltipFilterMode } from 'hooks/dashboard/useSyncTooltipFilterMode';
import { useIsDarkMode } from 'hooks/useDarkMode';
import { useResizeObserver } from 'hooks/useDimensions';
import { LegendPosition } from 'lib/uPlotV2/components/types';
import {
IRenderTooltipFooterArgs,
LegendPosition,
} from 'lib/uPlotV2/components/types';
import ContextMenu from 'periscope/components/ContextMenu';
import { useDashboardStore } from 'providers/Dashboard/store/useDashboardStore';
import { useTimezone } from 'providers/Timezone';
import uPlot from 'uplot';
import { getTimeRange } from 'utils/getTimeRange';
@@ -14,7 +20,7 @@ import { usePanelContextMenu } from '../../hooks/usePanelContextMenu';
import { prepareBarPanelConfig, prepareBarPanelData } from './utils';
import '../Panel.styles.scss';
import get from 'lodash/get';
import TooltipFooter from '../components/TooltipFooter';
function BarPanel(props: PanelWrapperProps): JSX.Element {
const {
@@ -24,6 +30,7 @@ function BarPanel(props: PanelWrapperProps): JSX.Element {
onDragSelect,
isFullViewMode,
onToggleModelHandler,
groupByPerQuery,
} = props;
const uPlotRef = useRef<uPlot | null>(null);
const graphRef = useRef<HTMLDivElement>(null);
@@ -34,6 +41,10 @@ function BarPanel(props: PanelWrapperProps): JSX.Element {
const isDarkMode = useIsDarkMode();
const { timezone } = useTimezone();
const dashboardId = useDashboardStore((s) => s.dashboardData?.id);
const [syncMode] = useDashboardCursorSyncMode(dashboardId, panelMode);
const [syncFilterMode] = useSyncTooltipFilterMode(dashboardId);
useEffect((): void => {
const { startTime, endTime } = getTimeRange(queryResponse);
@@ -75,6 +86,11 @@ function BarPanel(props: PanelWrapperProps): JSX.Element {
maxTimeScale,
timezone,
panelMode,
// `config` gets mutated by TooltipPlugin (config.setCursor for cursor sync).
// Rebuild it on syncMode changes so the new chart instance starts from a
// clean config — otherwise switching to "No Sync" would inherit stale sync
// settings from the previous mode.
syncMode,
]);
const chartData = useMemo(() => {
@@ -114,14 +130,20 @@ function BarPanel(props: PanelWrapperProps): JSX.Element {
uPlotRef.current = plot;
}, []);
const groupBy = useMemo(() => {
return get(widget, 'query.builder.queryData[0].groupBy', []);
}, [widget.query]);
const renderTooltipFooter = useCallback(
({ isPinned, dismiss }: IRenderTooltipFooterArgs) => {
return (
<TooltipFooter id={widget.id} isPinned={isPinned} dismiss={dismiss} />
);
},
[],
);
return (
<div className="panel-container" ref={graphRef}>
{containerDimensions.width > 0 && containerDimensions.height > 0 && (
<BarChart
key={`${syncMode}-${syncFilterMode}`}
config={config}
legendConfig={{
position: widget?.legendPosition ?? LegendPosition.BOTTOM,
@@ -133,11 +155,14 @@ function BarPanel(props: PanelWrapperProps): JSX.Element {
width={containerDimensions.width}
height={containerDimensions.height}
layoutChildren={layoutChildren}
groupBy={groupBy}
groupByPerQuery={groupByPerQuery}
isStackedBarChart={widget.stackedBarChart ?? false}
yAxisUnit={widget.yAxisUnit}
decimalPrecision={widget.decimalPrecision}
timezone={timezone}
syncMode={syncMode}
syncFilterMode={syncFilterMode}
renderTooltipFooter={renderTooltipFooter}
>
<ContextMenu
coordinates={coordinates}

View File

@@ -1,8 +1,11 @@
import { useMemo, useRef } from 'react';
import { useCallback, useMemo, useRef } from 'react';
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 {
IRenderTooltipFooterArgs,
LegendPosition,
} from 'lib/uPlotV2/components/types';
import uPlot from 'uplot';
import Histogram from '../../charts/Histogram/Histogram';
@@ -13,6 +16,7 @@ import {
} from './utils';
import '../Panel.styles.scss';
import TooltipFooter from '../components/TooltipFooter';
function HistogramPanel(props: PanelWrapperProps): JSX.Element {
const {
@@ -75,6 +79,20 @@ function HistogramPanel(props: PanelWrapperProps): JSX.Element {
widget.mergeAllActiveQueries,
]);
const renderTooltipFooter = useCallback(
({ isPinned, dismiss }: IRenderTooltipFooterArgs) => {
return (
<TooltipFooter
id={widget.id}
isPinned={isPinned}
dismiss={dismiss}
canDrilldown={false}
/>
);
},
[],
);
return (
<div className="panel-container" ref={graphRef}>
{containerDimensions.width > 0 && containerDimensions.height > 0 && (
@@ -97,6 +115,7 @@ function HistogramPanel(props: PanelWrapperProps): JSX.Element {
width={containerDimensions.width}
height={containerDimensions.height}
layoutChildren={layoutChildren}
renderTooltipFooter={renderTooltipFooter}
/>
)}
</div>

View File

@@ -1,20 +1,26 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import { useCallback, 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 { PanelWrapperProps } from 'container/PanelWrapper/panelWrapper.types';
import { useDashboardCursorSyncMode } from 'hooks/dashboard/useDashboardCursorSyncMode';
import { useSyncTooltipFilterMode } from 'hooks/dashboard/useSyncTooltipFilterMode';
import { useIsDarkMode } from 'hooks/useDarkMode';
import { useResizeObserver } from 'hooks/useDimensions';
import { LegendPosition } from 'lib/uPlotV2/components/types';
import {
IRenderTooltipFooterArgs,
LegendPosition,
} from 'lib/uPlotV2/components/types';
import { ContextMenu } from 'periscope/components/ContextMenu';
import { useDashboardStore } from 'providers/Dashboard/store/useDashboardStore';
import { useTimezone } from 'providers/Timezone';
import uPlot from 'uplot';
import { getTimeRange } from 'utils/getTimeRange';
import get from 'lodash/get';
import { prepareChartData, prepareUPlotConfig } from '../TimeSeriesPanel/utils';
import '../Panel.styles.scss';
import TooltipFooter from '../components/TooltipFooter';
function TimeSeriesPanel(props: PanelWrapperProps): JSX.Element {
const {
@@ -24,6 +30,7 @@ function TimeSeriesPanel(props: PanelWrapperProps): JSX.Element {
onDragSelect,
isFullViewMode,
onToggleModelHandler,
groupByPerQuery,
} = props;
const graphRef = useRef<HTMLDivElement>(null);
const [minTimeScale, setMinTimeScale] = useState<number>();
@@ -33,6 +40,10 @@ function TimeSeriesPanel(props: PanelWrapperProps): JSX.Element {
const isDarkMode = useIsDarkMode();
const { timezone } = useTimezone();
const dashboardId = useDashboardStore((s) => s.dashboardData?.id);
const [syncMode] = useDashboardCursorSyncMode(dashboardId, panelMode);
const [syncFilterMode] = useSyncTooltipFilterMode(dashboardId);
useEffect((): void => {
const { startTime, endTime } = getTimeRange(queryResponse);
@@ -81,6 +92,11 @@ function TimeSeriesPanel(props: PanelWrapperProps): JSX.Element {
minTimeScale,
maxTimeScale,
timezone,
// `config` gets mutated by TooltipPlugin (config.setCursor for cursor sync).
// Rebuild it on syncMode changes so the new chart instance starts from a
// clean config — otherwise switching to "No Sync" would inherit stale sync
// settings from the previous mode.
syncMode,
]);
const layoutChildren = useMemo(() => {
@@ -105,14 +121,20 @@ function TimeSeriesPanel(props: PanelWrapperProps): JSX.Element {
widget.decimalPrecision,
]);
const groupBy = useMemo(() => {
return get(widget, 'query.builder.queryData[0].groupBy', []);
}, [widget.query]);
const renderTooltipFooter = useCallback(
({ isPinned, dismiss }: IRenderTooltipFooterArgs) => {
return (
<TooltipFooter id={widget.id} isPinned={isPinned} dismiss={dismiss} />
);
},
[],
);
return (
<div className="panel-container" ref={graphRef}>
{containerDimensions.width > 0 && containerDimensions.height > 0 && (
<TimeSeries
key={`${syncMode}-${syncFilterMode}`}
config={config}
legendConfig={{
position: widget?.legendPosition ?? LegendPosition.BOTTOM,
@@ -122,10 +144,13 @@ function TimeSeriesPanel(props: PanelWrapperProps): JSX.Element {
yAxisUnit={widget.yAxisUnit}
decimalPrecision={widget.decimalPrecision}
data={chartData as uPlot.AlignedData}
groupBy={groupBy}
groupByPerQuery={groupByPerQuery}
width={containerDimensions.width}
height={containerDimensions.height}
syncMode={syncMode}
syncFilterMode={syncFilterMode}
layoutChildren={layoutChildren}
renderTooltipFooter={renderTooltipFooter}
>
<ContextMenu
coordinates={coordinates}

View File

@@ -7,22 +7,25 @@ import Styles from './TooltipFooter.module.scss';
import { MousePointerClick } from '@signozhq/icons';
import logEvent from 'api/common/logEvent';
import { Events } from 'constants/events';
import { getAbsoluteUrl } from 'utils/basePath';
interface TooltipFooterProps {
id: string;
pinKey?: string;
isPinned: boolean;
canDrilldown?: boolean;
dismiss: () => void;
}
export default function TooltipFooter({
id,
pinKey = DEFAULT_PIN_TOOLTIP_KEY,
isPinned,
canDrilldown = true,
dismiss,
}: TooltipFooterProps): JSX.Element {
const handleUnpinClick = (): void => {
logEvent(Events.TOOLTIP_UNPINNED, {
path: getAbsoluteUrl(window.location.pathname),
id: id,
});
dismiss();
};
@@ -43,12 +46,14 @@ export default function TooltipFooter({
</div>
) : (
<div className={Styles.hintList}>
<div className={Styles.hint} data-active="false">
<Kbd>
<MousePointerClick size={12} />
</Kbd>
<span>Click to drilldown</span>
</div>
{canDrilldown && (
<div className={Styles.hint} data-active="false">
<Kbd>
<MousePointerClick size={12} />
</Kbd>
<span>Click to drilldown</span>
</div>
)}
<div className={Styles.hint} data-active="false">
<span>Press</span>
<Kbd>{pinKey.toUpperCase()}</Kbd>

View File

@@ -0,0 +1,93 @@
import { Events } from 'constants/events';
import { DEFAULT_PIN_TOOLTIP_KEY } from 'lib/uPlotV2/plugins/TooltipPlugin/types';
import { render, screen, userEvent } from 'tests/test-utils';
import TooltipFooter from '../TooltipFooter';
const mockLogEvent = jest.fn();
jest.mock('api/common/logEvent', () => ({
__esModule: true,
default: (...args: unknown[]): unknown => mockLogEvent(...args),
}));
describe('TooltipFooter', () => {
const defaultProps = {
id: 'panel-123',
isPinned: false,
dismiss: jest.fn(),
};
describe('when not pinned', () => {
it('renders the drilldown and pin hints by default', () => {
render(<TooltipFooter {...defaultProps} />);
expect(screen.getByText('Click to drilldown')).toBeInTheDocument();
expect(screen.getByText('to pin the tooltip')).toBeInTheDocument();
expect(
screen.getByText(DEFAULT_PIN_TOOLTIP_KEY.toUpperCase()),
).toBeInTheDocument();
});
it('hides the drilldown hint when canDrilldown is false', () => {
render(<TooltipFooter {...defaultProps} canDrilldown={false} />);
expect(screen.queryByText('Click to drilldown')).not.toBeInTheDocument();
expect(screen.getByText('to pin the tooltip')).toBeInTheDocument();
});
it('renders a custom pin key in uppercase', () => {
render(<TooltipFooter {...defaultProps} pinKey="x" />);
expect(screen.getByText('X')).toBeInTheDocument();
});
it('does not render the unpin button', () => {
render(<TooltipFooter {...defaultProps} />);
expect(screen.queryByTestId('uplot-tooltip-unpin')).not.toBeInTheDocument();
});
});
describe('when pinned', () => {
it('renders the unpin hint with pin key and Esc', () => {
render(<TooltipFooter {...defaultProps} isPinned />);
expect(screen.getByText('to unpin')).toBeInTheDocument();
expect(
screen.getByText(DEFAULT_PIN_TOOLTIP_KEY.toUpperCase()),
).toBeInTheDocument();
expect(screen.getByText('Esc')).toBeInTheDocument();
});
it('renders the unpin button', () => {
render(<TooltipFooter {...defaultProps} isPinned />);
expect(screen.getByTestId('uplot-tooltip-unpin')).toBeInTheDocument();
expect(
screen.getByRole('button', { name: /unpin tooltip/i }),
).toBeInTheDocument();
});
it('hides the drilldown and pin-instruction hints', () => {
render(<TooltipFooter {...defaultProps} isPinned />);
expect(screen.queryByText('Click to drilldown')).not.toBeInTheDocument();
expect(screen.queryByText('to pin the tooltip')).not.toBeInTheDocument();
});
it('calls dismiss and logs the unpin event when the unpin button is clicked', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
const dismiss = jest.fn();
render(<TooltipFooter {...defaultProps} dismiss={dismiss} isPinned />);
await user.click(screen.getByTestId('uplot-tooltip-unpin'));
expect(mockLogEvent).toHaveBeenCalledWith(Events.TOOLTIP_UNPINNED, {
id: 'panel-123',
});
expect(dismiss).toHaveBeenCalledTimes(1);
});
});
});

View File

@@ -3,7 +3,7 @@ import React, { useCallback, useEffect, useState } from 'react';
import { useMutation, useQuery } from 'react-query';
import { Color } from '@signozhq/design-tokens';
import { Compass, Dot, House, Plus, Wrench } from '@signozhq/icons';
import { Button, PersistedAnnouncementBanner } from '@signozhq/ui';
import { Button } from '@signozhq/ui';
import { Popover } from 'antd';
import logEvent from 'api/common/logEvent';
import { useGetMetricsOnboardingStatus } from 'api/generated/services/metrics';
@@ -11,7 +11,6 @@ import listUserPreferences from 'api/v1/user/preferences/list';
import updateUserPreferenceAPI from 'api/v1/user/preferences/name/update';
import Header from 'components/Header/Header';
import { ENTITY_VERSION_V5 } from 'constants/app';
import { LOCALSTORAGE } from 'constants/localStorage';
import { ORG_PREFERENCES } from 'constants/orgPreferences';
import { initialQueriesMap, PANEL_TYPES } from 'constants/queryBuilder';
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
@@ -271,23 +270,6 @@ export default function Home(): JSX.Element {
return (
<div className="home-container">
{user?.role === USER_ROLES.ADMIN && (
<PersistedAnnouncementBanner
type="info"
storageKey={LOCALSTORAGE.DISMISSED_API_KEYS_DEPRECATION_BANNER}
action={{
label: 'Go to Service Accounts',
onClick: (): void => history.push(ROUTES.SERVICE_ACCOUNTS_SETTINGS),
}}
>
<>
<strong>API keys</strong> have been deprecated in favour of{' '}
<strong>Service accounts</strong>. The existing API Keys have been
migrated to service accounts.
</>
</PersistedAnnouncementBanner>
)}
<div className="sticky-header">
<Header
leftComponent={

View File

@@ -60,7 +60,7 @@ function CreateOrEdit(props: CreateOrEditProps): JSX.Element {
const [form] = Form.useForm<FormValues>();
const [authnProvider, setAuthnProvider] = useState<
AuthtypesAuthNProviderDTO | ''
>(record?.ssoType || '');
>(record?.config?.ssoType || '');
const { showErrorModal } = useErrorModal();
const { featureFlags } = useAppContext();

View File

@@ -112,21 +112,26 @@ export function prepareInitialValues(
};
}
const config = record.config ?? {};
return {
...record,
googleAuthConfig: record.googleAuthConfig
name: record.name,
ssoEnabled: config.ssoEnabled,
ssoType: config.ssoType,
samlConfig: config.samlConfig ?? undefined,
oidcConfig: config.oidcConfig ?? undefined,
googleAuthConfig: config.googleAuthConfig
? {
...record.googleAuthConfig,
...config.googleAuthConfig,
domainToAdminEmailList: convertDomainMappingsToList(
record.googleAuthConfig.domainToAdminEmail,
config.googleAuthConfig.domainToAdminEmail,
),
}
: undefined,
roleMapping: record.roleMapping
roleMapping: config.roleMapping
? {
...record.roleMapping,
...config.roleMapping,
groupMappingsList: convertGroupMappingsToList(
record.roleMapping.groupMappings,
config.roleMapping.groupMappings,
),
}
: undefined,

View File

@@ -43,11 +43,11 @@ function SSOEnforcementToggle({
data: {
config: {
ssoEnabled: checked,
ssoType: record.ssoType,
googleAuthConfig: record.googleAuthConfig,
oidcConfig: record.oidcConfig,
samlConfig: record.samlConfig,
roleMapping: record.roleMapping,
ssoType: record.config?.ssoType,
googleAuthConfig: record.config?.googleAuthConfig,
oidcConfig: record.config?.oidcConfig,
samlConfig: record.config?.samlConfig,
roleMapping: record.config?.roleMapping,
},
},
},

View File

@@ -55,7 +55,10 @@ describe('SSOEnforcementToggle', () => {
render(
<SSOEnforcementToggle
isDefaultChecked={false}
record={{ ...mockGoogleAuthDomain, ssoEnabled: false }}
record={{
...mockGoogleAuthDomain,
config: { ...mockGoogleAuthDomain.config, ssoEnabled: false },
}}
/>,
);

View File

@@ -13,11 +13,13 @@ export const AUTH_DOMAINS_DELETE_ENDPOINT = '*/api/v1/domains/:id';
export const mockGoogleAuthDomain: AuthtypesGettableAuthDomainDTO = {
id: 'domain-1',
name: 'signoz.io',
ssoEnabled: true,
ssoType: AuthtypesAuthNProviderDTO.google_auth,
googleAuthConfig: {
clientId: 'test-client-id',
clientSecret: 'test-client-secret',
config: {
ssoEnabled: true,
ssoType: AuthtypesAuthNProviderDTO.google_auth,
googleAuthConfig: {
clientId: 'test-client-id',
clientSecret: 'test-client-secret',
},
},
authNProviderInfo: {
relayStatePath: 'api/v1/sso/relay/domain-1',
@@ -28,12 +30,14 @@ export const mockGoogleAuthDomain: AuthtypesGettableAuthDomainDTO = {
export const mockSamlAuthDomain: AuthtypesGettableAuthDomainDTO = {
id: 'domain-2',
name: 'example.com',
ssoEnabled: false,
ssoType: AuthtypesAuthNProviderDTO.saml,
samlConfig: {
samlIdp: 'https://idp.example.com/sso',
samlEntity: 'urn:example:idp',
samlCert: 'MOCK_CERTIFICATE',
config: {
ssoEnabled: false,
ssoType: AuthtypesAuthNProviderDTO.saml,
samlConfig: {
samlIdp: 'https://idp.example.com/sso',
samlEntity: 'urn:example:idp',
samlCert: 'MOCK_CERTIFICATE',
},
},
authNProviderInfo: {
relayStatePath: 'api/v1/sso/relay/domain-2',
@@ -44,12 +48,14 @@ export const mockSamlAuthDomain: AuthtypesGettableAuthDomainDTO = {
export const mockOidcAuthDomain: AuthtypesGettableAuthDomainDTO = {
id: 'domain-3',
name: 'corp.io',
ssoEnabled: true,
ssoType: AuthtypesAuthNProviderDTO.oidc,
oidcConfig: {
issuer: 'https://oidc.corp.io',
clientId: 'oidc-client-id',
clientSecret: 'oidc-client-secret',
config: {
ssoEnabled: true,
ssoType: AuthtypesAuthNProviderDTO.oidc,
oidcConfig: {
issuer: 'https://oidc.corp.io',
clientId: 'oidc-client-id',
clientSecret: 'oidc-client-secret',
},
},
authNProviderInfo: {
relayStatePath: 'api/v1/sso/relay/domain-3',
@@ -60,20 +66,22 @@ export const mockOidcAuthDomain: AuthtypesGettableAuthDomainDTO = {
export const mockDomainWithRoleMapping: AuthtypesGettableAuthDomainDTO = {
id: 'domain-4',
name: 'enterprise.com',
ssoEnabled: true,
ssoType: AuthtypesAuthNProviderDTO.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',
config: {
ssoEnabled: true,
ssoType: AuthtypesAuthNProviderDTO.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: {
@@ -86,16 +94,18 @@ export const mockDomainWithDirectRoleAttribute: AuthtypesGettableAuthDomainDTO =
{
id: 'domain-5',
name: 'direct-role.com',
ssoEnabled: true,
ssoType: AuthtypesAuthNProviderDTO.oidc,
oidcConfig: {
issuer: 'https://oidc.direct-role.com',
clientId: 'direct-role-client-id',
clientSecret: 'direct-role-client-secret',
},
roleMapping: {
defaultRole: 'VIEWER',
useRoleAttribute: true,
config: {
ssoEnabled: true,
ssoType: AuthtypesAuthNProviderDTO.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',
@@ -106,20 +116,22 @@ export const mockDomainWithDirectRoleAttribute: AuthtypesGettableAuthDomainDTO =
export const mockOidcWithClaimMapping: AuthtypesGettableAuthDomainDTO = {
id: 'domain-6',
name: 'oidc-claims.com',
ssoEnabled: true,
ssoType: AuthtypesAuthNProviderDTO.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',
config: {
ssoEnabled: true,
ssoType: AuthtypesAuthNProviderDTO.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: {
@@ -131,17 +143,19 @@ export const mockOidcWithClaimMapping: AuthtypesGettableAuthDomainDTO = {
export const mockSamlWithAttributeMapping: AuthtypesGettableAuthDomainDTO = {
id: 'domain-7',
name: 'saml-attrs.com',
ssoEnabled: true,
ssoType: AuthtypesAuthNProviderDTO.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',
config: {
ssoEnabled: true,
ssoType: AuthtypesAuthNProviderDTO.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: {
@@ -154,19 +168,21 @@ export const mockGoogleAuthWithWorkspaceGroups: AuthtypesGettableAuthDomainDTO =
{
id: 'domain-8',
name: 'google-groups.com',
ssoEnabled: true,
ssoType: AuthtypesAuthNProviderDTO.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',
config: {
ssoEnabled: true,
ssoType: AuthtypesAuthNProviderDTO.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'],
},
fetchTransitiveGroupMembership: true,
allowedGroups: ['allowed-group-1', 'allowed-group-2'],
},
authNProviderInfo: {
relayStatePath: 'api/v1/sso/relay/domain-8',
@@ -191,15 +207,19 @@ export const mockSingleDomainResponse = {
data: [mockGoogleAuthDomain],
};
// Mock success responses
// Mock success responses. CreateAuthDomain returns just an Identifiable
// (the new domain ID); clients re-Read to get the full domain.
export const mockCreateSuccessResponse = {
status: 'success',
data: mockGoogleAuthDomain,
data: { id: mockGoogleAuthDomain.id },
};
export const mockUpdateSuccessResponse = {
status: 'success',
data: { ...mockGoogleAuthDomain, ssoEnabled: false },
data: {
...mockGoogleAuthDomain,
config: { ...mockGoogleAuthDomain.config, ssoEnabled: false },
},
};
export const mockDeleteSuccessResponse = {

View File

@@ -158,7 +158,7 @@ function AuthDomain(): JSX.Element {
onClick={(): void => setRecord(record)}
variant="link"
>
Configure {SSOType.get(record.ssoType || '')}
Configure {SSOType.get(record.config?.ssoType || '')}
</Button>
<Button
className="auth-domain-list-action-link delete"

View File

@@ -1,5 +1,6 @@
import { FC } from 'react';
import { FC, useMemo } from 'react';
import Spinner from 'components/Spinner';
import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
import { PanelTypeVsPanelWrapper } from './constants';
import { PanelWrapperProps } from './panelWrapper.types';
@@ -30,6 +31,20 @@ function PanelWrapper({
selectedGraph || widget.panelTypes
] as FC<PanelWrapperProps>;
const groupByPerQuery = useMemo<Record<string, BaseAutocompleteData[]>>(() => {
if (!widget.query.builder) {
return {};
}
const { queryData } = widget.query.builder;
return queryData.reduce<Record<string, BaseAutocompleteData[]>>(
(acc, query) => {
acc[query.queryName] = query.groupBy ?? [];
return acc;
},
{},
);
}, [widget]);
if (!Component) {
return <></>;
}
@@ -60,6 +75,7 @@ function PanelWrapper({
customSeries={customSeries}
enableDrillDown={enableDrillDown}
onColumnWidthsChange={onColumnWidthsChange}
groupByPerQuery={groupByPerQuery}
/>
);
}

View File

@@ -5,6 +5,7 @@ import { PanelMode } from 'container/DashboardContainer/visualization/panels/typ
import { WidgetGraphComponentProps } from 'container/GridCardLayout/GridCard/types';
import { RowData } from 'lib/query/createTableColumnsFromQuery';
import { OnClickPluginOpts } from 'lib/uPlotLib/plugins/onClickPlugin';
import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
import { Widgets } from 'types/api/dashboard/getAll';
import { MetricQueryRangeSuccessResponse } from 'types/api/metrics/getQueryRange';
import { QueryData } from 'types/api/widgets/getQuery';
@@ -30,6 +31,7 @@ export type PanelWrapperProps = {
enableDrillDown?: boolean;
panelMode: PanelMode;
onColumnWidthsChange?: (widths: Record<string, number>) => void;
groupByPerQuery?: Record<string, BaseAutocompleteData[]>;
};
export type TooltipData = {

View File

@@ -17,7 +17,6 @@ import { Search } from 'lucide-react';
import { useAppContext } from 'providers/App/App';
import { useErrorModal } from 'providers/ErrorModalProvider';
import { USER_ROLES } from 'types/roles';
import { DeepPartial } from 'utils/types';
import 'dayjs/locale/en';
@@ -25,7 +24,7 @@ import { PlannedDowntimeDeleteModal } from './PlannedDowntimeDeleteModal';
import { PlannedDowntimeForm } from './PlannedDowntimeForm';
import { PlannedDowntimeList } from './PlannedDowntimeList';
import {
defaultInitialValues,
defautlInitialValues,
deleteDowntimeHandler,
} from './PlannedDowntimeutils';
@@ -49,8 +48,8 @@ export function PlannedDowntime(): JSX.Element {
const urlQuery = useUrlQuery();
const [initialValues, setInitialValues] =
useState<DeepPartial<RuletypesPlannedMaintenanceDTO & { editMode: boolean }>>(
defaultInitialValues,
useState<Partial<RuletypesPlannedMaintenanceDTO & { editMode: boolean }>>(
defautlInitialValues,
);
const downtimeSchedules = useListDowntimeSchedules();
@@ -150,7 +149,7 @@ export function PlannedDowntime(): JSX.Element {
icon={<PlusOutlined />}
type="primary"
onClick={(): void => {
setInitialValues({ ...defaultInitialValues, editMode: false });
setInitialValues({ ...defautlInitialValues, editMode: false });
setIsOpen(true);
setEditMode(false);
form.resetFields();

View File

@@ -1,11 +1,5 @@
import React, {
ReactNode,
useCallback,
useEffect,
useMemo,
useState,
} from 'react';
import { CheckOutlined, InfoCircleOutlined } from '@ant-design/icons';
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { CheckOutlined } from '@ant-design/icons';
import {
Button,
DatePicker,
@@ -16,7 +10,6 @@ import {
Select,
SelectProps,
Spin,
Tooltip,
Typography,
} from 'antd';
import type { DefaultOptionType } from 'antd/es/select';
@@ -45,8 +38,6 @@ import { defaultTo, isEmpty } from 'lodash-es';
import { useErrorModal } from 'providers/ErrorModalProvider';
import APIError from 'types/api/error';
import { ALL_TIME_ZONES } from 'utils/timeZoneUtil';
import { type PanelMode } from 'rc-picker/lib/interface';
import { DeepPartial } from 'utils/types';
import 'dayjs/locale/en';
@@ -78,14 +69,14 @@ interface PlannedDowntimeFormData {
endTime: dayjs.Dayjs | string;
recurrence?: RuletypesRecurrenceDTO | null;
alertRules: DefaultOptionType[];
recurrenceSelect?: RuletypesRecurrenceDTO;
timezone?: string;
labelExpression?: string;
}
const customFormat = DATE_TIME_FORMATS.ORDINAL_DATETIME;
interface PlannedDowntimeFormProps {
initialValues: DeepPartial<
initialValues: Partial<
RuletypesPlannedMaintenanceDTO & {
editMode: boolean;
}
@@ -97,7 +88,7 @@ interface PlannedDowntimeFormProps {
setIsOpen: React.Dispatch<React.SetStateAction<boolean>>;
refetchAllSchedules: () => void;
isEditMode: boolean;
form: FormInstance;
form: FormInstance<any>;
}
export function PlannedDowntimeForm(
@@ -141,25 +132,26 @@ export function PlannedDowntimeForm(
const { notifications } = useNotifications();
const { showErrorModal } = useErrorModal();
const datePickerFooter = (mode: PanelMode): ReactNode =>
const datePickerFooter = (mode: any): any =>
mode === 'time' ? (
<span style={{ color: 'gray' }}>Please select the time</span>
) : null;
const saveHandler = useCallback(
const saveHanlder = useCallback(
async (values: PlannedDowntimeFormData) => {
const shouldKeepLocalTime = !isEditMode;
const data: RuletypesPostablePlannedMaintenanceDTO = {
alertIds: values.alertRules
.map((alert) => alert.value)
.filter((alert) => alert !== undefined) as string[],
name: values.name,
labelExpression: values.labelExpression,
schedule: {
startTime: new Date(
handleTimeConversion(
values.startTime,
timezoneInitialValue,
values.timezone,
shouldKeepLocalTime,
),
),
timezone: values.timezone as string,
@@ -169,6 +161,7 @@ export function PlannedDowntimeForm(
values.endTime,
timezoneInitialValue,
values.timezone,
shouldKeepLocalTime,
),
)
: undefined,
@@ -209,24 +202,38 @@ export function PlannedDowntimeForm(
],
);
const onFinish = async (values: PlannedDowntimeFormData): Promise<void> => {
const { recurrence } = values;
const recurrenceData =
!recurrence ||
recurrence.repeatType === recurrenceOptions.doesNotRepeat.value
values?.recurrence?.repeatType === recurrenceOptions.doesNotRepeat.value
? undefined
: {
duration: recurrence.duration
? `${recurrence.duration}${durationUnit}`
duration: values.recurrence?.duration
? `${values.recurrence?.duration}${durationUnit}`
: undefined,
repeatOn: recurrence.repeatOn?.length ? recurrence.repeatOn : undefined,
repeatType: recurrence.repeatType,
endTime: !isEmpty(values.endTime)
? handleTimeConversion(
values.endTime,
timezoneInitialValue,
values.timezone,
!isEditMode,
)
: undefined,
startTime: handleTimeConversion(
values.startTime,
timezoneInitialValue,
values.timezone,
!isEditMode,
),
repeatOn: !values.recurrence?.repeatOn?.length
? undefined
: values.recurrence?.repeatOn,
repeatType: values.recurrence?.repeatType,
};
const payloadValues = {
...values,
recurrence: recurrenceData as RuletypesRecurrenceDTO | undefined,
};
await saveHandler(payloadValues);
await saveHanlder(payloadValues);
};
const formValidationRules = [
@@ -279,7 +286,7 @@ export function PlannedDowntimeForm(
: '',
recurrence: {
...initialValues.schedule?.recurrence,
repeatType: (!isScheduleRecurring(initialValues.schedule)
repeatType: (!isScheduleRecurring(initialValues?.schedule)
? recurrenceOptions.doesNotRepeat.value
: initialValues.schedule?.recurrence
?.repeatType) as RuletypesRecurrenceDTO['repeatType'],
@@ -289,7 +296,6 @@ export function PlannedDowntimeForm(
),
} as RuletypesRecurrenceDTO,
timezone: initialValues.schedule?.timezone as string,
labelExpression: initialValues.labelExpression || '',
};
return formData;
}, [initialValues, alertOptions]);
@@ -310,6 +316,7 @@ export function PlannedDowntimeForm(
const getTimezoneFormattedTime = (
time: string | dayjs.Dayjs,
timeZone?: string,
isEditMode?: boolean,
format?: string,
): string => {
if (!time) {
@@ -318,11 +325,20 @@ export function PlannedDowntimeForm(
if (!timeZone) {
return dayjs(time).format(format);
}
return dayjs(time).tz(timeZone).format(format);
return dayjs(time).tz(timeZone, isEditMode).format(format);
};
const startTimeText = useMemo((): string => {
let startTime = formData.startTime;
let startTime = formData?.startTime;
if (recurrenceType !== recurrenceOptions.doesNotRepeat.value) {
startTime =
(formData?.recurrence?.startTime
? dayjs(formData.recurrence.startTime).toISOString()
: '') ||
formData?.startTime ||
'';
}
if (!startTime) {
return '';
}
@@ -332,6 +348,7 @@ export function PlannedDowntimeForm(
startTime,
timezoneInitialValue,
formData?.timezone,
!isEditMode,
);
}
const daysOfWeek = formData?.recurrence?.repeatOn;
@@ -339,16 +356,21 @@ export function PlannedDowntimeForm(
const formattedStartTime = getTimezoneFormattedTime(
startTime,
formData.timezone,
!isEditMode,
TIME_FORMAT,
);
const formattedStartDate = getTimezoneFormattedTime(
startTime,
formData.timezone,
!isEditMode,
DATE_FORMAT,
);
const ordinalFormat = getTimezoneFormattedTime(
startTime,
formData.timezone,
!isEditMode,
ORDINAL_FORMAT,
);
@@ -365,10 +387,21 @@ export function PlannedDowntimeForm(
default:
return `Scheduled for ${formattedStartDate} starting at ${formattedStartTime}.`;
}
}, [formData, recurrenceType, timezoneInitialValue]);
}, [formData, recurrenceType, isEditMode, timezoneInitialValue]);
const endTimeText = useMemo((): string => {
let endTime = formData?.endTime;
if (recurrenceType !== recurrenceOptions.doesNotRepeat.value) {
endTime =
(formData?.recurrence?.endTime
? dayjs(formData.recurrence.endTime).toISOString()
: '') || '';
if (!isEditMode && !endTime) {
endTime = formData?.endTime || '';
}
}
if (!endTime) {
return '';
}
@@ -378,21 +411,25 @@ export function PlannedDowntimeForm(
endTime,
timezoneInitialValue,
formData?.timezone,
!isEditMode,
);
}
const formattedEndTime = getTimezoneFormattedTime(
endTime,
formData.timezone,
!isEditMode,
TIME_FORMAT,
);
const formattedEndDate = getTimezoneFormattedTime(
endTime,
formData.timezone,
!isEditMode,
DATE_FORMAT,
);
return `Scheduled to end maintenance on ${formattedEndDate} at ${formattedEndTime}.`;
}, [formData, timezoneInitialValue]);
}, [formData, recurrenceType, isEditMode, timezoneInitialValue]);
return (
<Modal
@@ -427,7 +464,7 @@ export function PlannedDowntimeForm(
name="startTime"
rules={formValidationRules}
className={!isEmpty(startTimeText) ? 'formItemWithBullet' : ''}
getValueProps={(value) => ({
getValueProps={(value): any => ({
value: value ? dayjs(value).tz(timezoneInitialValue) : undefined,
})}
>
@@ -508,7 +545,7 @@ export function PlannedDowntimeForm(
},
]}
className={!isEmpty(endTimeText) ? 'formItemWithBullet' : ''}
getValueProps={(value) => ({
getValueProps={(value): any => ({
value: value ? dayjs(value).tz(timezoneInitialValue) : undefined,
})}
>
@@ -570,22 +607,6 @@ export function PlannedDowntimeForm(
</Select>
</Form.Item>
</div>
<Form.Item
label={
<span>
Label Expression&nbsp;
<Tooltip title='Filter by alert labels. Examples: env == "prod", region == "us-east-1" && severity == "critical"'>
<InfoCircleOutlined />
</Tooltip>
</span>
}
name="labelExpression"
>
<Input.TextArea
placeholder='e.g. env == "prod" && region == "us-east-1"'
autoSize={{ minRows: 2, maxRows: 4 }}
/>
</Form.Item>
<Form.Item style={{ marginBottom: 0 }}>
<ModalButtonWrapper>
<Button

View File

@@ -1,4 +1,4 @@
import React, { ReactNode, useEffect } from 'react';
import { ReactNode, useEffect } from 'react';
import { UseQueryResult } from 'react-query';
import { Color } from '@signozhq/design-tokens';
import {
@@ -26,9 +26,8 @@ import { defaultTo } from 'lodash-es';
import { CalendarClock, PenLine, Trash2 } from 'lucide-react';
import { useAppContext } from 'providers/App/App';
import { USER_ROLES } from 'types/roles';
import { showErrorNotification } from 'utils/error';
import { DeepPartial } from 'utils/types';
import { showErrorNotification } from '../../utils/error';
import {
formatDateTime,
getAlertOptionsFromIds,
@@ -36,6 +35,7 @@ import {
getEndTime,
recurrenceInfo,
} from './PlannedDowntimeutils';
import './PlannedDowntime.styles.scss';
const { Panel } = Collapse;
@@ -144,7 +144,7 @@ export function CollapseListContent({
created_at?: string;
created_by_name?: string;
created_by_email?: string;
timeframe: [string | undefined, string | undefined];
timeframe: [string | undefined | null, string | undefined | null];
repeats?: RuletypesRecurrenceDTO | null;
updated_at?: string;
updated_by_name?: string;
@@ -200,12 +200,7 @@ export function CollapseListContent({
),
)}
{renderItems('Timezone', <Typography>{timezone || '-'}</Typography>)}
{renderItems(
'Repeats',
<Typography>
{recurrenceInfo(timeframe[0], timeframe[1], repeats)}
</Typography>,
)}
{renderItems('Repeats', <Typography>{recurrenceInfo(repeats)}</Typography>)}
{renderItems(
'Alerts silenced',
alertOptions?.length ? (
@@ -225,7 +220,7 @@ export function CollapseListContent({
export function CustomCollapseList(
props: DowntimeSchedulesTableData & {
setInitialValues: React.Dispatch<
React.SetStateAction<DeepPartial<RuletypesPlannedMaintenanceDTO>>
React.SetStateAction<Partial<RuletypesPlannedMaintenanceDTO>>
>;
setModalOpen: React.Dispatch<React.SetStateAction<boolean>>;
handleDeleteDowntime: (id: string, name: string) => void;
@@ -296,7 +291,9 @@ export function CustomCollapseList(
schedule?.startTime?.toString(),
typeof endTime === 'string' ? endTime : endTime?.toString(),
]}
repeats={schedule?.recurrence}
repeats={
schedule?.recurrence as RuletypesRecurrenceDTO | null | undefined
}
updated_at={updatedAt ? dayjs(updatedAt).toISOString() : ''}
updated_by_name={defaultTo(updatedBy, '')}
alertOptions={alertOptions}
@@ -331,7 +328,7 @@ export function PlannedDowntimeList({
>;
alertOptions: DefaultOptionType[];
setInitialValues: React.Dispatch<
React.SetStateAction<DeepPartial<RuletypesPlannedMaintenanceDTO>>
React.SetStateAction<Partial<RuletypesPlannedMaintenanceDTO>>
>;
setModalOpen: React.Dispatch<React.SetStateAction<boolean>>;
handleDeleteDowntime: (id: string, name: string) => void;

View File

@@ -2,7 +2,7 @@ import { UseMutateAsyncFunction } from 'react-query';
import type { NotificationInstance } from 'antd/es/notification/interface';
import type { DefaultOptionType } from 'antd/es/select';
import { convertToApiError } from 'api/ErrorResponseHandlerForGeneratedAPIs';
import {
import type {
DeleteDowntimeScheduleByIDPathParameters,
RenderErrorResponseDTO,
RuletypesPlannedMaintenanceDTO,
@@ -14,7 +14,6 @@ import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
import dayjs from 'dayjs';
import { isEmpty, isEqual } from 'lodash-es';
import APIError from 'types/api/error';
import { DeepPartial } from 'utils/types';
type DateTimeString = string | null | undefined;
@@ -61,15 +60,13 @@ export const getAlertOptionsFromIds = (
);
export const recurrenceInfo = (
startTime?: string,
endTime?: string,
recurrence?: RuletypesRecurrenceDTO | null,
): string => {
if (!recurrence) {
return 'No';
}
const { duration, repeatOn, repeatType } = recurrence;
const { startTime, duration, repeatOn, repeatType, endTime } = recurrence;
const formattedStartTime = startTime
? formatDateTime(dayjs(startTime).toISOString())
@@ -83,7 +80,7 @@ export const recurrenceInfo = (
return `Repeats - ${repeatType} ${weeklyRepeatString} from ${formattedStartTime} ${formattedEndTime} ${durationString}`;
};
export const defaultInitialValues: DeepPartial<
export const defautlInitialValues: Partial<
RuletypesPlannedMaintenanceDTO & { editMode: boolean }
> = {
name: '',
@@ -213,17 +210,39 @@ export const recurrenceOptionWithSubmenu: Option[] = [
recurrenceOptions.monthly,
];
export const getRecurrenceOptionFromValue = (
value?: string | Option | null,
): Option | null | undefined => {
if (!value) {
return null;
}
if (typeof value === 'string') {
return Object.values(recurrenceOptions).find(
(option) => option.value === value,
);
}
return value;
};
export const getEndTime = ({
kind,
schedule,
}: DeepPartial<
}: Partial<
RuletypesPlannedMaintenanceDTO & {
editMode: boolean;
}
>): string | dayjs.Dayjs =>
schedule?.endTime ? dayjs(schedule.endTime).toISOString() : '';
>): string | dayjs.Dayjs => {
if (kind === 'fixed') {
return schedule?.endTime ? dayjs(schedule.endTime).toISOString() : '';
}
return schedule?.recurrence?.endTime
? dayjs(schedule.recurrence.endTime).toISOString()
: '';
};
export const isScheduleRecurring = (
schedule?: DeepPartial<RuletypesPlannedMaintenanceDTO['schedule']> | null,
schedule?: RuletypesPlannedMaintenanceDTO['schedule'] | null,
): boolean => (schedule ? !isEmpty(schedule?.recurrence) : false);
function convertUtcOffsetToTimezoneOffset(offsetMinutes: number): string {
@@ -253,6 +272,7 @@ export function handleTimeConversion(
dateValue: string | dayjs.Dayjs,
timezoneInit?: string,
timezone?: string,
shouldKeepLocalTime?: boolean,
): string {
const timezoneChanged = !isEqual(timezoneInit, timezone);
const initialTime = dayjs(dateValue).tz(timezoneInit);
@@ -260,5 +280,5 @@ export function handleTimeConversion(
const formattedTime = formatWithTimezone(initialTime, timezone);
return timezoneChanged
? formattedTime
: dayjs(dateValue).tz(timezone).format();
: dayjs(dateValue).tz(timezone, shouldKeepLocalTime).format();
}

View File

@@ -8,7 +8,7 @@ import {
} from 'api/generated/services/sigNoz.schemas';
export const buildSchedule = (
schedule: RuletypesScheduleDTO,
schedule: Partial<RuletypesScheduleDTO>,
): RuletypesScheduleDTO => ({
timezone: schedule?.timezone ?? '',
startTime: schedule?.startTime,
@@ -17,13 +17,16 @@ export const buildSchedule = (
});
export const createMockDowntime = (
overrides: Partial<RuletypesPlannedMaintenanceDTO> &
Pick<RuletypesPlannedMaintenanceDTO, 'schedule'>,
overrides: Partial<RuletypesPlannedMaintenanceDTO>,
): RuletypesPlannedMaintenanceDTO => ({
id: overrides.id ?? '0',
name: overrides.name ?? '',
description: overrides.description ?? '',
schedule: overrides.schedule,
schedule: buildSchedule({
timezone: 'UTC',
startTime: new Date('2024-01-01'),
...overrides.schedule,
}),
alertIds: overrides.alertIds ?? [],
createdAt: overrides.createdAt,
createdBy: overrides.createdBy ?? '',

View File

@@ -0,0 +1,150 @@
import { act, renderHook } from '@testing-library/react';
import { LOCALSTORAGE } from 'constants/localStorage';
import { PanelMode } from 'container/DashboardContainer/visualization/panels/types';
import { DashboardCursorSync } from 'lib/uPlotV2/plugins/TooltipPlugin/types';
import { useDashboardCursorSyncMode } from '../useDashboardCursorSyncMode';
import { useDashboardPreferencesStore } from '../useDashboardPreference';
const STORAGE_KEY = LOCALSTORAGE.DASHBOARD_PREFERENCES;
describe('useDashboardCursorSyncMode', () => {
beforeEach(() => {
useDashboardPreferencesStore.setState({ preferences: {} });
localStorage.removeItem(STORAGE_KEY);
});
describe('in DASHBOARD_VIEW mode', () => {
it('uses Crosshair as the default cursor sync mode', () => {
const { result } = renderHook(() =>
useDashboardCursorSyncMode('dash-1', PanelMode.DASHBOARD_VIEW),
);
expect(result.current[0]).toBe(DashboardCursorSync.Crosshair);
});
it('reads the stored cursor sync mode for the dashboard', () => {
useDashboardPreferencesStore.setState({
preferences: { 'dash-1': { cursorSyncMode: DashboardCursorSync.Tooltip } },
});
const { result } = renderHook(() =>
useDashboardCursorSyncMode('dash-1', PanelMode.DASHBOARD_VIEW),
);
expect(result.current[0]).toBe(DashboardCursorSync.Tooltip);
});
it('writes the value under the cursorSyncMode key in the store', () => {
const { result } = renderHook(() =>
useDashboardCursorSyncMode('dash-1', PanelMode.DASHBOARD_VIEW),
);
act(() => {
result.current[1](DashboardCursorSync.None);
});
expect(result.current[0]).toBe(DashboardCursorSync.None);
expect(useDashboardPreferencesStore.getState().preferences).toStrictEqual({
'dash-1': { cursorSyncMode: DashboardCursorSync.None },
});
});
it('persists the value to localStorage', () => {
const { result } = renderHook(() =>
useDashboardCursorSyncMode('dash-1', PanelMode.DASHBOARD_VIEW),
);
act(() => {
result.current[1](DashboardCursorSync.None);
});
const persisted = JSON.parse(localStorage.getItem(STORAGE_KEY) ?? '{}');
expect(persisted.state.preferences).toStrictEqual({
'dash-1': { cursorSyncMode: DashboardCursorSync.None },
});
});
it('returns the default when dashboardId is undefined', () => {
const { result } = renderHook(() =>
useDashboardCursorSyncMode(undefined, PanelMode.DASHBOARD_VIEW),
);
expect(result.current[0]).toBe(DashboardCursorSync.Crosshair);
});
it('treats the setter as a no-op when dashboardId is undefined', () => {
const { result } = renderHook(() =>
useDashboardCursorSyncMode(undefined, PanelMode.DASHBOARD_VIEW),
);
act(() => {
result.current[1](DashboardCursorSync.Tooltip);
});
expect(useDashboardPreferencesStore.getState().preferences).toStrictEqual(
{},
);
expect(localStorage.getItem(STORAGE_KEY)).toBeNull();
expect(result.current[0]).toBe(DashboardCursorSync.Crosshair);
});
});
describe('without a panelMode (e.g. dashboard settings call site)', () => {
it('reads the stored value just like DASHBOARD_VIEW does', () => {
useDashboardPreferencesStore.setState({
preferences: { 'dash-1': { cursorSyncMode: DashboardCursorSync.Tooltip } },
});
const { result } = renderHook(() => useDashboardCursorSyncMode('dash-1'));
expect(result.current[0]).toBe(DashboardCursorSync.Tooltip);
});
it('writes through the setter to the store', () => {
const { result } = renderHook(() => useDashboardCursorSyncMode('dash-1'));
act(() => {
result.current[1](DashboardCursorSync.None);
});
expect(result.current[0]).toBe(DashboardCursorSync.None);
expect(useDashboardPreferencesStore.getState().preferences).toStrictEqual({
'dash-1': { cursorSyncMode: DashboardCursorSync.None },
});
});
});
describe.each([[PanelMode.DASHBOARD_EDIT], [PanelMode.STANDALONE_VIEW]])(
'in %s mode (cursor sync disabled)',
(panelMode) => {
it('returns None and ignores any stored value', () => {
useDashboardPreferencesStore.setState({
preferences: { 'dash-1': { cursorSyncMode: DashboardCursorSync.Tooltip } },
});
const { result } = renderHook(() =>
useDashboardCursorSyncMode('dash-1', panelMode),
);
expect(result.current[0]).toBe(DashboardCursorSync.None);
});
it('treats the setter as a no-op and does not write to the store', () => {
const { result } = renderHook(() =>
useDashboardCursorSyncMode('dash-1', panelMode),
);
act(() => {
result.current[1](DashboardCursorSync.Tooltip);
});
expect(useDashboardPreferencesStore.getState().preferences).toStrictEqual(
{},
);
expect(localStorage.getItem(STORAGE_KEY)).toBeNull();
expect(result.current[0]).toBe(DashboardCursorSync.None);
});
},
);
});

View File

@@ -0,0 +1,201 @@
import { act, renderHook } from '@testing-library/react';
import { DashboardCursorSync } from 'lib/uPlotV2/plugins/TooltipPlugin/types';
import {
useDashboardPreference,
useDashboardPreferencesStore,
} from '../useDashboardPreference';
const DEFAULT_MODE = DashboardCursorSync.Crosshair;
describe('useDashboardPreference', () => {
beforeEach(() => {
useDashboardPreferencesStore.setState({ preferences: {} });
});
it('returns the default value when no preference is stored', () => {
const { result } = renderHook(() =>
useDashboardPreference('dash-1', 'cursorSyncMode', DEFAULT_MODE),
);
expect(result.current[0]).toBe(DEFAULT_MODE);
});
it('returns the default value when dashboardId is undefined', () => {
useDashboardPreferencesStore.setState({
preferences: { 'dash-1': { cursorSyncMode: DashboardCursorSync.Tooltip } },
});
const { result } = renderHook(() =>
useDashboardPreference(undefined, 'cursorSyncMode', DEFAULT_MODE),
);
expect(result.current[0]).toBe(DEFAULT_MODE);
});
it('returns the stored value for the given dashboardId', () => {
useDashboardPreferencesStore.setState({
preferences: {
'dash-1': { cursorSyncMode: DashboardCursorSync.Tooltip },
'dash-2': { cursorSyncMode: DashboardCursorSync.None },
},
});
const { result } = renderHook(() =>
useDashboardPreference('dash-1', 'cursorSyncMode', DEFAULT_MODE),
);
expect(result.current[0]).toBe(DashboardCursorSync.Tooltip);
});
it('persists the new value via the setter', () => {
const { result } = renderHook(() =>
useDashboardPreference('dash-1', 'cursorSyncMode', DEFAULT_MODE),
);
act(() => {
result.current[1](DashboardCursorSync.None);
});
expect(result.current[0]).toBe(DashboardCursorSync.None);
expect(useDashboardPreferencesStore.getState().preferences).toStrictEqual({
'dash-1': { cursorSyncMode: DashboardCursorSync.None },
});
});
it('does not write when dashboardId is undefined', () => {
const { result } = renderHook(() =>
useDashboardPreference(undefined, 'cursorSyncMode', DEFAULT_MODE),
);
act(() => {
result.current[1](DashboardCursorSync.Tooltip);
});
expect(useDashboardPreferencesStore.getState().preferences).toStrictEqual({});
expect(result.current[0]).toBe(DEFAULT_MODE);
});
it('keeps multiple hook instances in sync after a write', () => {
const { result: writer } = renderHook(() =>
useDashboardPreference('dash-1', 'cursorSyncMode', DEFAULT_MODE),
);
const { result: reader } = renderHook(() =>
useDashboardPreference('dash-1', 'cursorSyncMode', DEFAULT_MODE),
);
act(() => {
writer.current[1](DashboardCursorSync.Tooltip);
});
expect(writer.current[0]).toBe(DashboardCursorSync.Tooltip);
expect(reader.current[0]).toBe(DashboardCursorSync.Tooltip);
});
it('isolates preferences across different dashboardIds', () => {
const { result: dashOne } = renderHook(() =>
useDashboardPreference('dash-1', 'cursorSyncMode', DEFAULT_MODE),
);
const { result: dashTwo } = renderHook(() =>
useDashboardPreference('dash-2', 'cursorSyncMode', DEFAULT_MODE),
);
act(() => {
dashOne.current[1](DashboardCursorSync.None);
});
expect(dashOne.current[0]).toBe(DashboardCursorSync.None);
expect(dashTwo.current[0]).toBe(DEFAULT_MODE);
});
it('does not overwrite preferences for other dashboards when writing', () => {
useDashboardPreferencesStore.setState({
preferences: { 'dash-2': { cursorSyncMode: DashboardCursorSync.Tooltip } },
});
const { result } = renderHook(() =>
useDashboardPreference('dash-1', 'cursorSyncMode', DEFAULT_MODE),
);
act(() => {
result.current[1](DashboardCursorSync.None);
});
expect(useDashboardPreferencesStore.getState().preferences).toStrictEqual({
'dash-1': { cursorSyncMode: DashboardCursorSync.None },
'dash-2': { cursorSyncMode: DashboardCursorSync.Tooltip },
});
});
});
describe('useDashboardPreferencesStore.removePreferences', () => {
beforeEach(() => {
useDashboardPreferencesStore.setState({ preferences: {} });
});
it('removes the preferences for the given dashboardId', () => {
useDashboardPreferencesStore.setState({
preferences: {
'dash-1': { cursorSyncMode: DashboardCursorSync.Tooltip },
},
});
act(() => {
useDashboardPreferencesStore.getState().removePreferences('dash-1');
});
expect(useDashboardPreferencesStore.getState().preferences).toStrictEqual({});
});
it('leaves other dashboards untouched', () => {
useDashboardPreferencesStore.setState({
preferences: {
'dash-1': { cursorSyncMode: DashboardCursorSync.Tooltip },
'dash-2': { cursorSyncMode: DashboardCursorSync.None },
},
});
act(() => {
useDashboardPreferencesStore.getState().removePreferences('dash-1');
});
expect(useDashboardPreferencesStore.getState().preferences).toStrictEqual({
'dash-2': { cursorSyncMode: DashboardCursorSync.None },
});
});
it('is a no-op when the dashboardId is not present', () => {
const initial = {
'dash-2': { cursorSyncMode: DashboardCursorSync.Tooltip },
};
useDashboardPreferencesStore.setState({ preferences: initial });
const before = useDashboardPreferencesStore.getState().preferences;
act(() => {
useDashboardPreferencesStore.getState().removePreferences('dash-1');
});
// Identity-preserving so subscribers reading `preferences` don't re-render.
expect(useDashboardPreferencesStore.getState().preferences).toBe(before);
});
it('causes subsequent reads via useDashboardPreference to fall back to the default', () => {
useDashboardPreferencesStore.setState({
preferences: {
'dash-1': { cursorSyncMode: DashboardCursorSync.Tooltip },
},
});
const { result } = renderHook(() =>
useDashboardPreference('dash-1', 'cursorSyncMode', DEFAULT_MODE),
);
expect(result.current[0]).toBe(DashboardCursorSync.Tooltip);
act(() => {
useDashboardPreferencesStore.getState().removePreferences('dash-1');
});
expect(result.current[0]).toBe(DEFAULT_MODE);
});
});

View File

@@ -0,0 +1,26 @@
import { DashboardCursorSync } from 'lib/uPlotV2/plugins/TooltipPlugin/types';
import { useDashboardPreference } from './useDashboardPreference';
import { PanelMode } from 'container/DashboardContainer/visualization/panels/types';
const NOOP = (): void => {};
export function useDashboardCursorSyncMode(
dashboardId: string | undefined,
panelMode?: PanelMode,
): [DashboardCursorSync, (value: DashboardCursorSync) => void] {
const [value, setValue] = useDashboardPreference(
dashboardId,
'cursorSyncMode',
DashboardCursorSync.Crosshair,
);
// Chart panels in edit / standalone modes don't participate in cross-panel
// sync, so surface the default with a no-op setter for them. Callers without
// a panelMode (e.g. dashboard settings) read/write the preference normally.
if (panelMode && panelMode !== PanelMode.DASHBOARD_VIEW) {
return [DashboardCursorSync.None, NOOP];
}
return [value, setValue];
}

View File

@@ -0,0 +1,88 @@
import { useCallback } from 'react';
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
import { LOCALSTORAGE } from 'constants/localStorage';
import {
DashboardCursorSync,
SyncTooltipFilterMode,
} from 'lib/uPlotV2/plugins/TooltipPlugin/types';
// Per-dashboard preferences persisted in localStorage. Add new preference
// fields here as they are introduced.
export type DashboardPreferences = {
cursorSyncMode?: DashboardCursorSync;
syncTooltipFilterMode?: SyncTooltipFilterMode;
};
interface DashboardPreferencesState {
preferences: Record<string, DashboardPreferences>;
setPreference: <K extends keyof DashboardPreferences>(
dashboardId: string,
key: K,
value: NonNullable<DashboardPreferences[K]>,
) => void;
removePreferences: (dashboardId: string) => void;
}
export const useDashboardPreferencesStore = create<DashboardPreferencesState>()(
persist(
(set) => ({
preferences: {},
setPreference: (dashboardId, key, value): void => {
set((state) => ({
preferences: {
...state.preferences,
[dashboardId]: {
...state.preferences[dashboardId],
[key]: value,
},
},
}));
},
removePreferences: (dashboardId): void => {
set((state) => {
if (!(dashboardId in state.preferences)) {
return state;
}
const { [dashboardId]: _, ...rest } = state.preferences;
return { preferences: rest };
});
},
}),
{ name: LOCALSTORAGE.DASHBOARD_PREFERENCES },
),
);
export function useDashboardPreference<K extends keyof DashboardPreferences>(
dashboardId: string | undefined,
key: K,
defaultValue: NonNullable<DashboardPreferences[K]>,
): [
NonNullable<DashboardPreferences[K]>,
(value: NonNullable<DashboardPreferences[K]>) => void,
] {
type Value = NonNullable<DashboardPreferences[K]>;
const value = useDashboardPreferencesStore((state): Value => {
if (!dashboardId) {
return defaultValue;
}
return (
(state.preferences[dashboardId]?.[key] as Value | undefined) ?? defaultValue
);
});
const setPreference = useDashboardPreferencesStore((s) => s.setPreference);
const updateValue = useCallback(
(next: Value): void => {
if (!dashboardId) {
return;
}
setPreference(dashboardId, key, next);
},
[dashboardId, key, setPreference],
);
return [value, updateValue];
}

View File

@@ -5,10 +5,15 @@ import { useErrorModal } from 'providers/ErrorModalProvider';
import { SuccessResponseV2 } from 'types/api';
import APIError from 'types/api/error';
import { useDashboardPreferencesStore } from './useDashboardPreference';
export const useDeleteDashboard = (
id: string,
): UseMutationResult<SuccessResponseV2<null>, APIError, void, unknown> => {
const { showErrorModal } = useErrorModal();
const removePreferences = useDashboardPreferencesStore(
(state) => state.removePreferences,
);
return useMutation<SuccessResponseV2<null>, APIError>({
mutationKey: REACT_QUERY_KEY.DELETE_DASHBOARD,
@@ -16,6 +21,9 @@ export const useDeleteDashboard = (
deleteDashboard({
id,
}),
onSuccess: () => {
removePreferences(id);
},
onError: (error: APIError) => {
showErrorModal(error);
},

View File

@@ -0,0 +1,15 @@
import { SyncTooltipFilterMode } from 'lib/uPlotV2/plugins/TooltipPlugin/types';
import { useDashboardPreference } from './useDashboardPreference';
const DEFAULT_SYNC_TOOLTIP_FILTER_MODE = SyncTooltipFilterMode.Filtered;
export function useSyncTooltipFilterMode(
dashboardId: string | undefined,
): [SyncTooltipFilterMode, (value: SyncTooltipFilterMode) => void] {
return useDashboardPreference(
dashboardId,
'syncTooltipFilterMode',
DEFAULT_SYNC_TOOLTIP_FILTER_MODE,
);
}

View File

@@ -17,6 +17,7 @@ export default function BarChartTooltip(props: BarTooltipProps): JSX.Element {
decimalPrecision: props.decimalPrecision,
isStackedBarChart: props.isStackedBarChart,
syncedSeriesIndexes: props.syncedSeriesIndexes,
syncFilterMode: props.syncFilterMode,
}),
[
props.uPlotInstance,
@@ -26,6 +27,7 @@ export default function BarChartTooltip(props: BarTooltipProps): JSX.Element {
props.decimalPrecision,
props.isStackedBarChart,
props.syncedSeriesIndexes,
props.syncFilterMode,
],
);

View File

@@ -18,6 +18,7 @@ export default function HistogramTooltip(
yAxisUnit: props.yAxisUnit ?? '',
decimalPrecision: props.decimalPrecision,
syncedSeriesIndexes: props.syncedSeriesIndexes,
syncFilterMode: props.syncFilterMode,
}),
[
props.uPlotInstance,
@@ -26,6 +27,7 @@ export default function HistogramTooltip(
props.yAxisUnit,
props.decimalPrecision,
props.syncedSeriesIndexes,
props.syncFilterMode,
],
);

View File

@@ -18,6 +18,7 @@ export default function TimeSeriesTooltip(
yAxisUnit: props.yAxisUnit ?? '',
decimalPrecision: props.decimalPrecision,
syncedSeriesIndexes: props.syncedSeriesIndexes,
syncFilterMode: props.syncFilterMode,
}),
[
props.uPlotInstance,
@@ -26,6 +27,7 @@ export default function TimeSeriesTooltip(
props.yAxisUnit,
props.decimalPrecision,
props.syncedSeriesIndexes,
props.syncFilterMode,
],
);

View File

@@ -8,15 +8,15 @@
border: 1px solid var(--l2-border);
display: flex;
flex-direction: column;
gap: 8px;
&.pinned {
border-color: var(--ring);
}
.divider {
width: 100%;
height: 1px;
background-color: var(--l2-border);
}
}
.divider {
display: block;
width: 100%;
height: 1px;
background-color: var(--l2-border);
}

View File

@@ -2,19 +2,19 @@ import { useMemo } from 'react';
import cx from 'classnames';
import { TooltipProps } from '../types';
import TooltipFooter from './components/TooltipFooter/TooltipFooter';
import TooltipHeader from './components/TooltipHeader/TooltipHeader';
import TooltipList from './components/TooltipList/TooltipList';
import Styles from './Tooltip.module.scss';
export default function Tooltip({
id,
uPlotInstance,
timezone,
content,
showTooltipHeader = true,
isPinned,
canPinTooltip,
renderTooltipFooter,
dismiss,
}: TooltipProps): JSX.Element {
const tooltipContent = useMemo(() => content ?? [], [content]);
@@ -31,7 +31,9 @@ export default function Tooltip({
return (
<div
className={cx(Styles.container, isPinned && Styles.pinned)}
className={cx(Styles.container, {
[Styles.pinned]: isPinned,
})}
data-testid="uplot-tooltip-container"
>
{showHeader && (
@@ -46,9 +48,9 @@ export default function Tooltip({
{showDivider && <span className={Styles.divider} />}
{showList && <TooltipList content={tooltipContent} />}
{showList && <TooltipList id={id} content={tooltipContent} />}
{canPinTooltip && <TooltipFooter isPinned={isPinned} dismiss={dismiss} />}
{renderTooltipFooter && renderTooltipFooter({ isPinned, dismiss })}
</div>
);
}

View File

@@ -7,7 +7,7 @@ import { useIsDarkMode } from 'hooks/useDarkMode';
import { render, RenderResult, screen } from 'tests/test-utils';
import uPlot from 'uplot';
import { TooltipContentItem } from '../../types';
import { IRenderTooltipFooterArgs, TooltipContentItem } from '../../types';
import Tooltip from '../Tooltip';
type MockVirtuosoProps = {
@@ -83,6 +83,7 @@ function createUPlotInstance(cursorIdx: number | null): uPlot {
function renderTooltip(props: Partial<TooltipTestProps> = {}): RenderResult {
const defaultProps: TooltipTestProps = {
id: 'tooltip-1',
uPlotInstance: createUPlotInstance(null),
timezone: { value: 'UTC', name: 'UTC', offset: '0', searchIndex: '0' },
content: [],
@@ -192,63 +193,88 @@ describe('Tooltip', () => {
});
});
describe('Tooltip footer hint', () => {
describe('Tooltip renderTooltipFooter', () => {
beforeEach(() => {
jest.clearAllMocks();
mockUseIsDarkMode.mockReturnValue(false);
});
it('renders footer with "Press P to pin the tooltip" hint when not pinned', () => {
renderTooltip({ isPinned: false, canPinTooltip: true });
it('does not render footer content when renderTooltipFooter is not provided', () => {
renderTooltip();
const footer = screen.getByTestId('uplot-tooltip-footer');
expect(footer).toBeInTheDocument();
expect(footer).toHaveTextContent('Press');
expect(footer).toHaveTextContent('P');
expect(footer).toHaveTextContent('to pin the tooltip');
expect(screen.queryByTestId('custom-tooltip-footer')).not.toBeInTheDocument();
});
it('renders footer with "Press P or Esc to unpin" hint when pinned', () => {
renderTooltip({ isPinned: true, canPinTooltip: true });
it('renders content returned by renderTooltipFooter', () => {
const renderTooltipFooter = jest.fn(
(): JSX.Element => <div data-testid="custom-tooltip-footer">Footer</div>,
);
const footer = screen.getByTestId('uplot-tooltip-footer');
expect(footer).toHaveTextContent('Press');
expect(footer).toHaveTextContent('P');
expect(footer).toHaveTextContent('Esc');
expect(footer).toHaveTextContent('to unpin');
renderTooltip({ renderTooltipFooter });
expect(screen.getByTestId('custom-tooltip-footer')).toBeInTheDocument();
});
it('does not render Unpin button when not pinned', () => {
renderTooltip({ isPinned: false, canPinTooltip: true });
it('calls renderTooltipFooter with isPinned=false when tooltip is not pinned', () => {
const renderTooltipFooter = jest.fn(() => null);
expect(screen.queryByTestId('uplot-tooltip-unpin')).not.toBeInTheDocument();
renderTooltip({ renderTooltipFooter, isPinned: false });
expect(renderTooltipFooter).toHaveBeenCalledWith(
expect.objectContaining({ isPinned: false }),
);
});
it('renders Unpin button when pinned', () => {
renderTooltip({ isPinned: true, canPinTooltip: true });
it('calls renderTooltipFooter with isPinned=true when tooltip is pinned', () => {
const renderTooltipFooter = jest.fn(() => null);
const unpinBtn = screen.getByTestId('uplot-tooltip-unpin');
expect(unpinBtn).toBeInTheDocument();
expect(unpinBtn).toHaveAttribute('aria-label', 'Unpin tooltip');
renderTooltip({ renderTooltipFooter, isPinned: true });
expect(renderTooltipFooter).toHaveBeenCalledWith(
expect.objectContaining({ isPinned: true }),
);
});
it('calls dismiss when Unpin button is clicked', async () => {
it('calls renderTooltipFooter with the dismiss callback', () => {
const dismiss = jest.fn();
renderTooltip({ isPinned: true, canPinTooltip: true, dismiss });
const renderTooltipFooter = jest.fn(() => null);
renderTooltip({ renderTooltipFooter, dismiss });
expect(renderTooltipFooter).toHaveBeenCalledWith(
expect.objectContaining({ dismiss }),
);
});
it('footer content reflects pinned state via renderTooltipFooter args', () => {
const renderTooltipFooter = jest.fn(
({ isPinned }: IRenderTooltipFooterArgs): JSX.Element => (
<div data-testid="footer-state">{isPinned ? 'Pinned' : 'Not pinned'}</div>
),
);
renderTooltip({ renderTooltipFooter, isPinned: true });
expect(screen.getByTestId('footer-state')).toHaveTextContent('Pinned');
});
it('dismiss is callable when invoked from renderTooltipFooter', async () => {
const dismiss = jest.fn();
const renderTooltipFooter = jest.fn(
({ dismiss: onDismiss }: IRenderTooltipFooterArgs): JSX.Element => (
<button data-testid="dismiss-btn" onClick={onDismiss}>
Dismiss
</button>
),
);
renderTooltip({ renderTooltipFooter, isPinned: true, dismiss });
const user = userEvent.setup();
const unpinBtn = screen.getByTestId('uplot-tooltip-unpin');
await user.click(unpinBtn);
await user.click(screen.getByTestId('dismiss-btn'));
expect(dismiss).toHaveBeenCalledTimes(1);
});
it('footer has role="status" for screen reader announcements', () => {
renderTooltip({ canPinTooltip: true });
const footer = screen.getByRole('status');
expect(footer).toBeInTheDocument();
});
});
describe('Tooltip header status pill', () => {

View File

@@ -11,11 +11,10 @@
align-items: center;
justify-content: space-between;
gap: var(--spacing-2);
margin-bottom: var(--spacing-2);
}
.pinnedItem {
padding: var(--spacing-4) var(--spacing-4) 0 var(--spacing-4);
padding: var(--spacing-4);
}
.status {

View File

@@ -1,9 +1,13 @@
.container {
padding-bottom: var(--spacing-6);
}
.list {
width: 100%;
:global(div[data-viewport-type='element']) {
left: 0;
box-sizing: border-box;
padding: 0px var(--spacing-2) 0 var(--spacing-4);
padding: var(--spacing-4) var(--spacing-2) var(--spacing-4) var(--spacing-4);
[data-test-id='virtuoso-item-list'] > * + * {
margin-top: var(--spacing-2);

View File

@@ -9,17 +9,18 @@ import logEvent from 'api/common/logEvent';
import { Events } from 'constants/events';
import Styles from './TooltipList.module.scss';
import { getAbsoluteUrl } from 'utils/basePath';
// Fallback per-item height before Virtuoso reports the real total.
const TOOLTIP_ITEM_HEIGHT = 38;
const LIST_MAX_HEIGHT = 300;
interface TooltipListProps {
id: string;
content: TooltipContentItem[];
}
export default function TooltipList({
id,
content,
}: TooltipListProps): JSX.Element {
const isDarkMode = useIsDarkMode();
@@ -41,23 +42,25 @@ export default function TooltipList({
if (!isScrollEventTriggered.current) {
// TODO: remove event in July 2026
logEvent(Events.TOOLTIP_CONTENT_SCROLLED, {
path: getAbsoluteUrl(window.location.pathname),
id,
});
isScrollEventTriggered.current = true;
}
}, []);
return (
<Virtuoso
className={cx(Styles.list, !isDarkMode && Styles.listLightMode)}
data-testid="uplot-tooltip-list"
data={content}
onScroll={handleScroll}
style={{ height }}
totalListHeightChanged={setTotalListHeight}
itemContent={(_, item): JSX.Element => (
<TooltipItem item={item} isItemActive={false} />
)}
/>
<div className={Styles.container}>
<Virtuoso
className={cx(Styles.list, !isDarkMode && Styles.listLightMode)}
data-testid="uplot-tooltip-list"
data={content}
onScroll={handleScroll}
style={{ height }}
totalListHeightChanged={setTotalListHeight}
itemContent={(_, item): JSX.Element => (
<TooltipItem item={item} isItemActive={item.isHighlighted === true} />
)}
/>
</div>
);
}

View File

@@ -2,6 +2,7 @@ import { PrecisionOption } from 'components/Graph/types';
import { getToolTipValue } from 'components/Graph/yAxisConfig';
import uPlot, { AlignedData, Series } from 'uplot';
import { SyncTooltipFilterMode } from '../../plugins/TooltipPlugin/types';
import { TooltipContentItem } from '../types';
export const FALLBACK_SERIES_COLOR = '#000000';
@@ -63,6 +64,7 @@ export function buildTooltipContent({
decimalPrecision,
isStackedBarChart,
syncedSeriesIndexes,
syncFilterMode,
}: {
data: AlignedData;
series: Series[];
@@ -73,10 +75,16 @@ export function buildTooltipContent({
decimalPrecision?: PrecisionOption;
isStackedBarChart?: boolean;
syncedSeriesIndexes?: number[] | null;
syncFilterMode?: SyncTooltipFilterMode;
}): TooltipContentItem[] {
const items: TooltipContentItem[] = [];
const allowedIndexes =
const matchedIndexes =
syncedSeriesIndexes != null ? new Set(syncedSeriesIndexes) : null;
const filterMode = syncFilterMode ?? SyncTooltipFilterMode.Filtered;
// In Filtered mode the matched indexes act as a whitelist; in All mode every
// series renders and matched indexes only drive row highlighting.
const allowedIndexes =
filterMode === SyncTooltipFilterMode.All ? null : matchedIndexes;
for (let seriesIndex = 1; seriesIndex < series.length; seriesIndex += 1) {
const seriesItem = series[seriesIndex];
@@ -89,6 +97,7 @@ export function buildTooltipContent({
const dataIndex = dataIndexes[seriesIndex];
const isSync = allowedIndexes != null;
const isHighlighted = matchedIndexes?.has(seriesIndex) ?? false;
if (dataIndex === null) {
if (isSync) {
@@ -98,6 +107,7 @@ export function buildTooltipContent({
tooltipValue: 'No Data',
color: resolveSeriesColor(seriesItem.stroke, uPlotInstance, seriesIndex),
isActive: false,
isHighlighted,
});
}
continue;
@@ -118,6 +128,7 @@ export function buildTooltipContent({
tooltipValue: getToolTipValue(baseValue, yAxisUnit, decimalPrecision),
color: resolveSeriesColor(seriesItem.stroke, uPlotInstance, seriesIndex),
isActive: seriesIndex === activeSeriesIndex,
isHighlighted,
});
} else if (isSync) {
items.push({
@@ -126,6 +137,7 @@ export function buildTooltipContent({
tooltipValue: 'No Data',
color: resolveSeriesColor(seriesItem.stroke, uPlotInstance, seriesIndex),
isActive: false,
isHighlighted,
});
}
}

View File

@@ -4,6 +4,7 @@ import { PrecisionOption } from 'components/Graph/types';
import uPlot from 'uplot';
import { UPlotConfigBuilder } from '../config/UPlotConfigBuilder';
import { SyncTooltipFilterMode } from '../plugins/TooltipPlugin/types';
/**
* Props for the Plot component
@@ -58,17 +59,31 @@ export interface TooltipRenderArgs {
isPinned: boolean;
dismiss: () => void;
viaSync: boolean;
/** In Tooltip sync mode, limits which series are rendered in the receiver tooltip.
* null = no filtering; [] = no matches (tooltip hidden upstream); [...] = allowed indexes */
/** In Tooltip sync mode, identifies receiver series that match the source's
* focused series on the shared groupBy keys.
* Filtered mode: limits which series are rendered (null = no filter,
* [] = no matches/tooltip hidden upstream, [...] = allowed indexes).
* All mode: same indexes are interpreted as a highlight set; non-matching
* series still render. */
syncedSeriesIndexes?: number[] | null;
/** Receiver-side filter mode for the synced tooltip. Defaults to Filtered. */
syncFilterMode?: SyncTooltipFilterMode;
}
export interface IRenderTooltipFooterArgs {
pinKey?: string;
isPinned: boolean;
dismiss: () => void;
}
export interface BaseTooltipProps {
id: string;
showTooltipHeader?: boolean;
canPinTooltip?: boolean;
yAxisUnit?: string;
decimalPrecision?: PrecisionOption;
content?: TooltipContentItem[];
renderTooltipFooter?: (args: IRenderTooltipFooterArgs) => ReactNode;
timezone?: Timezone;
}
@@ -106,4 +121,9 @@ export interface TooltipContentItem {
tooltipValue: string;
color: string;
isActive: boolean;
/** Synced receiver series whose metric matches the source's focused series
* on the shared groupBy keys, in 'all' filter mode. List rendering uses this
* to apply the active highlight to matching rows while non-matching rows
* stay dimmed. */
isHighlighted?: boolean;
}

View File

@@ -18,6 +18,7 @@ import {
import {
DashboardCursorSync,
DEFAULT_PIN_TOOLTIP_KEY,
SyncTooltipFilterMode,
TooltipControllerContext,
TooltipControllerState,
TooltipLayoutInfo,
@@ -32,7 +33,6 @@ import {
import { Events } from 'constants/events';
import Styles from './TooltipPlugin.module.scss';
import { getAbsoluteUrl } from 'utils/basePath';
// Delay before hiding an unpinned tooltip when the cursor briefly leaves
// the plot this avoids flicker when moving between nearby points.
@@ -199,10 +199,14 @@ export default function TooltipPlugin({
if (!controller.hoverActive || !plot) {
return null;
}
// In Tooltip sync mode, suppress the receiver tooltip entirely when
// no receiver series match the source panel's focused series.
const filterMode =
syncMetadata?.filterMode ?? SyncTooltipFilterMode.Filtered;
// In Filtered Tooltip sync mode, suppress the receiver tooltip entirely
// when no receiver series match the source panel's focused series. In
// All mode the tooltip still renders with every series visible.
if (
syncTooltipWithDashboard &&
filterMode === SyncTooltipFilterMode.Filtered &&
controller.cursorDrivenBySync &&
Array.isArray(controller.syncedSeriesIndexes) &&
controller.syncedSeriesIndexes.length === 0
@@ -217,6 +221,7 @@ export default function TooltipPlugin({
dismiss: dismissTooltip,
viaSync: controller.cursorDrivenBySync,
syncedSeriesIndexes: controller.syncedSeriesIndexes,
syncFilterMode: filterMode,
});
}
@@ -304,7 +309,7 @@ export default function TooltipPlugin({
if (event.key === 'Escape') {
if (controller.pinned) {
logEvent(Events.TOOLTIP_UNPINNED, {
path: getAbsoluteUrl(window.location.pathname),
id: config.getId(),
});
dismissTooltip();
}
@@ -318,7 +323,7 @@ export default function TooltipPlugin({
// Toggle off: P pressed while already pinned.
if (controller.pinned) {
logEvent(Events.TOOLTIP_UNPINNED, {
path: getAbsoluteUrl(window.location.pathname),
id: config.getId(),
});
dismissTooltip();
return;
@@ -352,7 +357,7 @@ export default function TooltipPlugin({
controller.clickData = buildClickData(syntheticEvent, plot);
controller.pinned = true;
logEvent(Events.TOOLTIP_PINNED, {
path: getAbsoluteUrl(window.location.pathname),
id: config.getId(),
});
scheduleRender(true);
};

View File

@@ -2,10 +2,33 @@ import uPlot from 'uplot';
import type { ExtendedSeries } from '../../config/types';
import { syncCursorRegistry } from './syncCursorRegistry';
import type { TooltipControllerState, TooltipSyncMetadata } from './types';
import {
SyncTooltipFilterMode,
type TooltipControllerState,
type TooltipSyncMetadata,
} from './types';
/**
* Returns the dimension keys present in both groupBy arrays.
* Flattens per-query groupBys into a deduped set of dimension keys.
* A panel's effective groupBy is the union across all of its queries.
*/
function collectGroupByKeys(
groupByPerQuery: TooltipSyncMetadata['groupByPerQuery'],
): Set<string> {
const keys = new Set<string>();
if (!groupByPerQuery) {
return keys;
}
for (const groupBy of Object.values(groupByPerQuery)) {
for (const dim of groupBy) {
keys.add(dim.key);
}
}
return keys;
}
/**
* Returns the dimension keys present in both panels' groupBys.
* An empty result means no overlap — series highlighting should not run.
*
* exact [A, B] vs [A, B] → [A, B] one match
@@ -14,24 +37,28 @@ import type { TooltipControllerState, TooltipSyncMetadata } from './types';
* partial [A, B] vs [B, C] → [B]
*/
function getCommonGroupByKeys(
a: TooltipSyncMetadata['groupBy'],
b: TooltipSyncMetadata['groupBy'],
a: TooltipSyncMetadata['groupByPerQuery'],
b: TooltipSyncMetadata['groupByPerQuery'],
): string[] {
if (
!Array.isArray(a) ||
a.length === 0 ||
!Array.isArray(b) ||
b.length === 0
) {
const aKeys = collectGroupByKeys(a);
const bKeys = collectGroupByKeys(b);
if (aKeys.size === 0 || bKeys.size === 0) {
return [];
}
const bKeys = new Set(b.map((g) => g.key));
return a.filter((g) => bKeys.has(g.key)).map((g) => g.key);
const common: string[] = [];
aKeys.forEach((key) => {
if (bKeys.has(key)) {
common.push(key);
}
});
return common;
}
/**
* Returns the 1-based indexes of every series whose metric matches
* sourceMetric on all commonKeys.
* Returns the 1-based indexes of every visible series whose metric matches
* sourceMetric on all commonKeys. Hidden series (toggled off in the legend)
* are excluded so the synced tooltip is suppressed when no visible series
* would match.
*/
function findMatchingSeriesIndexes(
series: uPlot.Series[],
@@ -39,7 +66,7 @@ function findMatchingSeriesIndexes(
commonKeys: string[],
): number[] {
return series.reduce<number[]>((acc, s, i) => {
if (i === 0) {
if (i === 0 || s.show === false) {
return acc;
}
const metric = (s as ExtendedSeries).metric;
@@ -76,10 +103,15 @@ function applySourceSync({
}
/**
* Returns:
* null no groupBy filtering configured or cursor off-chart (no-op for tooltip)
* [] groupBy configured but no receiver series match the source (hide synced tooltip)
* number[] 1-based indexes of matching receiver series (show only these)
* Computes receiver-side series filtering / highlighting for Tooltip sync.
*
* Returns the indexes that the tooltip render path should treat per
* `syncMetadata.filterMode`:
* - Filtered (default): null = no filter, [] = no matches (suppress tooltip),
* number[] = allowed indexes (show only these).
* - All: null = no highlight (show all), number[] = highlight set (show all,
* emphasize matching rows). Never returns [] in this mode so the synced
* tooltip is not suppressed when matches are missing.
*/
function applyReceiverSync({
uPlotInstance,
@@ -99,8 +131,13 @@ function applyReceiverSync({
yCrosshairEl.style.display =
sourceMetadata?.yAxisUnit === syncMetadata?.yAxisUnit ? '' : 'none';
const filterMode = syncMetadata?.filterMode ?? SyncTooltipFilterMode.Filtered;
const noMatchResult: number[] | null =
filterMode === SyncTooltipFilterMode.All ? null : [];
if (commonKeys.length === 0) {
return null;
uPlotInstance.setSeries(null, { focus: false });
return [];
}
if ((uPlotInstance.cursor.left ?? -1) < 0) {
@@ -111,7 +148,7 @@ function applyReceiverSync({
const sourceSeriesMetric = syncCursorRegistry.getActiveSeriesMetric(syncKey);
if (sourceSeriesMetric == null) {
uPlotInstance.setSeries(null, { focus: false });
return [];
return noMatchResult;
}
const matchingIdxs = findMatchingSeriesIndexes(
@@ -122,7 +159,7 @@ function applyReceiverSync({
if (matchingIdxs.length === 0) {
uPlotInstance.setSeries(null, { focus: false });
return [];
return noMatchResult;
}
uPlotInstance.setSeries(matchingIdxs[0], { focus: true });
@@ -140,7 +177,7 @@ export function createSyncDisplayHook(
// groupBy on both panels is stable (set at config time). Recompute the
// intersection only when the source panel's groupBy reference changes.
let lastSourceGroupBy: TooltipSyncMetadata['groupBy'];
let lastSourceGroupBy: TooltipSyncMetadata['groupByPerQuery'];
let cachedCommonKeys: string[] = [];
return (u: uPlot): void => {
@@ -165,11 +202,11 @@ export function createSyncDisplayHook(
// inside applyReceiverSync.
const sourceMetadata = syncCursorRegistry.getMetadata(syncKey);
if (sourceMetadata?.groupBy !== lastSourceGroupBy) {
lastSourceGroupBy = sourceMetadata?.groupBy;
if (sourceMetadata?.groupByPerQuery !== lastSourceGroupBy) {
lastSourceGroupBy = sourceMetadata?.groupByPerQuery;
cachedCommonKeys = getCommonGroupByKeys(
sourceMetadata?.groupBy,
syncMetadata?.groupBy,
sourceMetadata?.groupByPerQuery,
syncMetadata?.groupByPerQuery,
);
}

View File

@@ -16,9 +16,18 @@ export const TOOLTIP_OFFSET = 10;
export const DEFAULT_PIN_TOOLTIP_KEY = 'p';
export enum DashboardCursorSync {
Crosshair,
None,
Tooltip,
Crosshair = 'crosshair',
None = 'none',
Tooltip = 'tooltip',
}
/**
* Controls whether a synced tooltip filters series by groupBy intersection
* or shows every series with the matching ones highlighted.
*/
export enum SyncTooltipFilterMode {
Filtered = 'filtered',
All = 'all',
}
export interface TooltipViewState {
@@ -40,7 +49,8 @@ export interface TooltipLayoutInfo {
export interface TooltipSyncMetadata {
yAxisUnit?: string;
groupBy?: BaseAutocompleteData[];
groupByPerQuery?: Record<string, BaseAutocompleteData[]>;
filterMode?: SyncTooltipFilterMode;
}
export interface TooltipPluginProps {

View File

@@ -0,0 +1,152 @@
import type { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
import type uPlot from 'uplot';
import type { ExtendedSeries } from '../../config/types';
import { syncCursorRegistry } from '../TooltipPlugin/syncCursorRegistry';
import { createSyncDisplayHook } from '../TooltipPlugin/syncDisplayHook';
import type {
TooltipControllerState,
TooltipSyncMetadata,
} from '../TooltipPlugin/types';
const SYNC_KEY = 'test-sync';
function makeController(): TooltipControllerState {
return {
plot: null,
hoverActive: false,
isAnySeriesActive: false,
pinned: false,
clickData: null,
style: {},
horizontalOffset: 0,
verticalOffset: 0,
seriesIndexes: [],
focusedSeriesIndex: null,
syncedSeriesIndexes: null,
cursorDrivenBySync: false,
plotWithinViewport: true,
windowWidth: 1024,
windowHeight: 768,
pendingPinnedUpdate: false,
};
}
function makeFakePlot(
series: ExtendedSeries[],
cursorEvent: Record<string, unknown> | null = null,
): uPlot {
const root = document.createElement('div');
const yCrosshair = document.createElement('div');
yCrosshair.className = 'u-cursor-y';
root.appendChild(yCrosshair);
return {
root,
series,
cursor: { event: cursorEvent, left: 50 },
setSeries: jest.fn(),
} as unknown as uPlot;
}
const SERVICE_NAME_KEY: BaseAutocompleteData = {
key: 'service.name',
type: 'tag',
};
const groupByService: TooltipSyncMetadata = {
groupByPerQuery: { queryName: [SERVICE_NAME_KEY] },
};
function seedSourcePanel(activeMetric: Record<string, string>): void {
syncCursorRegistry.setMetadata(SYNC_KEY, groupByService);
syncCursorRegistry.setActiveSeriesMetric(SYNC_KEY, activeMetric);
}
function makeReceiverSeries(
entries: { name: string; show?: boolean }[],
): ExtendedSeries[] {
return [
{} as ExtendedSeries,
...entries.map(
(e) =>
({
show: e.show ?? true,
metric: { 'service.name': e.name },
}) as unknown as ExtendedSeries,
),
];
}
describe('createSyncDisplayHook (receiver-side filtering)', () => {
beforeEach(() => {
syncCursorRegistry.setMetadata(SYNC_KEY, undefined);
syncCursorRegistry.setActiveSeriesMetric(SYNC_KEY, null);
});
it('returns indexes of visible matching series only', () => {
seedSourcePanel({ 'service.name': 'flagd' });
const series = makeReceiverSeries([
{ name: 'flagd', show: true },
{ name: 'frontend', show: true },
{ name: 'flagd', show: true },
]);
const plot = makeFakePlot(series, null);
const controller = makeController();
createSyncDisplayHook(SYNC_KEY, groupByService, controller)(plot);
expect(controller.syncedSeriesIndexes).toStrictEqual([1, 3]);
});
it('treats all matching series being hidden as no match → empty array', () => {
seedSourcePanel({ 'service.name': 'frontendproxy' });
const series = makeReceiverSeries([
{ name: 'flagd', show: true },
{ name: 'frontendproxy', show: false },
]);
const plot = makeFakePlot(series, null);
const controller = makeController();
createSyncDisplayHook(SYNC_KEY, groupByService, controller)(plot);
expect(controller.syncedSeriesIndexes).toStrictEqual([]);
expect(plot.setSeries).toHaveBeenCalledWith(null, { focus: false });
});
it('excludes hidden series and keeps the visible matches', () => {
seedSourcePanel({ 'service.name': 'flagd' });
const series = makeReceiverSeries([
{ name: 'flagd', show: false },
{ name: 'frontend', show: true },
{ name: 'flagd', show: true },
]);
const plot = makeFakePlot(series, null);
const controller = makeController();
createSyncDisplayHook(SYNC_KEY, groupByService, controller)(plot);
expect(controller.syncedSeriesIndexes).toStrictEqual([3]);
// Focuses the first visible match, not the hidden one at index 1.
expect(plot.setSeries).toHaveBeenCalledWith(3, { focus: true });
});
it('returns null (no filtering) when the hook runs on the source panel', () => {
const series = makeReceiverSeries([{ name: 'flagd', show: true }]);
// cursor.event != null marks this invocation as the source panel.
const plot = makeFakePlot(series, { type: 'mousemove' });
const controller = makeController();
controller.focusedSeriesIndex = 1;
(series[1] as ExtendedSeries).metric = { 'service.name': 'flagd' };
createSyncDisplayHook(SYNC_KEY, groupByService, controller)(plot);
expect(controller.syncedSeriesIndexes).toBeNull();
expect(syncCursorRegistry.getActiveSeriesMetric(SYNC_KEY)).toStrictEqual({
'service.name': 'flagd',
});
});
});

View File

@@ -1,62 +0,0 @@
/* eslint-disable */
//@ts-nocheck
import { memo } from 'react';
import ForceGraph2D from 'react-force-graph-2d';
import { useIsDarkMode } from 'hooks/useDarkMode';
import { getGraphData, getTooltip, transformLabel } from './utils';
function ServiceMap({ fgRef, serviceMap }: any): JSX.Element {
const isDarkMode = useIsDarkMode();
const { nodes, links } = getGraphData(serviceMap, isDarkMode);
const graphData = { nodes, links };
let zoomLevel = 1;
return (
<ForceGraph2D
ref={fgRef}
cooldownTicks={100}
graphData={graphData}
linkLabel={getTooltip}
linkAutoColorBy={(d) => d.target}
linkDirectionalParticles="value"
linkDirectionalParticleSpeed={(d) => d.value}
nodeCanvasObject={(node, ctx) => {
const label = transformLabel(node.id, zoomLevel);
let { fontSize } = node;
fontSize = (fontSize * 3) / zoomLevel;
ctx.font = `${fontSize}px Roboto`;
const { width } = node;
ctx.fillStyle = node.color;
ctx.beginPath();
ctx.arc(node.x, node.y, width, 0, 2 * Math.PI, false);
ctx.fill();
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillStyle = isDarkMode ? '#ffffff' : '#000000';
ctx.fillText(label, node.x, node.y);
}}
onLinkHover={(node) => {
const tooltip = document.querySelector('.graph-tooltip');
if (tooltip && node) {
tooltip.innerHTML = getTooltip(node);
}
}}
onZoom={(zoom) => {
zoomLevel = zoom.k;
}}
nodePointerAreaPaint={(node, color, ctx) => {
ctx.fillStyle = color;
ctx.beginPath();
ctx.arc(node.x, node.y, 5, 0, 2 * Math.PI, false);
ctx.fill();
}}
/>
);
}
export default memo(ServiceMap);

View File

@@ -1,6 +1,6 @@
//@ts-nocheck
import { useEffect, useRef } from 'react';
import { useEffect } from 'react';
// eslint-disable-next-line no-restricted-imports
import { connect } from 'react-redux';
import { RouteComponentProps, withRouter } from 'react-router-dom';
@@ -16,27 +16,9 @@ import { AppState } from 'store/reducers';
import styled from 'styled-components';
import { GlobalTime } from 'types/actions/globalTime';
import Map from './Map';
import Map from './components/Map/Map';
const Container = styled.div`
.force-graph-container {
overflow: scroll;
}
.force-graph-container .graph-tooltip {
background: black;
padding: 1px;
.keyval {
display: flex;
.key {
margin-right: 4px;
}
.val {
margin-left: auto;
}
}
}
`;
const Container = styled.div``;
interface ServiceMapProps extends RouteComponentProps<any> {
serviceMap: ServiceMapStore;
@@ -48,7 +30,8 @@ interface ServiceMapProps extends RouteComponentProps<any> {
}
interface graphNode {
id: string;
group: number;
color: string;
name: string;
}
interface graphLink {
source: string;
@@ -64,8 +47,6 @@ export interface graphDataType {
}
function ServiceMap(props: ServiceMapProps): JSX.Element {
const fgRef = useRef();
const { getDetailedServiceMapItems, globalTime, serviceMap } = props;
const { queries } = useResourceAttribute();
@@ -78,10 +59,6 @@ function ServiceMap(props: ServiceMapProps): JSX.Element {
getDetailedServiceMapItems(globalTime, queries);
}, [globalTime, getDetailedServiceMapItems, queries]);
useEffect(() => {
fgRef.current && fgRef.current.d3Force('charge').strength(-400);
});
if (serviceMap.loading) {
return <Spinner size="large" tip="Loading..." />;
}
@@ -108,7 +85,7 @@ function ServiceMap(props: ServiceMapProps): JSX.Element {
}
/>
<Map fgRef={fgRef} serviceMap={serviceMap} />
<Map serviceMap={serviceMap} />
</div>
);
}

View File

@@ -0,0 +1,89 @@
import { BaseEdge, Edge, EdgeProps, getBezierPath } from '@xyflow/react';
import { getParticleAnimation } from '../Map/Map.constants';
export interface FlowEdgeData extends Record<string, unknown> {
p99: number;
callRate: number;
errorRate: number;
particleColor: string;
maxCallRate: number;
}
const DEFAULT_PARTICLE_COLOR = 'var(--accent-primary)';
function FlowEdge({
id,
sourceX,
sourceY,
targetX,
targetY,
sourcePosition,
targetPosition,
style,
markerEnd,
data,
}: EdgeProps<Edge<FlowEdgeData>>): JSX.Element {
const [edgePath] = getBezierPath({
sourceX,
sourceY,
targetX,
targetY,
sourcePosition,
targetPosition,
});
// Particles flow callee -> caller (child -> parent), opposite to the edge's
// source -> target direction. Computing a reversed bezier instead of just
// playing the same path backward keeps the curve handles correct on both
// ends and avoids relying on `keyPoints`/`calcMode` quirks.
const [particlePath] = getBezierPath({
sourceX: targetX,
sourceY: targetY,
targetX: sourceX,
targetY: sourceY,
sourcePosition: targetPosition,
targetPosition: sourcePosition,
});
const callRate = data?.callRate ?? 0;
const maxCallRate = data?.maxCallRate ?? 0;
const { particleCount, duration } = getParticleAnimation(
callRate,
maxCallRate,
);
const fill = data?.particleColor || DEFAULT_PARTICLE_COLOR;
// Stagger each particle's `begin` so they're evenly distributed around the
// loop; the result is a continuous moving stream rather than synchronized
// dots stacking on top of each other.
const particles = Array.from({ length: particleCount }, (_, i) => {
const offset = (duration * i) / particleCount;
return (
<circle
key={`${id}-p${i}`}
className="flow-edge__particle"
r={2.75}
fill={fill}
pointerEvents="none"
>
<animateMotion
dur={`${duration}s`}
begin={`-${offset.toFixed(3)}s`}
repeatCount="indefinite"
path={particlePath}
rotate="auto"
/>
</circle>
);
});
return (
<>
<BaseEdge id={id} path={edgePath} style={style} markerEnd={markerEnd} />
{particles}
</>
);
}
export default FlowEdge;

View File

@@ -0,0 +1,27 @@
.tooltip {
position: fixed;
pointer-events: none;
z-index: 1000;
padding: 12px;
min-width: 160px;
font-size: 12px;
font-family: Inter, sans-serif;
border-radius: 4px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
color: var(--popover-foreground);
background: var(--popover);
border: 1px solid var(--secondary-border);
}
.row {
display: flex;
justify-content: space-between;
}
.label {
margin-right: 8px;
}
.value {
margin-left: auto;
}

View File

@@ -0,0 +1,39 @@
import styles from './LinkTooltip.module.scss';
export interface LinkTooltipData {
p99: string | number;
callRate: string | number;
errorRate: string | number;
}
export interface LinkTooltipProps {
tooltip: LinkTooltipData;
x: number;
y: number;
}
const POINTER_OFFSET = 12;
function LinkTooltip({ tooltip, x, y }: LinkTooltipProps): JSX.Element {
return (
<div
className={styles.tooltip}
style={{ top: y + POINTER_OFFSET, left: x + POINTER_OFFSET }}
>
<div className={styles.row}>
<span className={styles.label}>P99 latency:</span>
<span className={styles.value}>{tooltip.p99}ms</span>
</div>
<div className={styles.row}>
<span className={styles.label}>Request:</span>
<span className={styles.value}>{tooltip.callRate}/sec</span>
</div>
<div className={styles.row}>
<span className={styles.label}>Error Rate:</span>
<span className={styles.value}>{tooltip.errorRate}%</span>
</div>
</div>
);
}
export default LinkTooltip;

View File

@@ -0,0 +1,41 @@
// Geometry of a service node as drawn on the map. The dagre layout uses a
// taller bounding box (label + circle) than the circle itself, so the outer
// height is exposed for the position centering calc.
export const NODE_DIAMETER = 64;
export const LABEL_HEIGHT = 18;
export const NODE_LABEL_GAP = 6;
export const NODE_OUTER_HEIGHT = NODE_DIAMETER + LABEL_HEIGHT + NODE_LABEL_GAP;
// Per-edge animated stream of dots. Speed and particle count scale with the
// edge's call rate *relative to the busiest edge in the current graph*, on a
// log10 ladder. The busiest edge always pegs the fastest/most-dense
// visualisation; the slowest gets a single drifting particle. This keeps the
// stream legible whether the busiest service handles 5 req/sec or 5k.
export const PARTICLE_FAST_SECS = 0.6;
export const PARTICLE_SLOW_SECS = 5;
export const MAX_PARTICLES = 8;
// Compute particle count + per-loop duration for an edge's call rate, scaled
// against the max call rate observed across the graph. Pure so it can be
// unit-tested without rendering the edge.
export function getParticleAnimation(
callRate: number,
maxCallRate: number,
): { particleCount: number; duration: number } {
if (callRate <= 0) {
return { particleCount: 0, duration: PARTICLE_SLOW_SECS };
}
// Defensive: if a stale/zero max sneaks in, treat this edge as the max so
// `factor` stays in [0, 1] rather than going to Infinity or NaN.
const effectiveMax = Math.max(maxCallRate, callRate);
const logRate = Math.log10(callRate + 1);
const logMax = Math.log10(effectiveMax + 1);
const factor = logMax > 0 ? logRate / logMax : 1;
const duration =
PARTICLE_SLOW_SECS - factor * (PARTICLE_SLOW_SECS - PARTICLE_FAST_SECS);
const particleCount = Math.max(
1,
Math.min(MAX_PARTICLES, Math.ceil(factor * MAX_PARTICLES)),
);
return { particleCount, duration };
}

View File

@@ -0,0 +1,21 @@
.container {
width: 100%;
height: calc(100vh - 124px);
position: relative;
border: 1px solid var(--l3-border);
border-radius: 8px;
overflow: hidden;
// ReactFlow defaults edge pointer-events to `visibleStroke`, which means
// our thin dashed line only captures hover on the painted dash segments.
// Force `stroke` on the wide invisible interaction path so the entire edge
// length is hoverable for the tooltip.
:global(.react-flow__edge-interaction) {
pointer-events: stroke;
cursor: default;
}
:global(.react-flow__edge) {
pointer-events: stroke;
}
}

View File

@@ -0,0 +1,185 @@
import '@xyflow/react/dist/style.css';
import { memo, useEffect, useMemo, useState } from 'react';
import {
Background,
BackgroundVariant,
Controls,
Edge,
Node,
ReactFlow,
useEdgesState,
useNodesState,
} from '@xyflow/react';
import { useIsDarkMode } from 'hooks/useDarkMode';
import FlowEdge, { FlowEdgeData } from '../FlowEdge/FlowEdge';
import { NODE_DIAMETER, NODE_OUTER_HEIGHT } from './Map.constants';
import styles from './Map.module.scss';
import ServiceNode, { ServiceNodeData } from '../ServiceNode/ServiceNode';
import LinkTooltip from '../LinkTooltip/LinkTooltip';
import {
computeNodePositions,
getEdgeColor,
getGraphData,
getLinkTooltip,
LinkTooltip as LinkTooltipData,
} from '../../utils';
const nodeTypes = { service: ServiceNode };
const edgeTypes = { flow: FlowEdge };
const PARTICLE_COLOR = 'var(--l1-foreground)';
const BG_COLOR = 'var(--l2-background)';
const BASE_EDGE_STYLE = {
strokeWidth: 1.25,
strokeDasharray: '5 4',
};
interface HoverState {
tooltip: LinkTooltipData;
x: number;
y: number;
}
function ServiceMap({ serviceMap }: any): JSX.Element {
const isDarkMode = useIsDarkMode();
const [hovered, setHovered] = useState<HoverState | null>(null);
const { nodes: rawNodes, links } = useMemo(
() => getGraphData(serviceMap, isDarkMode),
[serviceMap, isDarkMode],
);
const positions = useMemo(
() => computeNodePositions(rawNodes, links),
[rawNodes, links],
);
const initialNodes: Node<ServiceNodeData>[] = useMemo(
() =>
rawNodes.map((node) => {
const center = positions[node.id] ?? { x: 0, y: 0 };
return {
id: node.id,
type: 'service',
// `position` is the top-left of the node bounding box; centre the
// circle on the simulated coordinate.
position: {
x: center.x - NODE_DIAMETER / 2,
y: center.y - NODE_OUTER_HEIGHT / 2,
},
data: { label: node.id, color: node.color },
draggable: true,
selectable: false,
};
}),
[rawNodes, positions],
);
// Particle visualisation is scaled relative to the busiest edge in the
// current graph, so each render of the edge layer needs the per-graph max.
const maxCallRate = useMemo(
() => links.reduce((max, link) => Math.max(max, link.callRate ?? 0), 0),
[links],
);
const initialEdges: Edge<FlowEdgeData>[] = useMemo(
() =>
links.map((link, i) => ({
id: `${link.source}->${link.target}-${i}`,
source: link.source,
target: link.target,
type: 'flow',
data: {
p99: link.p99,
callRate: link.callRate,
errorRate: link.errorRate,
particleColor: PARTICLE_COLOR,
maxCallRate,
},
// markerEnd: EDGE_MARKER,
// Stroke is hashed off the target so edges sharing a destination read
// as a single visual fan-in (matches the pre-rewrite behaviour).
style: { ...BASE_EDGE_STYLE, stroke: getEdgeColor(link.target) },
})),
[links, maxCallRate],
);
const [flowNodes, setFlowNodes, onNodesChange] =
useNodesState<Node<ServiceNodeData>>(initialNodes);
const [flowEdges, setFlowEdges, onEdgesChange] =
useEdgesState<Edge<FlowEdgeData>>(initialEdges);
// Reset internal node/edge state when the source graph changes (filters,
// time range, theme). User drag positions during a stable graph are kept.
useEffect(() => {
setFlowNodes(initialNodes);
}, [initialNodes, setFlowNodes]);
useEffect(() => {
setFlowEdges(initialEdges);
}, [initialEdges, setFlowEdges]);
const handleEdgeMouseEnter = (event: React.MouseEvent, edge: Edge): void => {
setHovered({
tooltip: getLinkTooltip(edge.data as any),
x: event.clientX,
y: event.clientY,
});
};
const handleEdgeMouseMove = (event: React.MouseEvent, edge: Edge): void => {
setHovered((prev) =>
prev
? { ...prev, x: event.clientX, y: event.clientY }
: {
tooltip: getLinkTooltip(edge.data as any),
x: event.clientX,
y: event.clientY,
},
);
};
const handleEdgeMouseLeave = (): void => {
setHovered(null);
};
return (
<div className={styles.container}>
<ReactFlow
nodes={flowNodes}
edges={flowEdges}
onNodesChange={onNodesChange}
onEdgesChange={onEdgesChange}
nodeTypes={nodeTypes}
edgeTypes={edgeTypes}
fitView
minZoom={0.2}
maxZoom={4}
nodesDraggable
nodesConnectable={false}
elementsSelectable={false}
proOptions={{ hideAttribution: true }}
colorMode={isDarkMode ? 'dark' : 'light'}
onEdgeMouseEnter={handleEdgeMouseEnter}
onEdgeMouseMove={handleEdgeMouseMove}
onEdgeMouseLeave={handleEdgeMouseLeave}
>
<Background
bgColor={BG_COLOR}
variant={BackgroundVariant.Dots}
gap={24}
size={1}
/>
<Controls showInteractive={false} />
</ReactFlow>
{hovered && (
<LinkTooltip tooltip={hovered.tooltip} x={hovered.x} y={hovered.y} />
)}
</div>
);
}
export default memo(ServiceMap);

View File

@@ -0,0 +1,39 @@
.node {
display: flex;
flex-direction: column;
align-items: center;
overflow: visible;
}
.circle {
border-radius: 50%;
border: 1px solid var(--l3-border);
box-sizing: border-box;
position: relative;
display: flex;
align-items: center;
justify-content: center;
color: var(--l1-foreground);
}
.handle {
opacity: 0;
width: 1px;
height: 1px;
pointer-events: none;
border: none;
}
.label {
margin-top: 6px;
max-width: 120px;
font-size: 12px;
line-height: 14px;
text-align: center;
color: var(--l1-foreground);
font-family: Inter, sans-serif;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
user-select: none;
}

View File

@@ -0,0 +1,38 @@
import { Handle, Node, NodeProps, Position } from '@xyflow/react';
import { Cpu } from 'lucide-react';
import { NODE_DIAMETER } from '../Map/Map.constants';
import styles from './ServiceNode.module.scss';
// Icon takes ~35% of the circle diameter — large enough to read at typical
// zoom, small enough to leave the colored ring visible as the health signal.
const ICON_SIZE = Math.round(NODE_DIAMETER * 0.35);
export interface ServiceNodeData extends Record<string, unknown> {
label: string;
color: string;
}
function ServiceNode({ data }: NodeProps<Node<ServiceNodeData>>): JSX.Element {
return (
<div className={styles.node}>
<div
className={styles.circle}
style={{
width: NODE_DIAMETER,
height: NODE_DIAMETER,
background: data.color,
}}
>
<Cpu size={ICON_SIZE} strokeWidth={1.5} aria-hidden />
<Handle type="target" position={Position.Left} className={styles.handle} />
<Handle type="source" position={Position.Right} className={styles.handle} />
</div>
<div className={styles.label} title={data.label}>
{data.label}
</div>
</div>
);
}
export default ServiceNode;

View File

@@ -0,0 +1,185 @@
import { Position } from '@xyflow/react';
import { render } from '@testing-library/react';
import FlowEdge, { FlowEdgeData } from '../FlowEdge/FlowEdge';
// Stub BaseEdge / getBezierPath so assertions don't depend on the internal
// path geometry — we only care that FlowEdge wires its inputs through and
// renders the right number of particles for the given call rate.
jest.mock('@xyflow/react', () => {
const actual = jest.requireActual('@xyflow/react');
return {
...actual,
BaseEdge: ({
id,
path,
style,
markerEnd,
}: {
id: string;
path: string;
style?: React.CSSProperties;
markerEnd?: string;
}): JSX.Element => (
<path
data-testid="base-edge"
data-id={id}
data-path={path}
data-marker-end={markerEnd ?? ''}
style={style}
/>
),
// Encode the inputs into the returned path so tests can distinguish
// between the forward edge path and the reversed particle path.
getBezierPath: ({
sourceX,
sourceY,
targetX,
targetY,
}: {
sourceX: number;
sourceY: number;
targetX: number;
targetY: number;
}): [string, number, number, number, number] => [
`M${sourceX},${sourceY} L${targetX},${targetY}`,
(sourceX + targetX) / 2,
(sourceY + targetY) / 2,
0,
0,
],
};
});
const baseEdgeProps = {
id: 'edge-1',
source: 'a',
target: 'b',
sourceX: 0,
sourceY: 0,
targetX: 100,
targetY: 0,
sourcePosition: Position.Right,
targetPosition: Position.Left,
style: { stroke: '#000' },
markerEnd: 'url(#arrow)',
} as const;
function renderEdge(data: FlowEdgeData | undefined): ReturnType<typeof render> {
return render(<FlowEdge {...(baseEdgeProps as any)} data={data} />);
}
const SAMPLE_DATA: FlowEdgeData = {
p99: 1000000,
callRate: 25,
errorRate: 0,
particleColor: 'rgb(0, 200, 0)',
maxCallRate: 1000,
};
describe('FlowEdge', () => {
it('forwards id, path, style, and markerEnd to BaseEdge', () => {
const { getByTestId } = renderEdge(SAMPLE_DATA);
const baseEdge = getByTestId('base-edge');
expect(baseEdge).toHaveAttribute('data-id', 'edge-1');
// BaseEdge gets the forward path (source -> target).
expect(baseEdge).toHaveAttribute('data-path', 'M0,0 L100,0');
expect(baseEdge).toHaveAttribute('data-marker-end', 'url(#arrow)');
expect(baseEdge).toHaveStyle({ stroke: '#000' });
});
it('renders no particles when callRate is zero', () => {
const { container } = renderEdge({ ...SAMPLE_DATA, callRate: 0 });
expect(container.querySelectorAll('circle')).toHaveLength(0);
});
it('renders no particles when data is missing', () => {
const { container } = renderEdge(undefined);
expect(container.querySelectorAll('circle')).toHaveLength(0);
});
it('renders multiple staggered particles for mid-range traffic', () => {
// callRate=25 against maxCallRate=1000:
// factor = log10(26) / log10(1001) ≈ 0.4716
// particleCount = ceil(0.4716 * 8) = 4
const { container } = renderEdge({
...SAMPLE_DATA,
callRate: 25,
maxCallRate: 1000,
});
const circles = container.querySelectorAll('circle');
expect(circles).toHaveLength(4);
// Each particle's animateMotion `begin` should be a distinct negative
// offset; identical offsets would stack particles on top of each other.
const begins = Array.from(container.querySelectorAll('animateMotion')).map(
(el) => el.getAttribute('begin'),
);
expect(new Set(begins).size).toBe(begins.length);
begins.forEach((begin) => {
expect(begin).toMatch(/^-\d+\.\d{3}s$/);
});
});
it('saturates at MAX_PARTICLES (8) when the edge is the busiest in the graph', () => {
// Relative scaling: whichever absolute rate is the max pegs at 8.
const { container } = renderEdge({
...SAMPLE_DATA,
callRate: 50,
maxCallRate: 50,
});
expect(container.querySelectorAll('circle')).toHaveLength(8);
});
it('uses data.particleColor as the particle fill', () => {
const { container } = renderEdge({
...SAMPLE_DATA,
callRate: 5,
particleColor: 'rgb(123, 45, 67)',
});
const circle = container.querySelector('circle');
expect(circle).toHaveAttribute('fill', 'rgb(123, 45, 67)');
});
it('falls back to the default particle color when particleColor is empty', () => {
const { container } = renderEdge({
...SAMPLE_DATA,
callRate: 5,
particleColor: '',
});
const circle = container.querySelector('circle');
expect(circle).toHaveAttribute('fill', 'var(--accent-primary)');
});
it('marks particles as non-interactive so they do not show a grab cursor', () => {
// Without pointer-events:none, react-flow's edge layer cursor (grab)
// cascades onto the SVG circles. Particles are decorative.
const { container } = renderEdge({ ...SAMPLE_DATA, callRate: 5 });
const circles = container.querySelectorAll('circle');
expect(circles.length).toBeGreaterThan(0);
circles.forEach((circle) => {
expect(circle).toHaveAttribute('pointer-events', 'none');
});
});
it('animates particles along the reversed path so they flow callee -> caller', () => {
// Edge goes (0,0) -> (100,0) but particles must travel back the other
// way to visualise the call-graph response direction.
const { container } = renderEdge({ ...SAMPLE_DATA, callRate: 5 });
const motions = container.querySelectorAll('animateMotion');
expect(motions.length).toBeGreaterThan(0);
motions.forEach((el) => {
expect(el).toHaveAttribute('path', 'M100,0 L0,0');
expect(el).toHaveAttribute('repeatCount', 'indefinite');
});
});
});

View File

@@ -0,0 +1,58 @@
import { render, screen } from '@testing-library/react';
import LinkTooltip, { LinkTooltipData } from '../LinkTooltip/LinkTooltip';
const baseTooltip: LinkTooltipData = {
p99: 12.34,
callRate: 5.6,
errorRate: 0.1,
};
describe('LinkTooltip', () => {
it('renders p99, request, and error rate rows with their suffixes', () => {
render(<LinkTooltip tooltip={baseTooltip} x={0} y={0} />);
expect(screen.getByText('P99 latency:')).toBeInTheDocument();
expect(screen.getByText('12.34ms')).toBeInTheDocument();
expect(screen.getByText('Request:')).toBeInTheDocument();
expect(screen.getByText('5.6/sec')).toBeInTheDocument();
expect(screen.getByText('Error Rate:')).toBeInTheDocument();
expect(screen.getByText('0.1%')).toBeInTheDocument();
});
it('renders string-typed metric values verbatim', () => {
render(
<LinkTooltip
tooltip={{ p99: '0', callRate: '0', errorRate: '0' }}
x={0}
y={0}
/>,
);
expect(screen.getByText('0ms')).toBeInTheDocument();
expect(screen.getByText('0/sec')).toBeInTheDocument();
expect(screen.getByText('0%')).toBeInTheDocument();
});
it('positions itself offset from the cursor coordinates', () => {
const { container } = render(
<LinkTooltip tooltip={baseTooltip} x={100} y={200} />,
);
// POINTER_OFFSET is 12 in the component; the tooltip should sit at
// (x + 12, y + 12) so it does not occlude the hovered edge segment.
const tooltip = container.firstChild as HTMLElement;
expect(tooltip).toHaveStyle({ top: '212px', left: '112px' });
});
it('handles negative coordinates without breaking the offset math', () => {
const { container } = render(
<LinkTooltip tooltip={baseTooltip} x={-50} y={-30} />,
);
const tooltip = container.firstChild as HTMLElement;
expect(tooltip).toHaveStyle({ top: '-18px', left: '-38px' });
});
});

View File

@@ -0,0 +1,91 @@
import {
getParticleAnimation,
MAX_PARTICLES,
PARTICLE_FAST_SECS,
PARTICLE_SLOW_SECS,
} from '../Map/Map.constants';
describe('getParticleAnimation', () => {
it('returns zero particles and the slow duration for non-positive call rates', () => {
expect(getParticleAnimation(0, 1000)).toStrictEqual({
particleCount: 0,
duration: PARTICLE_SLOW_SECS,
});
expect(getParticleAnimation(-5, 1000)).toStrictEqual({
particleCount: 0,
duration: PARTICLE_SLOW_SECS,
});
});
it('produces at least one particle for any positive call rate', () => {
expect(getParticleAnimation(0.1, 1000).particleCount).toBeGreaterThanOrEqual(
1,
);
expect(getParticleAnimation(1, 1000).particleCount).toBeGreaterThanOrEqual(1);
});
it('saturates at MAX_PARTICLES and PARTICLE_FAST_SECS when callRate equals max', () => {
// Whatever the absolute scale, the busiest edge should peg the
// visualisation — that's the point of the relative scaling.
const EPS = 1e-9;
[5, 50, 500, 5_000, 1_000_000].forEach((rate) => {
const { particleCount, duration } = getParticleAnimation(rate, rate);
expect(particleCount).toBe(MAX_PARTICLES);
expect(duration).toBeGreaterThanOrEqual(PARTICLE_FAST_SECS - EPS);
expect(duration).toBeLessThanOrEqual(PARTICLE_FAST_SECS + EPS);
});
});
it('caps particleCount at MAX_PARTICLES even if max is stale or zero', () => {
// Defensive: if max somehow lags behind callRate, factor still clamps to 1.
expect(getParticleAnimation(1000, 0).particleCount).toBe(MAX_PARTICLES);
expect(getParticleAnimation(1000, 100).particleCount).toBe(MAX_PARTICLES);
});
it('produces different particle counts for the same callRate at different scales', () => {
// 50 req/sec is "busy" in a 50-max graph but "trickle" in a 5k-max graph.
const busy = getParticleAnimation(50, 50);
const trickle = getParticleAnimation(50, 5000);
expect(busy.particleCount).toBeGreaterThan(trickle.particleCount);
expect(busy.duration).toBeLessThan(trickle.duration);
});
it('monotonically increases particle count as rate climbs toward max', () => {
const max = 5000;
const rates = [0.5, 5, 50, 500, max];
const counts = rates.map((r) => getParticleAnimation(r, max).particleCount);
for (let i = 1; i < counts.length; i += 1) {
expect(counts[i]).toBeGreaterThanOrEqual(counts[i - 1]);
}
});
it('monotonically decreases per-loop duration as rate climbs toward max', () => {
const max = 5000;
const rates = [0.5, 5, 50, 500, max];
const durations = rates.map((r) => getParticleAnimation(r, max).duration);
for (let i = 1; i < durations.length; i += 1) {
expect(durations[i]).toBeLessThanOrEqual(durations[i - 1]);
}
});
it('keeps duration bounded between PARTICLE_FAST_SECS and PARTICLE_SLOW_SECS', () => {
// At saturation the formula computes to PARTICLE_FAST_SECS up to
// floating-point error (~1e-16), so allow a small epsilon.
const EPS = 1e-9;
const cases: Array<[number, number]> = [
[0, 1000],
[0.01, 1000],
[1, 1000],
[10, 1000],
[100, 1000],
[1000, 1000],
[1000, 0], // defensive max
[1_000_000, 1_000_000],
];
cases.forEach(([rate, max]) => {
const { duration } = getParticleAnimation(rate, max);
expect(duration).toBeGreaterThanOrEqual(PARTICLE_FAST_SECS - EPS);
expect(duration).toBeLessThanOrEqual(PARTICLE_SLOW_SECS + EPS);
});
});
});

View File

@@ -0,0 +1,93 @@
import { render, screen } from '@testing-library/react';
import ServiceNode from '../ServiceNode/ServiceNode';
import { NODE_DIAMETER } from '../Map/Map.constants';
// `Handle` requires a ReactFlowProvider to mount. We don't exercise its
// connection logic from this component, so a stub keeps the test isolated to
// ServiceNode's own rendering responsibilities.
jest.mock('@xyflow/react', () => {
const actual = jest.requireActual('@xyflow/react');
return {
...actual,
Handle: ({
type,
position,
}: {
type: string;
position: string;
}): JSX.Element => (
<div data-testid={`handle-${type}`} data-position={position} />
),
};
});
const baseNodeProps = {
id: 'frontend',
type: 'service',
dragging: false,
isConnectable: true,
positionAbsoluteX: 0,
positionAbsoluteY: 0,
zIndex: 0,
selectable: false,
deletable: false,
draggable: true,
selected: false,
} as const;
function renderNode(data: {
label: string;
color: string;
}): ReturnType<typeof render> {
return render(<ServiceNode {...(baseNodeProps as any)} data={data} />);
}
describe('ServiceNode', () => {
it('renders the label text from data', () => {
renderNode({ label: 'checkout-service', color: 'red' });
expect(screen.getByText('checkout-service')).toBeInTheDocument();
});
it('exposes the label as a title attribute for full-name hover-disclosure', () => {
// The visible label is truncated for layout, so the full service name is
// surfaced via title — assert the attribute round-trips data.label.
renderNode({
label: 'a-very-long-service-name-that-truncates',
color: 'red',
});
const label = screen.getByText('a-very-long-service-name-that-truncates');
expect(label).toHaveAttribute(
'title',
'a-very-long-service-name-that-truncates',
);
});
it('applies data.color as the circle background and uses the configured diameter', () => {
// All nodes render at NODE_DIAMETER — there is no per-node sizing.
const { container } = renderNode({
label: 'frontend',
color: 'rgb(255, 0, 0)',
});
const wrapper = container.firstChild as HTMLElement;
const circle = wrapper.firstChild as HTMLElement;
expect(circle).toHaveStyle({
background: 'rgb(255, 0, 0)',
width: `${NODE_DIAMETER}px`,
height: `${NODE_DIAMETER}px`,
});
});
it('renders a target handle on the left and a source handle on the right', () => {
renderNode({ label: 'frontend', color: 'red' });
const target = screen.getByTestId('handle-target');
const source = screen.getByTestId('handle-source');
expect(target).toHaveAttribute('data-position', 'left');
expect(source).toHaveAttribute('data-position', 'right');
});
});

View File

@@ -1,5 +1,8 @@
//@ts-nocheck
import dagre from '@dagrejs/dagre';
import { themeColors } from 'constants/theme';
import { generateColor } from 'lib/uPlotLib/utils/generateColor';
import {
cloneDeep,
find,
@@ -12,27 +15,7 @@ import {
import { graphDataType } from './ServiceMap';
const MIN_WIDTH = 10;
const MAX_WIDTH = 20;
const DEFAULT_FONT_SIZE = 6;
export const getDimensions = (
num: number,
highest: number,
): {
fontSize: number;
width: number;
} => {
const percentage = (num / highest) * 100;
const width = (percentage * (MAX_WIDTH - MIN_WIDTH)) / 100 + MIN_WIDTH;
const fontSize = DEFAULT_FONT_SIZE;
return {
fontSize,
width,
};
};
export const getGraphData = (serviceMap, isDarkMode): graphDataType => {
export const getGraphData = (serviceMap, _isDarkMode): graphDataType => {
const { items } = serviceMap;
const services = Object.values(groupBy(items, 'child')).map((e) => {
return {
@@ -42,7 +25,6 @@ export const getGraphData = (serviceMap, isDarkMode): graphDataType => {
};
});
const highestCallCount = maxBy(items, (e) => e?.callCount)?.callCount;
const highestCallRate = maxBy(services, (e) => e?.callRate)?.callRate;
const divNum = Number(
String(1).padEnd(highestCallCount.toString().length, '0'),
@@ -62,31 +44,19 @@ export const getGraphData = (serviceMap, isDarkMode): graphDataType => {
const uniqParent = uniqBy(cloneDeep(items), 'parent').map((e) => e.parent);
const uniqChild = uniqBy(cloneDeep(items), 'child').map((e) => e.child);
const uniqNodes = uniq([...uniqParent, ...uniqChild]);
const nodes = uniqNodes.map((node, i) => {
// Semantic tokens auto-flip with theme; passed as CSS variable strings so
// the consuming component can apply them directly via `style.background`.
const HEALTHY_COLOR = 'var(--l3-background)';
const ERROR_COLOR = 'var(--danger-background)';
const nodes = uniqNodes.map((node) => {
const service = find(services, (service) => service.serviceName === node);
let color = isDarkMode ? '#7CA568' : '#D5F2BB';
if (!service) {
return {
id: node,
group: i + 1,
fontSize: DEFAULT_FONT_SIZE,
width: MIN_WIDTH,
color,
nodeVal: MIN_WIDTH,
name: node,
};
let color = HEALTHY_COLOR;
if (service && service.errorRate > 0) {
color = ERROR_COLOR;
}
if (service.errorRate > 0) {
color = isDarkMode ? '#DB836E' : '#F98989';
}
const { fontSize, width } = getDimensions(service.callRate, highestCallRate);
return {
id: node,
group: i + 1,
fontSize,
width,
color,
nodeVal: width,
name: node,
};
});
@@ -96,20 +66,6 @@ export const getGraphData = (serviceMap, isDarkMode): graphDataType => {
};
};
export const getZoomPx = (): number => {
const { width } = window.screen;
if (width < 1400) {
return 190;
}
if (width > 1400 && width < 1700) {
return 380;
}
if (width > 1700) {
return 470;
}
return 190;
};
const getRound2DigitsAfterDecimal = (num: number): number => {
if (num === 0) {
return 0;
@@ -117,27 +73,28 @@ const getRound2DigitsAfterDecimal = (num: number): number => {
return num.toFixed(20).match(/^-?\d*\.?0*\d{0,2}/)[0];
};
export const getTooltip = (link: {
export interface LinkTooltip {
p99: string | number;
callRate: string | number;
errorRate: string | number;
}
export const getLinkTooltip = (link: {
p99: number;
errorRate: number;
callRate: number;
id: string;
}): string => {
return `<div style="color:#333333;padding:12px;background: white;border-radius: 2px;">
<div class="keyval">
<div class="key">P99 latency:</div>
<div class="val">${getRound2DigitsAfterDecimal(link.p99 / 1000000)}ms</div>
</div>
<div class="keyval">
<div class="key">Request:</div>
<div class="val">${getRound2DigitsAfterDecimal(link.callRate)}/sec</div>
</div>
<div class="keyval">
<div class="key">Error Rate:</div>
<div class="val">${getRound2DigitsAfterDecimal(link.errorRate)}%</div>
</div>
</div>`;
};
}): LinkTooltip => ({
p99: getRound2DigitsAfterDecimal(link.p99 / 1000000),
callRate: getRound2DigitsAfterDecimal(link.callRate),
errorRate: getRound2DigitsAfterDecimal(link.errorRate),
});
// Edges share a color with every other edge pointing at the same destination
// service (mirrors the original `linkAutoColorBy={(d) => d.target}` behaviour).
// Hashing onto the existing chart palette keeps it visually consistent with
// the rest of the app and stable across renders for a given target id.
export const getEdgeColor = (targetId: string): string =>
generateColor(targetId, themeColors.chartcolors);
export const transformLabel = (label: string, zoomLevel: number): string => {
//? 13 is the minimum label length. Scaling factor of 0.9 which is slightly less than 1
@@ -150,3 +107,51 @@ export const transformLabel = (label: string, zoomLevel: number): string => {
}
return label;
};
// Layered DAG layout via dagre. For service maps the data flows
// caller -> callee, so a left-to-right rank direction reads naturally and
// minimises edge crossings vs. a force-directed simulation.
//
// `nodeBoxWidth` reserves space for the label rendered below each circle —
// the visible label can be up to ~120px wide, so dagre needs to know that
// horizontally adjacent ranks must keep that distance.
export const computeNodePositions = (
nodes: { id: string }[],
links: { source: string; target: string }[],
nodeBoxWidth = 130,
nodeBoxHeight = 100,
): Record<string, { x: number; y: number }> => {
const result: Record<string, { x: number; y: number }> = {};
if (nodes.length === 0) {
return result;
}
const g = new dagre.graphlib.Graph({ multigraph: true, compound: false });
g.setGraph({
rankdir: 'LR',
nodesep: 40,
ranksep: 90,
marginx: 40,
marginy: 40,
});
g.setDefaultEdgeLabel(() => ({}));
nodes.forEach((node) => {
g.setNode(node.id, { width: nodeBoxWidth, height: nodeBoxHeight });
});
links.forEach((link, i) => {
// `name` makes parallel edges (same source+target, different metrics)
// safe under multigraph mode.
g.setEdge(link.source, link.target, {}, `${link.source}-${link.target}-${i}`);
});
dagre.layout(g);
nodes.forEach((node) => {
const laidOut = g.node(node.id);
if (laidOut) {
result[node.id] = { x: laidOut.x, y: laidOut.y };
}
});
return result;
};

View File

@@ -19,6 +19,7 @@ export type ServerError = 500;
export type SuccessStatusCode = Created | Success | SuccessNoContent;
export type ErrorStatusCode =
| Forbidden
| Forbidden
| Unauthorized
| NotFound

View File

@@ -1,22 +0,0 @@
type Builtin =
| string
| number
| boolean
| bigint
| symbol
| null
| undefined
| Function // eslint-disable-line
| Date
| RegExp
| Error;
export type DeepPartial<T> = T extends Builtin
? T
: T extends Array<infer U>
? Array<DeepPartial<U>>
: T extends ReadonlyArray<infer U>
? ReadonlyArray<DeepPartial<U>>
: T extends object
? { [K in keyof T]?: DeepPartial<T[K]> }
: T;

View File

@@ -2953,6 +2953,18 @@
resolved "https://registry.yarnpkg.com/@ctrl/tinycolor/-/tinycolor-3.6.1.tgz#b6c75a56a1947cc916ea058772d666a2c8932f31"
integrity sha512-SITSV6aIXsuVNV3f3O0f2n/cgyEDWoSqtZMYiAmcsYHydcKrOz3gUxB/iXd/Qf08+IZX4KpgNbvUdMBmWz+kcA==
"@dagrejs/dagre@3.0.0":
version "3.0.0"
resolved "https://registry.yarnpkg.com/@dagrejs/dagre/-/dagre-3.0.0.tgz#543f20188f7494db0f45d634f7b3760747f87f23"
integrity sha512-ZzhnTy1rfuoew9Ez3EIw4L2znPGnYYhfn8vc9c4oB8iw6QAsszbiU0vRhlxWPFnmmNSFAkrYeF1PhM5m4lAN0Q==
dependencies:
"@dagrejs/graphlib" "4.0.1"
"@dagrejs/graphlib@4.0.1":
version "4.0.1"
resolved "https://registry.yarnpkg.com/@dagrejs/graphlib/-/graphlib-4.0.1.tgz#a9cf907cc5ddf9140a64360ad487766f17d1ee36"
integrity sha512-IvcV6FduIIAmLwnH+yun+QtV36SC7mERqa86aClNqmMN09WhmPPYU8ckHrZBozErf+UvHPWOTJYaGYiIcs0DgA==
"@date-fns/tz@^1.4.1":
version "1.4.1"
resolved "https://registry.yarnpkg.com/@date-fns/tz/-/tz-1.4.1.tgz#2d905f282304630e07bef6d02d2e7dbf3f0cc4e4"
@@ -6002,11 +6014,6 @@
resolved "https://registry.npmjs.org/@tsconfig/node16/-/node16-1.0.3.tgz"
integrity sha512-yOlFc+7UtL/89t2ZhjPvvB/DeAr3r+Dq58IgzsFkOAvVC6NMJXmCGjbptdXdR9qsX7pKcTL+s87FtYREi2dEEQ==
"@tweenjs/tween.js@18 - 25":
version "25.0.0"
resolved "https://registry.yarnpkg.com/@tweenjs/tween.js/-/tween.js-25.0.0.tgz#7266baebcc3affe62a3a54318a3ea82d904cd0b9"
integrity sha512-XKLA6syeBUaPzx4j3qwMqzzq+V4uo72BnlbOjmuljLrRqdsd3qnzvZZoxvMHZ23ndsRS4aufU6JOZYpCbU6T1A==
"@tybys/wasm-util@^0.10.0", "@tybys/wasm-util@^0.10.1":
version "0.10.1"
resolved "https://registry.yarnpkg.com/@tybys/wasm-util/-/wasm-util-0.10.1.tgz#ecddd3205cf1e2d5274649ff0eedd2991ed7f414"
@@ -6124,6 +6131,13 @@
resolved "https://registry.yarnpkg.com/@types/d3-delaunay/-/d3-delaunay-6.0.1.tgz#006b7bd838baec1511270cb900bf4fc377bbbf41"
integrity sha512-tLxQ2sfT0p6sxdG75c6f/ekqxjyYR0+LwPrsO1mbC9YDBzPJhs2HbJJRrn8Ez1DBoHRo2yx7YEATI+8V1nGMnQ==
"@types/d3-drag@^3.0.7":
version "3.0.7"
resolved "https://registry.yarnpkg.com/@types/d3-drag/-/d3-drag-3.0.7.tgz#b13aba8b2442b4068c9a9e6d1d82f8bcea77fc02"
integrity sha512-HE3jVKlzU9AaMazNufooRJ5ZpWmLIoc90A37WU2JMmeq28w1FQqCZswHZ3xR+SuxYftzHq6WU6KJHvqxKzTxxQ==
dependencies:
"@types/d3-selection" "*"
"@types/d3-format@3.0.1":
version "3.0.1"
resolved "https://registry.yarnpkg.com/@types/d3-format/-/d3-format-3.0.1.tgz#194f1317a499edd7e58766f96735bdc0216bb89d"
@@ -6141,6 +6155,13 @@
resolved "https://registry.yarnpkg.com/@types/d3-hierarchy/-/d3-hierarchy-1.1.11.tgz#c3bd70d025621f73cb3319e97e08ae4c9051c791"
integrity sha512-lnQiU7jV+Gyk9oQYk0GGYccuexmQPTp08E0+4BidgFdiJivjEvf+esPSdZqCZ2C7UwTWejWpqetVaU8A+eX3FA==
"@types/d3-interpolate@*", "@types/d3-interpolate@^3.0.4":
version "3.0.4"
resolved "https://registry.yarnpkg.com/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz#412b90e84870285f2ff8a846c6eb60344f12a41c"
integrity sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==
dependencies:
"@types/d3-color" "*"
"@types/d3-interpolate@3.0.1", "@types/d3-interpolate@^3.0.0":
version "3.0.1"
resolved "https://registry.yarnpkg.com/@types/d3-interpolate/-/d3-interpolate-3.0.1.tgz#e7d17fa4a5830ad56fe22ce3b4fac8541a9572dc"
@@ -6160,6 +6181,11 @@
dependencies:
"@types/d3-time" "*"
"@types/d3-selection@*", "@types/d3-selection@^3.0.10":
version "3.0.11"
resolved "https://registry.yarnpkg.com/@types/d3-selection/-/d3-selection-3.0.11.tgz#bd7a45fc0a8c3167a631675e61bc2ca2b058d4a3"
integrity sha512-bhAXu23DJWsrI45xafYpkQ4NtcKMwWnAC/vKrd2l+nxMFuvOT3XMYTIj2opv8vq8AO5Yh7Qac/nSeP/3zjTK0w==
"@types/d3-shape@^1.3.1":
version "1.3.12"
resolved "https://registry.yarnpkg.com/@types/d3-shape/-/d3-shape-1.3.12.tgz#8f2f9f7a12e631ce6700d6d55b84795ce2c8b259"
@@ -6182,6 +6208,21 @@
resolved "https://registry.yarnpkg.com/@types/d3-time/-/d3-time-3.0.0.tgz#e1ac0f3e9e195135361fa1a1d62f795d87e6e819"
integrity sha512-sZLCdHvBUcNby1cB6Fd3ZBrABbjz3v1Vm90nysCQ6Vt7vd6e/h9Lt7SiJUoEX0l4Dzc7P5llKyhqSi1ycSf1Hg==
"@types/d3-transition@^3.0.8":
version "3.0.9"
resolved "https://registry.yarnpkg.com/@types/d3-transition/-/d3-transition-3.0.9.tgz#1136bc57e9ddb3c390dccc9b5ff3b7d2b8d94706"
integrity sha512-uZS5shfxzO3rGlu0cC3bjmMFKsXv+SmZZcgp0KD22ts4uGXp5EVYGzu/0YdwZeKmddhcAccYtREJKkPfXkZuCg==
dependencies:
"@types/d3-selection" "*"
"@types/d3-zoom@^3.0.8":
version "3.0.8"
resolved "https://registry.yarnpkg.com/@types/d3-zoom/-/d3-zoom-3.0.8.tgz#dccb32d1c56b1e1c6e0f1180d994896f038bc40b"
integrity sha512-iqMC4/YlFCSlO8+2Ii1GGGliCAY4XdeG748w5vQUbevlbDu0zSjH/+jojorQVBK/se0j6DUFNPBGSqD3YWYnDw==
dependencies:
"@types/d3-interpolate" "*"
"@types/d3-selection" "*"
"@types/debug@^4.0.0":
version "4.1.8"
resolved "https://registry.yarnpkg.com/@types/debug/-/debug-4.1.8.tgz#cef723a5d0a90990313faec2d1e22aee5eecb317"
@@ -7072,6 +7113,30 @@
resolved "https://registry.npmjs.org/@xobotyi/scrollbar-width/-/scrollbar-width-1.9.5.tgz"
integrity sha512-N8tkAACJx2ww8vFMneJmaAgmjAG1tnVBZJRLRcx061tmsLRZHSEZSLuGWnwPtunsSLvSqXQ2wfp7Mgqg1I+2dQ==
"@xyflow/react@12.10.2":
version "12.10.2"
resolved "https://registry.yarnpkg.com/@xyflow/react/-/react-12.10.2.tgz#40f6d71944f674f0ffbb83c660f9473018adbe61"
integrity sha512-CgIi6HwlcHXwlkTpr0fxLv/0sRVNZ8IdwKLzzeCscaYBwpvfcH1QFOCeaTCuEn1FQEs/B8CjnTSjhs8udgmBgQ==
dependencies:
"@xyflow/system" "0.0.76"
classcat "^5.0.3"
zustand "^4.4.0"
"@xyflow/system@0.0.76":
version "0.0.76"
resolved "https://registry.yarnpkg.com/@xyflow/system/-/system-0.0.76.tgz#57da5e4d230cdbec56548a6d5eec115f22858259"
integrity sha512-hvwvnRS1B3REwVDlWexsq7YQaPZeG3/mKo1jv38UmnpWmxihp14bW6VtEOuHEwJX2FvzFw8k77LyKSk/wiZVNA==
dependencies:
"@types/d3-drag" "^3.0.7"
"@types/d3-interpolate" "^3.0.4"
"@types/d3-selection" "^3.0.10"
"@types/d3-transition" "^3.0.8"
"@types/d3-zoom" "^3.0.8"
d3-drag "^3.0.0"
d3-interpolate "^3.0.1"
d3-selection "^3.0.0"
d3-zoom "^3.0.0"
"@zxing/text-encoding@0.9.0":
version "0.9.0"
resolved "https://registry.yarnpkg.com/@zxing/text-encoding/-/text-encoding-0.9.0.tgz#fb50ffabc6c7c66a0c96b4c03e3d9be74864b70b"
@@ -7097,11 +7162,6 @@ abort-controller@^3.0.0:
dependencies:
event-target-shim "^5.0.0"
accessor-fn@1:
version "1.4.1"
resolved "https://registry.npmjs.org/accessor-fn/-/accessor-fn-1.4.1.tgz"
integrity sha512-P7yNKfmpuWLUwiRVk9RkRIPGjngemjZ7yANc0DL7otgDqEIWkEByMhShzfgQ5ZwCPEUmba4v1kOqCdGhpzY3ew==
acorn-globals@^7.0.0:
version "7.0.1"
resolved "https://registry.yarnpkg.com/acorn-globals/-/acorn-globals-7.0.1.tgz#0dbf05c44fa7c94332914c02066d5beff62c40c3"
@@ -7999,11 +8059,6 @@ better-xlsx@^0.7.5:
jszip "^3.2.2"
kind-of "^6.0.3"
"bezier-js@3 - 6":
version "6.1.3"
resolved "https://registry.npmjs.org/bezier-js/-/bezier-js-6.1.3.tgz"
integrity sha512-VPFvkyO98oCJ1Tsi+bFBrKEWLdefAj4DJVaWp3xTEsdCbunC7Pt/nTeIgu/UdskBNcmHv8TOfsgdMZb1GsICmg==
big-integer@^1.6.16:
version "1.6.51"
resolved "https://registry.yarnpkg.com/big-integer/-/big-integer-1.6.51.tgz#0df92a5d9880560d3ff2d5fd20245c889d130686"
@@ -8273,13 +8328,6 @@ caniuse-lite@^1.0.30001759:
resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001767.tgz#0279c498e862efb067938bba0a0aabafe8d0b730"
integrity sha512-34+zUAMhSH+r+9eKmYG+k2Rpt8XttfE4yXAjoZvkAPs15xcYQhyBYdalJ65BzivAvGRMViEjy6oKr/S91loekQ==
canvas-color-tracker@^1.3:
version "1.3.2"
resolved "https://registry.yarnpkg.com/canvas-color-tracker/-/canvas-color-tracker-1.3.2.tgz#b924cf94b33441b82692938fca5b936be971a46d"
integrity sha512-ryQkDX26yJ3CXzb3hxUVNlg1NKE4REc5crLBq661Nxzr8TNd236SaEf2ffYLXyI5tSABSeguHLqcVq4vf9L3Zg==
dependencies:
tinycolor2 "^1.6.0"
ccount@^2.0.0:
version "2.0.1"
resolved "https://registry.yarnpkg.com/ccount/-/ccount-2.0.1.tgz#17a3bf82302e0870d6da43a01311a8bc02a3ecf5"
@@ -8421,6 +8469,11 @@ class-variance-authority@^0.7.0:
dependencies:
clsx "^2.1.1"
classcat@^5.0.3:
version "5.0.5"
resolved "https://registry.yarnpkg.com/classcat/-/classcat-5.0.5.tgz#8c209f359a93ac302404a10161b501eba9c09c77"
integrity sha512-JhZUT7JFcQy/EzW605k/ktHtncoo9vnyW/2GspNYwFlN1C/WmjuV/xtS04e9SOkL2sTdw0VAZ2UGCcQ9lR6p6w==
classnames@2.3.2, classnames@2.x, classnames@^2.2.1, classnames@^2.2.3, classnames@^2.2.5, classnames@^2.2.6, classnames@^2.3.1, classnames@^2.3.2:
version "2.3.2"
resolved "https://registry.npmjs.org/classnames/-/classnames-2.3.2.tgz"
@@ -9055,7 +9108,7 @@ csstype@^3.1.2:
resolved "https://registry.yarnpkg.com/csstype/-/csstype-3.1.3.tgz#d80ff294d114fb0e6ac500fbf85b60137d7eff81"
integrity sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==
"d3-array@1 - 3", "d3-array@2 - 3", "d3-array@2.10.0 - 3":
"d3-array@2 - 3", "d3-array@2.10.0 - 3":
version "3.2.3"
resolved "https://registry.npmjs.org/d3-array/-/d3-array-3.2.3.tgz"
integrity sha512-JRHwbQQ84XuAESWhvIPaUV4/1UYTBOLiOPGWqgFDHZS1D5QN9c57FbH3QpEnQMYiOXNzKUQyGTZf+EVO7RT5TQ==
@@ -9081,11 +9134,6 @@ d3-array@^1.2.0:
resolved "https://registry.yarnpkg.com/d3-array/-/d3-array-1.2.4.tgz#635ce4d5eea759f6f605863dbcfc30edc737f71f"
integrity sha512-KHW6M86R+FUPYGb3R5XiYjXPq7VzwxZ22buHhAEVG5ztoEcZZMLov530mmccaqA1GghZArjQV46fuc8kUqhhHw==
d3-binarytree@1:
version "1.0.2"
resolved "https://registry.npmjs.org/d3-binarytree/-/d3-binarytree-1.0.2.tgz"
integrity sha512-cElUNH+sHu95L04m92pG73t2MEJXKu+GeKUN1TJkFsu93E5W8E9Sc3kHEGJKgenGvj19m6upSn2EunvMgMD2Yw==
"d3-color@1 - 3", d3-color@3.1.0:
version "3.1.0"
resolved "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz"
@@ -9103,7 +9151,7 @@ d3-delaunay@6.0.2:
resolved "https://registry.npmjs.org/d3-dispatch/-/d3-dispatch-3.0.1.tgz"
integrity sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==
"d3-drag@2 - 3":
"d3-drag@2 - 3", d3-drag@^3.0.0:
version "3.0.0"
resolved "https://registry.npmjs.org/d3-drag/-/d3-drag-3.0.0.tgz"
integrity sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==
@@ -9116,17 +9164,6 @@ d3-delaunay@6.0.2:
resolved "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz"
integrity sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==
"d3-force-3d@2 - 3":
version "3.0.5"
resolved "https://registry.npmjs.org/d3-force-3d/-/d3-force-3d-3.0.5.tgz"
integrity sha512-tdwhAhoTYZY/a6eo9nR7HP3xSW/C6XvJTbeRpR92nlPzH6OiE+4MliN9feuSFd0tPtEUo+191qOhCTWx3NYifg==
dependencies:
d3-binarytree "1"
d3-dispatch "1 - 3"
d3-octree "1"
d3-quadtree "1 - 3"
d3-timer "1 - 3"
"d3-format@1 - 3", d3-format@3.1.0:
version "3.1.0"
resolved "https://registry.npmjs.org/d3-format/-/d3-format-3.1.0.tgz"
@@ -9149,18 +9186,13 @@ d3-hierarchy@^1.1.4:
resolved "https://registry.yarnpkg.com/d3-hierarchy/-/d3-hierarchy-1.1.9.tgz#2f6bee24caaea43f8dc37545fa01628559647a83"
integrity sha512-j8tPxlqh1srJHAtxfvOUwKNYJkQuBFdM1+JAUfq6xqH5eAqf93L7oG1NVqDa4CpFZNvnNKtCYEUC8KY9yEn9lQ==
"d3-interpolate@1 - 3", "d3-interpolate@1.2.0 - 3", d3-interpolate@3.0.1:
"d3-interpolate@1 - 3", "d3-interpolate@1.2.0 - 3", d3-interpolate@3.0.1, d3-interpolate@^3.0.1:
version "3.0.1"
resolved "https://registry.yarnpkg.com/d3-interpolate/-/d3-interpolate-3.0.1.tgz#3c47aa5b32c5b3dfb56ef3fd4342078a632b400d"
integrity sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==
dependencies:
d3-color "1 - 3"
d3-octree@1:
version "1.0.2"
resolved "https://registry.npmjs.org/d3-octree/-/d3-octree-1.0.2.tgz"
integrity sha512-Qxg4oirJrNXauiuC94uKMbgxwnhdda9xRLl9ihq45srlJ4Ga3CSgqGcAL8iW7N5CIv4Oz8x3E734ulxyvHPvwA==
d3-path@1, d3-path@^1.0.5:
version "1.0.9"
resolved "https://registry.yarnpkg.com/d3-path/-/d3-path-1.0.9.tgz#48c050bb1fe8c262493a8caf5524e3e9591701cf"
@@ -9171,20 +9203,7 @@ d3-polygon@^1.0.3:
resolved "https://registry.yarnpkg.com/d3-polygon/-/d3-polygon-1.0.6.tgz#0bf8cb8180a6dc107f518ddf7975e12abbfbd38e"
integrity sha512-k+RF7WvI08PC8reEoXa/w2nSg5AUMTi+peBD9cmFc+0ixHfbs4QmxxkarVal1IkVkgxVuk9JSHhJURHiyHKAuQ==
"d3-quadtree@1 - 3":
version "3.0.1"
resolved "https://registry.npmjs.org/d3-quadtree/-/d3-quadtree-3.0.1.tgz"
integrity sha512-04xDrxQTDTCFwP5H6hRhsRcb9xxv2RzkcsygFzmkSIOJy3PeRJP7sNk3VRIbKXcog561P9oU0/rVH6vDROAgUw==
"d3-scale-chromatic@1 - 3":
version "3.0.0"
resolved "https://registry.npmjs.org/d3-scale-chromatic/-/d3-scale-chromatic-3.0.0.tgz"
integrity sha512-Lx9thtxAKrO2Pq6OO2Ua474opeziKr279P/TKZsMAhYyNDD3EnCffdbgeSYN5O7m2ByQsxtuP2CSDczNUIZ22g==
dependencies:
d3-color "1 - 3"
d3-interpolate "1 - 3"
"d3-scale@1 - 4", d3-scale@4.0.2:
d3-scale@4.0.2:
version "4.0.2"
resolved "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz"
integrity sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==
@@ -9195,9 +9214,9 @@ d3-polygon@^1.0.3:
d3-time "2.1.1 - 3"
d3-time-format "2 - 4"
"d3-selection@2 - 3", d3-selection@3:
"d3-selection@2 - 3", d3-selection@3, d3-selection@^3.0.0:
version "3.0.0"
resolved "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz"
resolved "https://registry.yarnpkg.com/d3-selection/-/d3-selection-3.0.0.tgz#c25338207efa72cc5b9bd1458a1a41901f1e1b31"
integrity sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==
d3-shape@^1.0.6, d3-shape@^1.2.0:
@@ -9237,9 +9256,9 @@ d3-shape@^1.0.6, d3-shape@^1.2.0:
d3-interpolate "1 - 3"
d3-timer "1 - 3"
"d3-zoom@2 - 3":
d3-zoom@^3.0.0:
version "3.0.0"
resolved "https://registry.npmjs.org/d3-zoom/-/d3-zoom-3.0.0.tgz"
resolved "https://registry.yarnpkg.com/d3-zoom/-/d3-zoom-3.0.0.tgz#d13f4165c73217ffeaa54295cd6969b3e7aee8f3"
integrity sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==
dependencies:
d3-dispatch "1 - 3"
@@ -10460,15 +10479,6 @@ flatted@^3.4.2:
resolved "https://registry.yarnpkg.com/flatted/-/flatted-3.4.2.tgz#f5c23c107f0f37de8dbdf24f13722b3b98d52726"
integrity sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==
float-tooltip@^1.7:
version "1.7.5"
resolved "https://registry.yarnpkg.com/float-tooltip/-/float-tooltip-1.7.5.tgz#7083bf78f0de5a97f9c2d6aa8e90d2139f34047f"
integrity sha512-/kXzuDnnBqyyWyhDMH7+PfP8J/oXiAavGzcRxASOMRHFuReDtofizLLJsf7nnDLAfEaMW4pVWaXrAjtnglpEkg==
dependencies:
d3-selection "2 - 3"
kapsule "^1.16"
preact "10"
flubber@^0.4.2:
version "0.4.2"
resolved "https://registry.yarnpkg.com/flubber/-/flubber-0.4.2.tgz#14452d4a838cc3b9f2fb6175da94e35acd55fbaa"
@@ -10505,27 +10515,6 @@ for-each@^0.3.5:
dependencies:
is-callable "^1.2.7"
force-graph@^1.51:
version "1.51.1"
resolved "https://registry.yarnpkg.com/force-graph/-/force-graph-1.51.1.tgz#c967249bf6ad2cb4a3ba89ed4c6d79895bd70fe1"
integrity sha512-uEEX8iRzgq1IKRISOw6RrB2RLMhcI25xznQYrCTVvxZHZZ+A2jH6qIolYuwavVxAMi64pFp2yZm4KFVdD993cg==
dependencies:
"@tweenjs/tween.js" "18 - 25"
accessor-fn "1"
bezier-js "3 - 6"
canvas-color-tracker "^1.3"
d3-array "1 - 3"
d3-drag "2 - 3"
d3-force-3d "2 - 3"
d3-scale "1 - 4"
d3-scale-chromatic "1 - 3"
d3-selection "2 - 3"
d3-zoom "2 - 3"
float-tooltip "^1.7"
index-array-by "1"
kapsule "^1.16"
lodash-es "4"
foreground-child@^3.1.0:
version "3.3.1"
resolved "https://registry.yarnpkg.com/foreground-child/-/foreground-child-3.3.1.tgz#32e8e9ed1b68a3497befb9ac2b6adf92a638576f"
@@ -11673,11 +11662,6 @@ indent-string@^4.0.0:
resolved "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz"
integrity sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==
index-array-by@1:
version "1.4.1"
resolved "https://registry.npmjs.org/index-array-by/-/index-array-by-1.4.1.tgz"
integrity sha512-Zu6THdrxQdyTuT2uA5FjUoBEsFHPzHcPIj18FszN6yXKHxSfGcR4TPLabfuT//E25q1Igyx9xta2WMvD/x9P/g==
inflected@^2.1.0:
version "2.1.0"
resolved "https://registry.yarnpkg.com/inflected/-/inflected-2.1.0.tgz#2816ac17a570bbbc8303ca05bca8bf9b3f959687"
@@ -12383,11 +12367,6 @@ jake@^10.8.5:
filelist "^1.0.4"
picocolors "^1.1.1"
jerrypick@^1.1.1:
version "1.1.1"
resolved "https://registry.npmjs.org/jerrypick/-/jerrypick-1.1.1.tgz"
integrity sha512-XTtedPYEyVp4t6hJrXuRKr/jHj8SC4z+4K0b396PMkov6muL+i8IIamJIvZWe3jUspgIJak0P+BaWKawMYNBLg==
jest-changed-files@30.2.0:
version "30.2.0"
resolved "https://registry.yarnpkg.com/jest-changed-files/-/jest-changed-files-30.2.0.tgz#602266e478ed554e1e1469944faa7efd37cee61c"
@@ -13103,13 +13082,6 @@ junk@^3.1.0:
resolved "https://registry.yarnpkg.com/junk/-/junk-3.1.0.tgz#31499098d902b7e98c5d9b9c80f43457a88abfa1"
integrity sha512-pBxcB3LFc8QVgdggvZWyeys+hnrNWg4OcZIU/1X59k5jQdLBlCsYGRQaz234SqoRLTCgMH00fY0xRJH+F9METQ==
kapsule@^1.16:
version "1.16.3"
resolved "https://registry.yarnpkg.com/kapsule/-/kapsule-1.16.3.tgz#5684ed89838b6658b30d0f2cc056dffc3ba68c30"
integrity sha512-4+5mNNf4vZDSwPhKprKwz3330iisPrb08JyMgbsdFrimBCKNHecua/WBwvVg3n7vwx0C1ARjfhwIpbrbd9n5wg==
dependencies:
lodash-es "4"
keyv@^4.0.0:
version "4.5.4"
resolved "https://registry.yarnpkg.com/keyv/-/keyv-4.5.4.tgz#a879a99e29452f942439f2a405e3af8b31d4de93"
@@ -13334,7 +13306,7 @@ locate-path@^7.1.0:
dependencies:
p-locate "^6.0.0"
lodash-es@4, lodash-es@^4.17.21:
lodash-es@^4.17.21:
version "4.17.21"
resolved "https://registry.npmjs.org/lodash-es/-/lodash-es-4.17.21.tgz"
integrity sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw==
@@ -15645,11 +15617,6 @@ powershell-utils@^0.1.0:
resolved "https://registry.yarnpkg.com/powershell-utils/-/powershell-utils-0.1.0.tgz#5a42c9a824fb4f2f251ccb41aaae73314f5d6ac2"
integrity sha512-dM0jVuXJPsDN6DvRpea484tCUaMiXWjuCn++HGTqUWzGDjv5tZkEZldAJ/UMlqRYGFrD/etByo4/xOuC/snX2A==
preact@10:
version "10.28.4"
resolved "https://registry.yarnpkg.com/preact/-/preact-10.28.4.tgz#8ffab01c5c0590535bdaecdd548801f44c6e483a"
integrity sha512-uKFfOHWuSNpRFVTnljsCluEFq57OKT+0QdOiQo8XWnQ/pSvg7OpX5eNOejELXJMWy+BwM2nobz0FkvzmnpCNsQ==
preact@^10.19.3:
version "10.22.0"
resolved "https://registry.yarnpkg.com/preact/-/preact-10.22.0.tgz#a50f38006ae438d255e2631cbdaf7488e6dd4e16"
@@ -15711,7 +15678,7 @@ progress@^2.0.3:
resolved "https://registry.yarnpkg.com/progress/-/progress-2.0.3.tgz#7e8cf8d8f5b8f239c1bc68beb4eb78567d572ef8"
integrity sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==
prop-types@15, prop-types@15.8.1, prop-types@15.x, prop-types@^15.0.0, prop-types@^15.5.10, prop-types@^15.5.8, prop-types@^15.6.1, prop-types@^15.6.2, prop-types@^15.7.2, prop-types@^15.8.1:
prop-types@15.8.1, prop-types@15.x, prop-types@^15.0.0, prop-types@^15.5.10, prop-types@^15.5.8, prop-types@^15.6.1, prop-types@^15.6.2, prop-types@^15.7.2, prop-types@^15.8.1:
version "15.8.1"
resolved "https://registry.yarnpkg.com/prop-types/-/prop-types-15.8.1.tgz#67d87bf1a694f48435cf332c24af10214a3140b5"
integrity sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==
@@ -16335,15 +16302,6 @@ react-fast-compare@^3.2.0:
resolved "https://registry.yarnpkg.com/react-fast-compare/-/react-fast-compare-3.2.2.tgz#929a97a532304ce9fee4bcae44234f1ce2c21d49"
integrity sha512-nsO+KSNgo1SbJqJEYRE9ERzo7YtYbou/OqjSQKxV7jcKox7+usiUVZOAC+XnDOABXggQTno0Y1CpVnuWEc1boQ==
react-force-graph-2d@^1.29.1:
version "1.29.1"
resolved "https://registry.yarnpkg.com/react-force-graph-2d/-/react-force-graph-2d-1.29.1.tgz#a0784d4387b12b28e2b552058ec09d092b4e8cda"
integrity sha512-1Rl/1Z3xy2iTHKj6a0jRXGyiI86xUti81K+jBQZ+Oe46csaMikp47L5AjrzA9hY9fNGD63X8ffrqnvaORukCuQ==
dependencies:
force-graph "^1.51"
prop-types "15"
react-kapsule "^2.5"
react-full-screen@1.1.1:
version "1.1.1"
resolved "https://registry.yarnpkg.com/react-full-screen/-/react-full-screen-1.1.1.tgz#b707d56891015a71c503a65dbab3086d75be97d7"
@@ -16413,13 +16371,6 @@ react-is@^18.3.1:
resolved "https://registry.yarnpkg.com/react-is/-/react-is-18.3.1.tgz#e83557dc12eae63a99e003a46388b1dcbb44db7e"
integrity sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==
react-kapsule@^2.5:
version "2.5.7"
resolved "https://registry.yarnpkg.com/react-kapsule/-/react-kapsule-2.5.7.tgz#dcd957ae8e897ff48055fc8ff48ed04ebe3c5bd2"
integrity sha512-kifAF4ZPD77qZKc4CKLmozq6GY1sBzPEJTIJb0wWFK6HsePJatK3jXplZn2eeAt3x67CDozgi7/rO8fNQ/AL7A==
dependencies:
jerrypick "^1.1.1"
react-lottie@1.2.10:
version "1.2.10"
resolved "https://registry.yarnpkg.com/react-lottie/-/react-lottie-1.2.10.tgz#399f78a448a7833b2380d74fc489ecf15f8d18c7"
@@ -18489,7 +18440,7 @@ tiny-warning@^1.0.0:
resolved "https://registry.npmjs.org/tiny-warning/-/tiny-warning-1.0.3.tgz"
integrity sha512-lBN9zLN/oAf68o3zNXYrdCt1kP8WsiGW8Oo2ka41b2IM5JL/S1CTyX1rW0mb/zSuJun0ZUrDxx4sqvYS2FWzPA==
tinycolor2@1.6.0, tinycolor2@^1.6.0:
tinycolor2@1.6.0:
version "1.6.0"
resolved "https://registry.yarnpkg.com/tinycolor2/-/tinycolor2-1.6.0.tgz#f98007460169b0263b97072c5ae92484ce02d09e"
integrity sha512-XPaBkWQJdsf3pLKJV9p4qN/S+fm2Oj8AIPo1BTUhg5oxkvm9+SVEGFdhyOz7tTdUTfvxMiAs4sp6/eZO2Ew+pw==
@@ -19194,7 +19145,7 @@ use-sidecar@^1.1.3:
detect-node-es "^1.1.0"
tslib "^2.0.0"
use-sync-external-store@1.6.0:
use-sync-external-store@1.6.0, use-sync-external-store@^1.2.2:
version "1.6.0"
resolved "https://registry.yarnpkg.com/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz#b174bfa65cb2b526732d9f2ac0a408027876f32d"
integrity sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==
@@ -19837,6 +19788,13 @@ zustand@5.0.11:
resolved "https://registry.yarnpkg.com/zustand/-/zustand-5.0.11.tgz#99f912e590de1ca9ce6c6d1cab6cdb1f034ab494"
integrity sha512-fdZY+dk7zn/vbWNCYmzZULHRrss0jx5pPFiOuMZ/5HJN6Yv3u+1Wswy/4MpZEkEGhtNH+pwxZB8OKgUBPzYAGg==
zustand@^4.4.0:
version "4.5.7"
resolved "https://registry.yarnpkg.com/zustand/-/zustand-4.5.7.tgz#7d6bb2026a142415dd8be8891d7870e6dbe65f55"
integrity sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw==
dependencies:
use-sync-external-store "^1.2.2"
zwitch@^2.0.0, zwitch@^2.0.4:
version "2.0.4"
resolved "https://registry.yarnpkg.com/zwitch/-/zwitch-2.0.4.tgz#c827d4b0acb76fc3e685a4c6ec2902d51070e9d7"

View File

@@ -24,7 +24,6 @@ import (
"github.com/prometheus/alertmanager/dispatch"
"github.com/prometheus/alertmanager/notify"
"github.com/prometheus/alertmanager/provider/mem"
promTypes "github.com/prometheus/alertmanager/types"
"github.com/prometheus/client_golang/prometheus"
"github.com/prometheus/common/model"
"github.com/prometheus/common/promslog"
@@ -365,7 +364,7 @@ route:
providerSettings := createTestProviderSettings()
logger := providerSettings.Logger
route := dispatch.NewRoute(conf.Route, nil)
marker := promTypes.NewMarker(prometheus.NewRegistry())
marker := alertmanagertypes.NewMarker(prometheus.NewRegistry())
alerts, err := mem.NewAlerts(context.Background(), marker, time.Hour, 0, nil, logger, prometheus.NewRegistry(), nil)
if err != nil {
t.Fatal(err)
@@ -638,7 +637,7 @@ route:
providerSettings := createTestProviderSettings()
logger := providerSettings.Logger
route := dispatch.NewRoute(conf.Route, nil)
marker := promTypes.NewMarker(prometheus.NewRegistry())
marker := alertmanagertypes.NewMarker(prometheus.NewRegistry())
alerts, err := mem.NewAlerts(context.Background(), marker, time.Hour, 0, nil, logger, prometheus.NewRegistry(), nil)
if err != nil {
t.Fatal(err)
@@ -897,7 +896,7 @@ route:
providerSettings := createTestProviderSettings()
logger := providerSettings.Logger
route := dispatch.NewRoute(conf.Route, nil)
marker := promTypes.NewMarker(prometheus.NewRegistry())
marker := alertmanagertypes.NewMarker(prometheus.NewRegistry())
alerts, err := mem.NewAlerts(context.Background(), marker, time.Hour, 0, nil, logger, prometheus.NewRegistry(), nil)
if err != nil {
t.Fatal(err)
@@ -1159,7 +1158,7 @@ func newAlert(labels model.LabelSet) *alertmanagertypes.Alert {
func TestDispatcherRace(t *testing.T) {
logger := promslog.NewNopLogger()
marker := promTypes.NewMarker(prometheus.NewRegistry())
marker := alertmanagertypes.NewMarker(prometheus.NewRegistry())
alerts, err := mem.NewAlerts(context.Background(), marker, time.Hour, 0, nil, logger, prometheus.NewRegistry(), nil)
if err != nil {
t.Fatal(err)
@@ -1195,7 +1194,7 @@ route:
route := dispatch.NewRoute(conf.Route, nil)
providerSettings := createTestProviderSettings()
logger := providerSettings.Logger
marker := promTypes.NewMarker(prometheus.NewRegistry())
marker := alertmanagertypes.NewMarker(prometheus.NewRegistry())
alerts, err := mem.NewAlerts(context.Background(), marker, time.Hour, 0, nil, logger, prometheus.NewRegistry(), nil)
if err != nil {
t.Fatal(err)
@@ -1265,7 +1264,7 @@ route:
func TestDispatcher_DoMaintenance(t *testing.T) {
r := prometheus.NewRegistry()
marker := promTypes.NewMarker(r)
marker := alertmanagertypes.NewMarker(r)
alerts, err := mem.NewAlerts(context.Background(), marker, time.Minute, 0, nil, promslog.NewNopLogger(), prometheus.NewRegistry(), nil)
if err != nil {
@@ -1371,7 +1370,7 @@ route:
providerSettings := createTestProviderSettings()
logger := providerSettings.Logger
route := dispatch.NewRoute(conf.Route, nil)
marker := promTypes.NewMarker(prometheus.NewRegistry())
marker := alertmanagertypes.NewMarker(prometheus.NewRegistry())
alerts, err := mem.NewAlerts(context.Background(), marker, time.Hour, 0, nil, logger, prometheus.NewRegistry(), nil)
if err != nil {
t.Fatal(err)

View File

@@ -1,95 +0,0 @@
package alertmanagerserver
import (
"context"
"log/slog"
"sync"
"time"
"github.com/prometheus/common/model"
"github.com/SigNoz/signoz/pkg/types/ruletypes"
)
// MaintenanceMuter implements types.Muter for maintenance windows.
// It suppresses alerts whose ruleId label matches an active maintenance schedule.
// Results are cached for cacheTTL to avoid a DB query on every per-alert check.
type MaintenanceMuter struct {
maintenanceStore ruletypes.MaintenanceStore
orgID string
logger *slog.Logger
mu sync.RWMutex
cached []*ruletypes.PlannedMaintenance
cacheExpiry time.Time
}
const maintenanceCacheTTL = 30 * time.Second
func NewMaintenanceMuter(store ruletypes.MaintenanceStore, orgID string, logger *slog.Logger) *MaintenanceMuter {
return &MaintenanceMuter{
maintenanceStore: store,
orgID: orgID,
logger: logger,
}
}
func (m *MaintenanceMuter) Mutes(ctx context.Context, lset model.LabelSet) bool {
ruleID := string(lset[ruletypes.AlertRuleIDLabel])
if ruleID == "" {
return false
}
now := time.Now()
for _, mw := range m.getMaintenances(ctx) {
if mw.ShouldSkip(ruleID, now, lset) {
return true
}
}
return false
}
// MutedBy returns the IDs of all active maintenance windows currently
// suppressing the alert identified by lset. It is used to populate the
// `mutedBy` field on the v2 API alert response so that maintenance-suppressed
// alerts surface as `state=suppressed` in GetAlerts responses.
func (m *MaintenanceMuter) MutedBy(ctx context.Context, lset model.LabelSet) []string {
ruleID := string(lset[ruletypes.AlertRuleIDLabel])
if ruleID == "" {
return nil
}
var ids []string
now := time.Now()
for _, mw := range m.getMaintenances(ctx) {
if mw.ShouldSkip(ruleID, now, lset) {
ids = append(ids, mw.ID.String())
}
}
return ids
}
func (m *MaintenanceMuter) getMaintenances(ctx context.Context) []*ruletypes.PlannedMaintenance {
m.mu.RLock()
if time.Now().Before(m.cacheExpiry) {
cached := m.cached
m.mu.RUnlock()
return cached
}
m.mu.RUnlock()
m.mu.Lock()
defer m.mu.Unlock()
// Double-check after acquiring write lock.
if time.Now().Before(m.cacheExpiry) {
return m.cached
}
mws, err := m.maintenanceStore.ListPlannedMaintenance(ctx, m.orgID)
if err != nil {
m.logger.ErrorContext(ctx, "failed to list planned maintenance windows; alerts will not be suppressed", slog.String("org_id", m.orgID))
return m.cached // return stale (potentially empty) cache on error
}
m.cached = mws
m.cacheExpiry = time.Now().Add(maintenanceCacheTTL)
return m.cached
}

View File

@@ -1,109 +0,0 @@
// Copyright (c) 2026 SigNoz, Inc.
// Copyright 2015 Prometheus Team
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package alertmanagerserver
import (
"time"
"github.com/prometheus/alertmanager/featurecontrol"
"github.com/prometheus/alertmanager/inhibit"
"github.com/prometheus/alertmanager/nflog/nflogpb"
"github.com/prometheus/alertmanager/notify"
"github.com/prometheus/alertmanager/silence"
"github.com/prometheus/alertmanager/timeinterval"
"github.com/prometheus/alertmanager/types"
"github.com/prometheus/client_golang/prometheus"
)
// pipelineBuilder is a local copy of notify.PipelineBuilder that injects
// the maintenance mute stage immediately before the receiver stage.
//
// We maintain our own copy so we can control exactly where in the pipeline
// the maintenance stage runs (between the silence stage and the receiver),
// which is not possible by wrapping the output of the upstream builder.
//
// Upstream pipeline order:
// GossipSettle → Inhibit → TimeActive → TimeMute → Silence → [mms] → Receiver
type pipelineBuilder struct {
metrics *notify.Metrics
ff featurecontrol.Flagger
}
func newPipelineBuilder(
r prometheus.Registerer,
ff featurecontrol.Flagger,
) *pipelineBuilder {
return &pipelineBuilder{
metrics: notify.NewMetrics(r, ff),
ff: ff,
}
}
// New returns a map of receivers to Stages, mirroring notify.PipelineBuilder.New
// but inserting a maintenanceMuteStage between the silence stage and the receiver.
func (pb *pipelineBuilder) New(
receivers map[string][]notify.Integration,
wait func() time.Duration,
inhibitor *inhibit.Inhibitor,
silencer *silence.Silencer,
intervener *timeinterval.Intervener,
marker types.GroupMarker,
muter *MaintenanceMuter,
notificationLog notify.NotificationLog,
peer notify.Peer,
) notify.RoutingStage {
rs := make(notify.RoutingStage, len(receivers))
ms := notify.NewGossipSettleStage(peer)
is := notify.NewMuteStage(inhibitor, pb.metrics)
tas := notify.NewTimeActiveStage(intervener, marker, pb.metrics)
tms := notify.NewTimeMuteStage(intervener, marker, pb.metrics)
ss := notify.NewMuteStage(silencer, pb.metrics)
mms := notify.NewMuteStage(muter, pb.metrics)
for name := range receivers {
stages := notify.MultiStage{ms, is, tas, tms, ss, mms}
stages = append(stages, createReceiverStage(name, receivers[name], wait, notificationLog, pb.metrics))
rs[name] = stages
}
pb.metrics.InitializeFor(receivers)
return rs
}
// createReceiverStage is a copy of notify.createReceiverStage (unexported upstream).
func createReceiverStage(
name string,
integrations []notify.Integration,
wait func() time.Duration,
notificationLog notify.NotificationLog,
metrics *notify.Metrics,
) notify.Stage {
var fs notify.FanoutStage
for i := range integrations {
recv := &nflogpb.Receiver{
GroupName: name,
Integration: integrations[i].Name(),
Idx: uint32(integrations[i].Index()),
}
var s notify.MultiStage
s = append(s, notify.NewWaitStage(wait))
s = append(s, notify.NewDedupStage(&integrations[i], notificationLog, recv))
s = append(s, notify.NewRetryStage(integrations[i], name, metrics))
s = append(s, notify.NewSetNotifiesStage(notificationLog, recv))
fs = append(fs, s)
}
return fs
}

View File

@@ -26,13 +26,14 @@ import (
"github.com/SigNoz/signoz/pkg/alertmanager/nfmanager"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/types/alertmanagertypes"
"github.com/SigNoz/signoz/pkg/types/ruletypes"
)
// This is not a real snapshot file and will never be used. We need this placeholder to ensure maintenance runs on shutdown.
// See https://github.com/prometheus/alertmanager/blob/3ee2cd0f1271e277295c02b6160507b4d193dde2/silence/silence.go#L435-L438
// and https://github.com/prometheus/alertmanager/blob/3b06b97af4d146e141af92885a185891eb79a5b0/nflog/nflog.go#L362.
var snapfnoop string = "snapfnoop"
var (
// This is not a real file and will never be used. We need this placeholder to ensure maintenance runs on shutdown. See
// https://github.com/prometheus/server/blob/3ee2cd0f1271e277295c02b6160507b4d193dde2/silence/silence.go#L435-L438
// and https://github.com/prometheus/server/blob/3b06b97af4d146e141af92885a185891eb79a5b0/nflog/nflog.go#L362.
snapfnoop string = "snapfnoop"
)
type Server struct {
// logger is the logger for the alertmanager
@@ -62,25 +63,15 @@ type Server struct {
silencer *silence.Silencer
silences *silence.Silences
timeIntervals map[string][]timeinterval.TimeInterval
pipelineBuilder *pipelineBuilder
muter *MaintenanceMuter
marker *types.MemMarker
pipelineBuilder *notify.PipelineBuilder
marker *alertmanagertypes.MemMarker
tmpl *template.Template
wg sync.WaitGroup
stopc chan struct{}
notificationManager nfmanager.NotificationManager
}
func New(
ctx context.Context,
logger *slog.Logger,
registry prometheus.Registerer,
srvConfig Config,
orgID string,
stateStore alertmanagertypes.StateStore,
nfManager nfmanager.NotificationManager,
maintenanceStore ruletypes.MaintenanceStore,
) (*Server, error) {
func New(ctx context.Context, logger *slog.Logger, registry prometheus.Registerer, srvConfig Config, orgID string, stateStore alertmanagertypes.StateStore, nfManager nfmanager.NotificationManager) (*Server, error) {
server := &Server{
logger: logger.With(slog.String("pkg", "go.signoz.io/pkg/alertmanager/alertmanagerserver")),
registry: registry,
@@ -93,7 +84,7 @@ func New(
signozRegisterer := prometheus.WrapRegistererWithPrefix("signoz_", registry)
signozRegisterer = prometheus.WrapRegistererWith(prometheus.Labels{"org_id": server.orgID}, signozRegisterer)
// initialize marker
server.marker = types.NewMarker(signozRegisterer)
server.marker = alertmanagertypes.NewMarker(signozRegisterer)
// get silences for initial state
state, err := server.stateStore.Get(ctx, server.orgID)
@@ -169,6 +160,7 @@ func New(
return c, server.stateStore.Set(ctx, storableSilences)
})
}()
// Start maintenance for notification logs
@@ -204,25 +196,17 @@ func New(
return nil, err
}
server.muter = NewMaintenanceMuter(maintenanceStore, orgID, server.logger)
server.pipelineBuilder = newPipelineBuilder(signozRegisterer, featurecontrol.NoopFlags{})
server.pipelineBuilder = notify.NewPipelineBuilder(signozRegisterer, featurecontrol.NoopFlags{})
server.dispatcherMetrics = NewDispatcherMetrics(false, signozRegisterer)
return server, nil
}
func (server *Server) GetAlerts(ctx context.Context, params alertmanagertypes.GettableAlertsParams) (alertmanagertypes.GettableAlerts, error) {
return alertmanagertypes.NewGettableAlertsFromAlertProvider(
server.alerts, server.alertmanagerConfig, server.marker.Status,
func(labels model.LabelSet) {
server.inhibitor.Mutes(ctx, labels)
server.silencer.Mutes(ctx, labels)
},
func(labels model.LabelSet) []string {
return server.muter.MutedBy(ctx, labels)
},
params,
)
return alertmanagertypes.NewGettableAlertsFromAlertProvider(server.alerts, server.alertmanagerConfig, server.marker.Status, func(labels model.LabelSet) {
server.inhibitor.Mutes(ctx, labels)
server.silencer.Mutes(ctx, labels)
}, params)
}
func (server *Server) PutAlerts(ctx context.Context, postableAlerts alertmanagertypes.PostableAlerts) error {
@@ -306,7 +290,6 @@ func (server *Server) SetConfig(ctx context.Context, alertmanagerConfig *alertma
server.silencer,
intervener,
server.marker,
server.muter,
server.nflog,
pipelinePeer,
)

View File

@@ -7,10 +7,6 @@ import (
"testing"
"time"
sqlmock "github.com/DATA-DOG/go-sqlmock"
"github.com/SigNoz/signoz/pkg/ruler/rulestore/sqlrulestore"
"github.com/SigNoz/signoz/pkg/sqlstore"
"github.com/SigNoz/signoz/pkg/sqlstore/sqlstoretest"
"github.com/SigNoz/signoz/pkg/types/alertmanagertypes/alertmanagertypestest"
"github.com/prometheus/alertmanager/dispatch"
@@ -94,8 +90,7 @@ func TestEndToEndAlertManagerFlow(t *testing.T) {
stateStore := alertmanagertypestest.NewStateStore()
registry := prometheus.NewRegistry()
logger := slog.New(slog.DiscardHandler)
maintenanceStore := sqlrulestore.NewMaintenanceStore(sqlstoretest.New(sqlstore.Config{Provider: "sqlite"}, sqlmock.QueryMatcherEqual))
server, err := New(context.Background(), logger, registry, srvCfg, orgID, stateStore, notificationManager, maintenanceStore)
server, err := New(context.Background(), logger, registry, srvCfg, orgID, stateStore, notificationManager)
require.NoError(t, err)
amConfig, err := alertmanagertypes.NewDefaultConfig(srvCfg.Global, srvCfg.Route, orgID)
require.NoError(t, err)

View File

@@ -10,14 +10,9 @@ import (
"testing"
"time"
"github.com/DATA-DOG/go-sqlmock"
"github.com/SigNoz/signoz/pkg/alertmanager/nfmanager/nfmanagertest"
"github.com/SigNoz/signoz/pkg/ruler/rulestore/sqlrulestore"
"github.com/SigNoz/signoz/pkg/sqlstore"
"github.com/SigNoz/signoz/pkg/sqlstore/sqlstoretest"
"github.com/SigNoz/signoz/pkg/types/alertmanagertypes"
"github.com/SigNoz/signoz/pkg/types/alertmanagertypes/alertmanagertypestest"
"github.com/SigNoz/signoz/pkg/types/ruletypes"
"github.com/go-openapi/strfmt"
"github.com/prometheus/alertmanager/api/v2/models"
"github.com/prometheus/alertmanager/config"
@@ -28,14 +23,9 @@ import (
"github.com/stretchr/testify/require"
)
func newTestMaintenanceStore() ruletypes.MaintenanceStore {
ss := sqlstoretest.New(sqlstore.Config{Provider: "sqlite"}, sqlmock.QueryMatcherEqual)
return sqlrulestore.NewMaintenanceStore(ss)
}
func TestServerSetConfigAndStop(t *testing.T) {
notificationManager := nfmanagertest.NewMock()
server, err := New(context.Background(), slog.New(slog.DiscardHandler), prometheus.NewRegistry(), NewConfig(), "1", alertmanagertypestest.NewStateStore(), notificationManager, newTestMaintenanceStore())
server, err := New(context.Background(), slog.New(slog.DiscardHandler), prometheus.NewRegistry(), NewConfig(), "1", alertmanagertypestest.NewStateStore(), notificationManager)
require.NoError(t, err)
amConfig, err := alertmanagertypes.NewDefaultConfig(alertmanagertypes.GlobalConfig{}, alertmanagertypes.RouteConfig{GroupInterval: 1 * time.Minute, RepeatInterval: 1 * time.Minute, GroupWait: 1 * time.Minute}, "1")
@@ -47,7 +37,7 @@ func TestServerSetConfigAndStop(t *testing.T) {
func TestServerTestReceiverTypeWebhook(t *testing.T) {
notificationManager := nfmanagertest.NewMock()
server, err := New(context.Background(), slog.New(slog.DiscardHandler), prometheus.NewRegistry(), NewConfig(), "1", alertmanagertypestest.NewStateStore(), notificationManager, newTestMaintenanceStore())
server, err := New(context.Background(), slog.New(slog.DiscardHandler), prometheus.NewRegistry(), NewConfig(), "1", alertmanagertypestest.NewStateStore(), notificationManager)
require.NoError(t, err)
amConfig, err := alertmanagertypes.NewDefaultConfig(alertmanagertypes.GlobalConfig{}, alertmanagertypes.RouteConfig{GroupInterval: 1 * time.Minute, RepeatInterval: 1 * time.Minute, GroupWait: 1 * time.Minute}, "1")
@@ -95,7 +85,7 @@ func TestServerPutAlerts(t *testing.T) {
srvCfg := NewConfig()
srvCfg.Route.GroupInterval = 1 * time.Second
notificationManager := nfmanagertest.NewMock()
server, err := New(context.Background(), slog.New(slog.DiscardHandler), prometheus.NewRegistry(), srvCfg, "1", stateStore, notificationManager, newTestMaintenanceStore())
server, err := New(context.Background(), slog.New(slog.DiscardHandler), prometheus.NewRegistry(), srvCfg, "1", stateStore, notificationManager)
require.NoError(t, err)
amConfig, err := alertmanagertypes.NewDefaultConfig(srvCfg.Global, srvCfg.Route, "1")
@@ -143,7 +133,7 @@ func TestServerTestAlert(t *testing.T) {
srvCfg := NewConfig()
srvCfg.Route.GroupInterval = 1 * time.Second
notificationManager := nfmanagertest.NewMock()
server, err := New(context.Background(), slog.New(slog.DiscardHandler), prometheus.NewRegistry(), srvCfg, "1", stateStore, notificationManager, newTestMaintenanceStore())
server, err := New(context.Background(), slog.New(slog.DiscardHandler), prometheus.NewRegistry(), srvCfg, "1", stateStore, notificationManager)
require.NoError(t, err)
amConfig, err := alertmanagertypes.NewDefaultConfig(srvCfg.Global, srvCfg.Route, "1")
@@ -248,7 +238,7 @@ func TestServerTestAlertContinuesOnFailure(t *testing.T) {
srvCfg := NewConfig()
srvCfg.Route.GroupInterval = 1 * time.Second
notificationManager := nfmanagertest.NewMock()
server, err := New(context.Background(), slog.New(slog.DiscardHandler), prometheus.NewRegistry(), srvCfg, "1", stateStore, notificationManager, newTestMaintenanceStore())
server, err := New(context.Background(), slog.New(slog.DiscardHandler), prometheus.NewRegistry(), srvCfg, "1", stateStore, notificationManager)
require.NoError(t, err)
amConfig, err := alertmanagertypes.NewDefaultConfig(srvCfg.Global, srvCfg.Route, "1")

View File

@@ -14,7 +14,6 @@ import (
"github.com/SigNoz/signoz/pkg/factory"
"github.com/SigNoz/signoz/pkg/modules/organization"
"github.com/SigNoz/signoz/pkg/types/alertmanagertypes"
"github.com/SigNoz/signoz/pkg/types/ruletypes"
)
type Service struct {
@@ -40,18 +39,16 @@ type Service struct {
serversMtx sync.RWMutex
notificationManager nfmanager.NotificationManager
maintenanceStore ruletypes.MaintenanceStore
}
func New(
ctx context.Context,
settings factory.ScopedProviderSettings,
config alertmanagerserver.Config,
stateStore alertmanagertypes.StateStore,
configStore alertmanagertypes.ConfigStore,
orgGetter organization.Getter,
nfManager nfmanager.NotificationManager,
maintenanceStore ruletypes.MaintenanceStore,
) *Service {
service := &Service{
config: config,
@@ -62,7 +59,6 @@ func New(
servers: make(map[string]*alertmanagerserver.Server),
serversMtx: sync.RWMutex{},
notificationManager: nfManager,
maintenanceStore: maintenanceStore,
}
return service
@@ -181,10 +177,7 @@ func (service *Service) newServer(ctx context.Context, orgID string) (*alertmana
return nil, err
}
server, err := alertmanagerserver.New(
ctx, service.settings.Logger(), service.settings.PrometheusRegisterer(), service.config, orgID,
service.stateStore, service.notificationManager, service.maintenanceStore,
)
server, err := alertmanagerserver.New(ctx, service.settings.Logger(), service.settings.PrometheusRegisterer(), service.config, orgID, service.stateStore, service.notificationManager)
if err != nil {
return nil, err
}

View File

@@ -4,21 +4,22 @@ import (
"context"
"time"
amConfig "github.com/prometheus/alertmanager/config"
"github.com/prometheus/common/model"
"github.com/SigNoz/signoz/pkg/query-service/utils/labels"
amConfig "github.com/prometheus/alertmanager/config"
"github.com/SigNoz/signoz/pkg/alertmanager"
"github.com/SigNoz/signoz/pkg/alertmanager/alertmanagerstore/sqlalertmanagerstore"
"github.com/SigNoz/signoz/pkg/alertmanager/nfmanager"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/factory"
"github.com/SigNoz/signoz/pkg/modules/organization"
"github.com/SigNoz/signoz/pkg/query-service/utils/labels"
"github.com/SigNoz/signoz/pkg/sqlstore"
"github.com/SigNoz/signoz/pkg/types"
"github.com/SigNoz/signoz/pkg/types/alertmanagertypes"
"github.com/SigNoz/signoz/pkg/types/authtypes"
"github.com/SigNoz/signoz/pkg/types/ruletypes"
"github.com/SigNoz/signoz/pkg/valuer"
)
@@ -29,49 +30,35 @@ type provider struct {
configStore alertmanagertypes.ConfigStore
stateStore alertmanagertypes.StateStore
notificationManager nfmanager.NotificationManager
maintenanceStore ruletypes.MaintenanceStore
stopC chan struct{}
}
func NewFactory(
sqlstore sqlstore.SQLStore,
orgGetter organization.Getter,
notificationManager nfmanager.NotificationManager,
maintenanceStore ruletypes.MaintenanceStore,
) factory.ProviderFactory[alertmanager.Alertmanager, alertmanager.Config] {
func NewFactory(sqlstore sqlstore.SQLStore, orgGetter organization.Getter, notificationManager nfmanager.NotificationManager) factory.ProviderFactory[alertmanager.Alertmanager, alertmanager.Config] {
return factory.NewProviderFactory(factory.MustNewName("signoz"), func(ctx context.Context, settings factory.ProviderSettings, config alertmanager.Config) (alertmanager.Alertmanager, error) {
return New(settings, config, sqlstore, orgGetter, notificationManager, maintenanceStore)
return New(ctx, settings, config, sqlstore, orgGetter, notificationManager)
})
}
func New(
providerSettings factory.ProviderSettings,
config alertmanager.Config,
sqlstore sqlstore.SQLStore,
orgGetter organization.Getter,
notificationManager nfmanager.NotificationManager,
maintenanceStore ruletypes.MaintenanceStore,
) (*provider, error) {
func New(ctx context.Context, providerSettings factory.ProviderSettings, config alertmanager.Config, sqlstore sqlstore.SQLStore, orgGetter organization.Getter, notificationManager nfmanager.NotificationManager) (*provider, error) {
settings := factory.NewScopedProviderSettings(providerSettings, "github.com/SigNoz/signoz/pkg/alertmanager/signozalertmanager")
configStore := sqlalertmanagerstore.NewConfigStore(sqlstore)
stateStore := sqlalertmanagerstore.NewStateStore(sqlstore)
p := &provider{
service: alertmanager.New(
ctx,
settings,
config.Signoz.Config,
stateStore,
configStore,
orgGetter,
notificationManager,
maintenanceStore,
),
settings: settings,
config: config,
configStore: configStore,
stateStore: stateStore,
notificationManager: notificationManager,
maintenanceStore: maintenanceStore,
stopC: make(chan struct{}),
}

View File

@@ -34,9 +34,9 @@ func (provider *provider) addAuthDomainRoutes(router *mux.Router) error {
Description: "This endpoint creates an auth domain",
Request: new(authtypes.PostableAuthDomain),
RequestContentType: "application/json",
Response: new(authtypes.GettableAuthDomain),
Response: new(types.Identifiable),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusOK,
SuccessStatusCode: http.StatusCreated,
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusConflict},
Deprecated: false,
SecuritySchemes: newSecuritySchemes(types.RoleAdmin),
@@ -66,7 +66,7 @@ func (provider *provider) addAuthDomainRoutes(router *mux.Router) error {
Tags: []string{"authdomains"},
Summary: "Update auth domain",
Description: "This endpoint updates an auth domain",
Request: new(authtypes.UpdateableAuthDomain),
Request: new(authtypes.UpdatableAuthDomain),
RequestContentType: "application/json",
Response: nil,
ResponseContentType: "",

View File

@@ -2,8 +2,6 @@ package envprovider
import (
"context"
"os"
"strings"
"testing"
"github.com/SigNoz/signoz/pkg/config"
@@ -11,21 +9,7 @@ import (
"github.com/stretchr/testify/require"
)
// clearSignozEnv unsets all existing SIGNOZ_* env vars for the duration of the test.
func clearSignozEnv(t *testing.T) {
t.Helper()
for _, kv := range os.Environ() {
if strings.HasPrefix(kv, prefix) {
key := strings.SplitN(kv, "=", 2)[0]
orig, _ := os.LookupEnv(key)
os.Unsetenv(key)
t.Cleanup(func() { os.Setenv(key, orig) })
}
}
}
func TestGetWithStrings(t *testing.T) {
clearSignozEnv(t)
t.Setenv("SIGNOZ_K1_K2", "string")
t.Setenv("SIGNOZ_K3__K4", "string")
t.Setenv("SIGNOZ_K5__K6_K7__K8", "string")
@@ -47,7 +31,6 @@ func TestGetWithStrings(t *testing.T) {
}
func TestGetWithNoPrefix(t *testing.T) {
clearSignozEnv(t)
t.Setenv("K1_K2", "string")
t.Setenv("K3_K4", "string")
expected := map[string]any{}
@@ -60,7 +43,6 @@ func TestGetWithNoPrefix(t *testing.T) {
}
func TestGetWithGoTypes(t *testing.T) {
clearSignozEnv(t)
t.Setenv("SIGNOZ_BOOL", "true")
t.Setenv("SIGNOZ_STRING", "string")
t.Setenv("SIGNOZ_INT", "1")

View File

@@ -142,7 +142,7 @@ func (handler *handler) Update(rw http.ResponseWriter, r *http.Request) {
return
}
body := new(authtypes.UpdateableAuthDomain)
body := new(authtypes.UpdatableAuthDomain)
if err := binding.JSON.BindBody(r.Body, body); err != nil {
render.Error(rw, err)
return

View File

@@ -32,28 +32,30 @@ import (
)
type PrepareTaskOptions struct {
Rule *ruletypes.PostableRule
TaskName string
RuleStore ruletypes.RuleStore
Querier querier.Querier
Logger *slog.Logger
Cache cache.Cache
ManagerOpts *ManagerOptions
NotifyFunc NotifyFunc
SQLStore sqlstore.SQLStore
OrgID valuer.UUID
Rule *ruletypes.PostableRule
TaskName string
RuleStore ruletypes.RuleStore
MaintenanceStore ruletypes.MaintenanceStore
Querier querier.Querier
Logger *slog.Logger
Cache cache.Cache
ManagerOpts *ManagerOptions
NotifyFunc NotifyFunc
SQLStore sqlstore.SQLStore
OrgID valuer.UUID
}
type PrepareTestRuleOptions struct {
Rule *ruletypes.PostableRule
RuleStore ruletypes.RuleStore
Querier querier.Querier
Logger *slog.Logger
Cache cache.Cache
ManagerOpts *ManagerOptions
NotifyFunc NotifyFunc
SQLStore sqlstore.SQLStore
OrgID valuer.UUID
Rule *ruletypes.PostableRule
RuleStore ruletypes.RuleStore
MaintenanceStore ruletypes.MaintenanceStore
Querier querier.Querier
Logger *slog.Logger
Cache cache.Cache
ManagerOpts *ManagerOptions
NotifyFunc NotifyFunc
SQLStore sqlstore.SQLStore
OrgID valuer.UUID
}
const taskNameSuffix = "webAppEditor"
@@ -132,6 +134,7 @@ func defaultOptions(o *ManagerOptions) *ManagerOptions {
}
func defaultPrepareTaskFunc(opts PrepareTaskOptions) (Task, error) {
rules := make([]Rule, 0)
var task Task
@@ -156,6 +159,7 @@ func defaultPrepareTaskFunc(opts PrepareTaskOptions) (Task, error) {
WithMetadataStore(opts.ManagerOpts.MetadataStore),
WithRuleStateHistoryModule(opts.ManagerOpts.RuleStateHistoryModule),
)
if err != nil {
return task, err
}
@@ -163,7 +167,7 @@ func defaultPrepareTaskFunc(opts PrepareTaskOptions) (Task, error) {
rules = append(rules, tr)
// create ch rule task for evaluation
task = newTask(TaskTypeCh, opts.TaskName, taskNameSuffix, evaluation.GetFrequency().Duration(), rules, opts.ManagerOpts, opts.NotifyFunc)
task = newTask(TaskTypeCh, opts.TaskName, taskNameSuffix, evaluation.GetFrequency().Duration(), rules, opts.ManagerOpts, opts.NotifyFunc, opts.MaintenanceStore, opts.OrgID)
} else if opts.Rule.RuleType == ruletypes.RuleTypeProm {
@@ -179,6 +183,7 @@ func defaultPrepareTaskFunc(opts PrepareTaskOptions) (Task, error) {
WithMetadataStore(opts.ManagerOpts.MetadataStore),
WithRuleStateHistoryModule(opts.ManagerOpts.RuleStateHistoryModule),
)
if err != nil {
return task, err
}
@@ -186,7 +191,7 @@ func defaultPrepareTaskFunc(opts PrepareTaskOptions) (Task, error) {
rules = append(rules, pr)
// create promql rule task for evaluation
task = newTask(TaskTypeProm, opts.TaskName, taskNameSuffix, evaluation.GetFrequency().Duration(), rules, opts.ManagerOpts, opts.NotifyFunc)
task = newTask(TaskTypeProm, opts.TaskName, taskNameSuffix, evaluation.GetFrequency().Duration(), rules, opts.ManagerOpts, opts.NotifyFunc, opts.MaintenanceStore, opts.OrgID)
} else {
return nil, errors.NewInvalidInputf(errors.CodeInvalidInput, "unsupported rule type %s. Supported types: %s, %s", opts.Rule.RuleType, ruletypes.RuleTypeProm, ruletypes.RuleTypeThreshold)
@@ -236,7 +241,6 @@ func (m *Manager) MaintenanceStore() ruletypes.MaintenanceStore {
return m.maintenanceStore
}
// TODO(jatinderjit): remove (unused)?
func (m *Manager) Pause(b bool) {
m.mtx.Lock()
defer m.mtx.Unlock()
@@ -426,17 +430,19 @@ func (m *Manager) editTask(_ context.Context, orgID valuer.UUID, rule *ruletypes
m.logger.Debug("editing a rule task", "name", taskName)
newTask, err := m.prepareTaskFunc(PrepareTaskOptions{
Rule: rule,
TaskName: taskName,
RuleStore: m.ruleStore,
Querier: m.opts.Querier,
Logger: m.opts.Logger,
Cache: m.cache,
ManagerOpts: m.opts,
NotifyFunc: m.notifyFunc,
SQLStore: m.sqlstore,
OrgID: orgID,
Rule: rule,
TaskName: taskName,
RuleStore: m.ruleStore,
MaintenanceStore: m.maintenanceStore,
Querier: m.opts.Querier,
Logger: m.opts.Logger,
Cache: m.cache,
ManagerOpts: m.opts,
NotifyFunc: m.notifyFunc,
SQLStore: m.sqlstore,
OrgID: orgID,
})
if err != nil {
m.logger.Error("loading tasks failed", errors.Attr(err))
return errors.NewInvalidInputf(errors.CodeInvalidInput, "error preparing rule with given parameters, previous rule set restored")
@@ -637,17 +643,19 @@ func (m *Manager) addTask(_ context.Context, orgID valuer.UUID, rule *ruletypes.
m.logger.Debug("adding a new rule task", "name", taskName)
newTask, err := m.prepareTaskFunc(PrepareTaskOptions{
Rule: rule,
TaskName: taskName,
RuleStore: m.ruleStore,
Querier: m.opts.Querier,
Logger: m.opts.Logger,
Cache: m.cache,
ManagerOpts: m.opts,
NotifyFunc: m.notifyFunc,
SQLStore: m.sqlstore,
OrgID: orgID,
Rule: rule,
TaskName: taskName,
RuleStore: m.ruleStore,
MaintenanceStore: m.maintenanceStore,
Querier: m.opts.Querier,
Logger: m.opts.Logger,
Cache: m.cache,
ManagerOpts: m.opts,
NotifyFunc: m.notifyFunc,
SQLStore: m.sqlstore,
OrgID: orgID,
})
if err != nil {
m.logger.Error("creating rule task failed", "name", taskName, errors.Attr(err))
return errors.NewInvalidInputf(errors.CodeInvalidInput, "error loading rules, previous rule set restored")
@@ -695,6 +703,7 @@ func (m *Manager) RuleTasks() []Task {
// RuleTasksWithoutLock returns the list of manager's rule tasks without
// acquiring a lock on the manager.
func (m *Manager) RuleTasksWithoutLock() []Task {
rgs := make([]Task, 0, len(m.tasks))
for _, g := range m.tasks {
rgs = append(rgs, g)
@@ -888,6 +897,7 @@ func (m *Manager) GetRule(ctx context.Context, id valuer.UUID) (*ruletypes.Getta
// the task state. For example - if a stored rule is disabled, then
// there is no task running against it.
func (m *Manager) syncRuleStateWithTask(ctx context.Context, orgID valuer.UUID, taskName string, rule *ruletypes.PostableRule) error {
if rule.Disabled {
// check if rule has any task running
if _, ok := m.tasks[taskName]; ok {
@@ -1019,15 +1029,16 @@ func (m *Manager) TestNotification(ctx context.Context, orgID valuer.UUID, ruleS
}
alertCount, err := m.prepareTestRuleFunc(PrepareTestRuleOptions{
Rule: &parsedRule,
RuleStore: m.ruleStore,
Querier: m.opts.Querier,
Logger: m.opts.Logger,
Cache: m.cache,
ManagerOpts: m.opts,
NotifyFunc: m.testNotifyFunc,
SQLStore: m.sqlstore,
OrgID: orgID,
Rule: &parsedRule,
RuleStore: m.ruleStore,
MaintenanceStore: m.maintenanceStore,
Querier: m.opts.Querier,
Logger: m.opts.Logger,
Cache: m.cache,
ManagerOpts: m.opts,
NotifyFunc: m.testNotifyFunc,
SQLStore: m.sqlstore,
OrgID: orgID,
})
return alertCount, err

View File

@@ -15,6 +15,7 @@ import (
"github.com/SigNoz/signoz/pkg/types/authtypes"
"github.com/SigNoz/signoz/pkg/types/ctxtypes"
ruletypes "github.com/SigNoz/signoz/pkg/types/ruletypes"
"github.com/SigNoz/signoz/pkg/valuer"
)
// PromRuleTask is a promql rule executor
@@ -38,11 +39,14 @@ type PromRuleTask struct {
pause bool
logger *slog.Logger
notify NotifyFunc
maintenanceStore ruletypes.MaintenanceStore
orgID valuer.UUID
}
// NewPromRuleTask holds rules that have promql condition
// and evaluates the rule at a given frequency
func NewPromRuleTask(name, file string, frequency time.Duration, rules []Rule, opts *ManagerOptions, notify NotifyFunc) *PromRuleTask {
func NewPromRuleTask(name, file string, frequency time.Duration, rules []Rule, opts *ManagerOptions, notify NotifyFunc, maintenanceStore ruletypes.MaintenanceStore, orgID valuer.UUID) *PromRuleTask {
opts.Logger.Info("initiating a new rule group", "name", name, "frequency", frequency)
if frequency == 0 {
@@ -59,8 +63,10 @@ func NewPromRuleTask(name, file string, frequency time.Duration, rules []Rule, o
seriesInPreviousEval: make([]map[string]plabels.Labels, len(rules)),
done: make(chan struct{}),
terminated: make(chan struct{}),
notify: notify,
logger: opts.Logger,
notify: notify,
maintenanceStore: maintenanceStore,
logger: opts.Logger,
orgID: orgID,
}
}
@@ -324,12 +330,30 @@ func (g *PromRuleTask) Eval(ctx context.Context, ts time.Time) {
}()
g.logger.InfoContext(ctx, "promql rule task", "name", g.name, "eval_started_at", ts)
maintenance, err := g.maintenanceStore.ListPlannedMaintenance(ctx, g.orgID.StringValue())
if err != nil {
g.logger.ErrorContext(ctx, "error in processing sql query", errors.Attr(err))
}
for i, rule := range g.rules {
if rule == nil {
continue
}
shouldSkip := false
for _, m := range maintenance {
g.logger.InfoContext(ctx, "checking if rule should be skipped", slog.String("rule.id", rule.ID()), slog.Any("maintenance", m))
if m.ShouldSkip(rule.ID(), ts) {
shouldSkip = true
break
}
}
if shouldSkip {
g.logger.InfoContext(ctx, "rule should be skipped", slog.String("rule.id", rule.ID()))
continue
}
select {
case <-g.done:
return

View File

@@ -2,18 +2,20 @@ package rules
import (
"context"
"log/slog"
"runtime/debug"
"sort"
"sync"
"time"
"log/slog"
opentracing "github.com/opentracing/opentracing-go"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/types/authtypes"
"github.com/SigNoz/signoz/pkg/types/ctxtypes"
"github.com/SigNoz/signoz/pkg/types/ruletypes"
"github.com/SigNoz/signoz/pkg/valuer"
)
// RuleTask holds a rule (with composite queries)
@@ -35,28 +37,34 @@ type RuleTask struct {
pause bool
notify NotifyFunc
maintenanceStore ruletypes.MaintenanceStore
orgID valuer.UUID
}
const DefaultFrequency = 1 * time.Minute
// NewRuleTask makes a new RuleTask with the given name, options, and rules.
func NewRuleTask(name, file string, frequency time.Duration, rules []Rule, opts *ManagerOptions, notify NotifyFunc) *RuleTask {
func NewRuleTask(name, file string, frequency time.Duration, rules []Rule, opts *ManagerOptions, notify NotifyFunc, maintenanceStore ruletypes.MaintenanceStore, orgID valuer.UUID) *RuleTask {
if frequency == 0 {
frequency = DefaultFrequency
}
opts.Logger.Info("initiating a new rule task", "name", name, "frequency", frequency)
return &RuleTask{
name: name,
file: file,
pause: false,
frequency: frequency,
rules: rules,
opts: opts,
logger: opts.Logger,
done: make(chan struct{}),
terminated: make(chan struct{}),
notify: notify,
name: name,
file: file,
pause: false,
frequency: frequency,
rules: rules,
opts: opts,
logger: opts.Logger,
done: make(chan struct{}),
terminated: make(chan struct{}),
notify: notify,
maintenanceStore: maintenanceStore,
orgID: orgID,
}
}
@@ -64,7 +72,6 @@ func NewRuleTask(name, file string, frequency time.Duration, rules []Rule, opts
func (g *RuleTask) Name() string { return g.name }
// Key returns the group key
// TODO(jatinderjit): remove (unused)?
func (g *RuleTask) Key() string {
return g.name + ";" + g.file
}
@@ -76,7 +83,7 @@ func (g *RuleTask) Type() TaskType { return TaskTypeCh }
func (g *RuleTask) Rules() []Rule { return g.rules }
// Interval returns the group's interval.
// TODO(jatinderjit): remove (unused)?
// TODO: remove (unused)?
func (g *RuleTask) Interval() time.Duration { return g.frequency }
func (g *RuleTask) Pause(b bool) {
@@ -254,6 +261,7 @@ func nameAndLabels(rule Rule) string {
// Rules are matched based on their name and labels. If there are duplicates, the
// first is matched with the first, second with the second etc.
func (g *RuleTask) CopyState(fromTask Task) error {
from, ok := fromTask.(*RuleTask)
if !ok {
return errors.NewInternalf(errors.CodeInternal, "invalid from task for copy")
@@ -298,6 +306,7 @@ func (g *RuleTask) CopyState(fromTask Task) error {
// Eval runs a single evaluation cycle in which all rules are evaluated sequentially.
func (g *RuleTask) Eval(ctx context.Context, ts time.Time) {
defer func() {
if r := recover(); r != nil {
g.logger.ErrorContext(
@@ -309,11 +318,31 @@ func (g *RuleTask) Eval(ctx context.Context, ts time.Time) {
g.logger.DebugContext(ctx, "rule task eval started", "name", g.name, "start_time", ts)
maintenance, err := g.maintenanceStore.ListPlannedMaintenance(ctx, g.orgID.StringValue())
if err != nil {
g.logger.ErrorContext(ctx, "error in processing sql query", errors.Attr(err))
}
for i, rule := range g.rules {
if rule == nil {
continue
}
shouldSkip := false
for _, m := range maintenance {
g.logger.InfoContext(ctx, "checking if rule should be skipped", slog.String("rule.id", rule.ID()), slog.Any("maintenance", m))
if m.ShouldSkip(rule.ID(), ts) {
shouldSkip = true
break
}
}
if shouldSkip {
g.logger.InfoContext(ctx, "rule should be skipped", slog.String("rule.id", rule.ID()))
continue
}
select {
case <-g.done:
return
@@ -353,6 +382,7 @@ func (g *RuleTask) Eval(ctx context.Context, ts time.Time) {
}
rule.SendAlerts(ctx, ts, g.opts.ResendDelay, g.frequency, g.notify)
}(i, rule)
}
}

View File

@@ -3,6 +3,9 @@ package rules
import (
"context"
"time"
"github.com/SigNoz/signoz/pkg/types/ruletypes"
"github.com/SigNoz/signoz/pkg/valuer"
)
type TaskType string
@@ -29,9 +32,9 @@ type Task interface {
// newTask returns an appropriate group for
// rule type
func newTask(taskType TaskType, name, file string, frequency time.Duration, rules []Rule, opts *ManagerOptions, notify NotifyFunc) Task {
func newTask(taskType TaskType, name, file string, frequency time.Duration, rules []Rule, opts *ManagerOptions, notify NotifyFunc, maintenanceStore ruletypes.MaintenanceStore, orgID valuer.UUID) Task {
if taskType == TaskTypeCh {
return NewRuleTask(name, file, frequency, rules, opts, notify)
return NewRuleTask(name, file, frequency, rules, opts, notify, maintenanceStore, orgID)
}
return NewPromRuleTask(name, file, frequency, rules, opts, notify)
return NewPromRuleTask(name, file, frequency, rules, opts, notify, maintenanceStore, orgID)
}

View File

@@ -75,11 +75,10 @@ func (r *maintenance) CreatePlannedMaintenance(ctx context.Context, maintenance
CreatedBy: claims.Email,
UpdatedBy: claims.Email,
},
Name: maintenance.Name,
Description: maintenance.Description,
Schedule: maintenance.Schedule,
OrgID: claims.OrgID,
LabelExpression: maintenance.LabelExpression,
Name: maintenance.Name,
Description: maintenance.Description,
Schedule: maintenance.Schedule,
OrgID: claims.OrgID,
}
maintenanceRules := make([]*ruletypes.StorablePlannedMaintenanceRule, 0)
@@ -127,16 +126,15 @@ func (r *maintenance) CreatePlannedMaintenance(ctx context.Context, maintenance
}
return &ruletypes.PlannedMaintenance{
ID: storablePlannedMaintenance.ID,
Name: storablePlannedMaintenance.Name,
Description: storablePlannedMaintenance.Description,
Schedule: storablePlannedMaintenance.Schedule,
RuleIDs: maintenance.AlertIds,
LabelExpression: maintenance.LabelExpression,
CreatedAt: storablePlannedMaintenance.CreatedAt,
CreatedBy: storablePlannedMaintenance.CreatedBy,
UpdatedAt: storablePlannedMaintenance.UpdatedAt,
UpdatedBy: storablePlannedMaintenance.UpdatedBy,
ID: storablePlannedMaintenance.ID,
Name: storablePlannedMaintenance.Name,
Description: storablePlannedMaintenance.Description,
Schedule: storablePlannedMaintenance.Schedule,
RuleIDs: maintenance.AlertIds,
CreatedAt: storablePlannedMaintenance.CreatedAt,
CreatedBy: storablePlannedMaintenance.CreatedBy,
UpdatedAt: storablePlannedMaintenance.UpdatedAt,
UpdatedBy: storablePlannedMaintenance.UpdatedBy,
}, nil
}
@@ -177,11 +175,10 @@ func (r *maintenance) UpdatePlannedMaintenance(ctx context.Context, maintenance
CreatedBy: existing.CreatedBy,
UpdatedBy: claims.Email,
},
Name: maintenance.Name,
Description: maintenance.Description,
Schedule: maintenance.Schedule,
OrgID: claims.OrgID,
LabelExpression: maintenance.LabelExpression,
Name: maintenance.Name,
Description: maintenance.Description,
Schedule: maintenance.Schedule,
OrgID: claims.OrgID,
}
storablePlannedMaintenanceRules := make([]*ruletypes.StorablePlannedMaintenanceRule, 0)

View File

@@ -19,7 +19,6 @@ import (
"github.com/SigNoz/signoz/pkg/modules/user/impluser"
"github.com/SigNoz/signoz/pkg/querier"
"github.com/SigNoz/signoz/pkg/queryparser"
"github.com/SigNoz/signoz/pkg/ruler/rulestore/sqlrulestore"
"github.com/SigNoz/signoz/pkg/sharder"
"github.com/SigNoz/signoz/pkg/sharder/noopsharder"
"github.com/SigNoz/signoz/pkg/sqlstore"
@@ -39,8 +38,7 @@ func TestNewHandlers(t *testing.T) {
orgGetter := implorganization.NewGetter(implorganization.NewStore(sqlstore), sharder)
notificationManager := nfmanagertest.NewMock()
require.NoError(t, err)
maintenanceStore := sqlrulestore.NewMaintenanceStore(sqlstore)
alertmanager, err := signozalertmanager.New(providerSettings, alertmanager.Config{}, sqlstore, orgGetter, notificationManager, maintenanceStore)
alertmanager, err := signozalertmanager.New(context.TODO(), providerSettings, alertmanager.Config{}, sqlstore, orgGetter, notificationManager)
require.NoError(t, err)
tokenizer := tokenizertest.NewMockTokenizer(t)
emailing := emailingtest.New()

View File

@@ -20,7 +20,6 @@ import (
"github.com/SigNoz/signoz/pkg/modules/serviceaccount/implserviceaccount"
"github.com/SigNoz/signoz/pkg/modules/user/impluser"
"github.com/SigNoz/signoz/pkg/queryparser"
"github.com/SigNoz/signoz/pkg/ruler/rulestore/sqlrulestore"
"github.com/SigNoz/signoz/pkg/sharder"
"github.com/SigNoz/signoz/pkg/sharder/noopsharder"
"github.com/SigNoz/signoz/pkg/sqlstore"
@@ -40,8 +39,7 @@ func TestNewModules(t *testing.T) {
orgGetter := implorganization.NewGetter(implorganization.NewStore(sqlstore), sharder)
notificationManager := nfmanagertest.NewMock()
require.NoError(t, err)
maintenanceStore := sqlrulestore.NewMaintenanceStore(sqlstore)
alertmanager, err := signozalertmanager.New(providerSettings, alertmanager.Config{}, sqlstore, orgGetter, notificationManager, maintenanceStore)
alertmanager, err := signozalertmanager.New(context.TODO(), providerSettings, alertmanager.Config{}, sqlstore, orgGetter, notificationManager)
require.NoError(t, err)
tokenizer := tokenizertest.NewMockTokenizer(t)
emailing := emailingtest.New()

View File

@@ -65,7 +65,6 @@ import (
"github.com/SigNoz/signoz/pkg/tokenizer/tokenizerstore/sqltokenizerstore"
"github.com/SigNoz/signoz/pkg/types/alertmanagertypes"
"github.com/SigNoz/signoz/pkg/types/featuretypes"
ruletypes "github.com/SigNoz/signoz/pkg/types/ruletypes"
"github.com/SigNoz/signoz/pkg/version"
"github.com/SigNoz/signoz/pkg/web"
"github.com/SigNoz/signoz/pkg/web/noopweb"
@@ -196,7 +195,6 @@ func NewSQLMigrationProviderFactories(
sqlmigration.NewServiceAccountAuthzactory(sqlstore),
sqlmigration.NewDropUserDeletedAtFactory(sqlstore, sqlschema),
sqlmigration.NewMigrateAWSAllRegionsFactory(sqlstore),
sqlmigration.NewAddLabelExpressionToPlannedMaintenanceFactory(sqlstore, sqlschema),
)
}
@@ -223,14 +221,9 @@ func NewNotificationManagerProviderFactories(routeStore alertmanagertypes.RouteS
)
}
func NewAlertmanagerProviderFactories(
sqlstore sqlstore.SQLStore,
orgGetter organization.Getter,
nfManager nfmanager.NotificationManager,
maintenanceStore ruletypes.MaintenanceStore,
) factory.NamedMap[factory.ProviderFactory[alertmanager.Alertmanager, alertmanager.Config]] {
func NewAlertmanagerProviderFactories(sqlstore sqlstore.SQLStore, orgGetter organization.Getter, nfManager nfmanager.NotificationManager) factory.NamedMap[factory.ProviderFactory[alertmanager.Alertmanager, alertmanager.Config]] {
return factory.MustNewNamedMap(
signozalertmanager.NewFactory(sqlstore, orgGetter, nfManager, maintenanceStore),
signozalertmanager.NewFactory(sqlstore, orgGetter, nfManager),
)
}

View File

@@ -14,7 +14,6 @@ import (
"github.com/SigNoz/signoz/pkg/modules/user/impluser"
"github.com/SigNoz/signoz/pkg/sqlschema"
"github.com/SigNoz/signoz/pkg/sqlschema/sqlschematest"
"github.com/SigNoz/signoz/pkg/ruler/rulestore/sqlrulestore"
"github.com/SigNoz/signoz/pkg/sqlstore"
"github.com/SigNoz/signoz/pkg/sqlstore/sqlstoretest"
"github.com/SigNoz/signoz/pkg/statsreporter"
@@ -60,11 +59,9 @@ func TestNewProviderFactories(t *testing.T) {
})
assert.NotPanics(t, func() {
store := sqlstoretest.New(sqlstore.Config{Provider: "sqlite"}, sqlmock.QueryMatcherEqual)
orgGetter := implorganization.NewGetter(implorganization.NewStore(store), nil)
orgGetter := implorganization.NewGetter(implorganization.NewStore(sqlstoretest.New(sqlstore.Config{Provider: "sqlite"}, sqlmock.QueryMatcherEqual)), nil)
notificationManager := nfmanagertest.NewMock()
maintenanceStore := sqlrulestore.NewMaintenanceStore(store)
NewAlertmanagerProviderFactories(store, orgGetter, notificationManager, maintenanceStore)
NewAlertmanagerProviderFactories(sqlstoretest.New(sqlstore.Config{Provider: "sqlite"}, sqlmock.QueryMatcherEqual), orgGetter, notificationManager)
})
assert.NotPanics(t, func() {

View File

@@ -34,7 +34,6 @@ import (
"github.com/SigNoz/signoz/pkg/querier"
"github.com/SigNoz/signoz/pkg/queryparser"
"github.com/SigNoz/signoz/pkg/ruler"
"github.com/SigNoz/signoz/pkg/ruler/rulestore/sqlrulestore"
"github.com/SigNoz/signoz/pkg/sharder"
"github.com/SigNoz/signoz/pkg/sqlmigration"
"github.com/SigNoz/signoz/pkg/sqlmigrator"
@@ -363,14 +362,12 @@ func New(
return nil, err
}
maintenanceStore := sqlrulestore.NewMaintenanceStore(sqlstore)
// Initialize alertmanager from the available alertmanager provider factories
alertmanager, err := factory.NewProviderFromNamedMap(
ctx,
providerSettings,
config.Alertmanager,
NewAlertmanagerProviderFactories(sqlstore, orgGetter, nfManager, maintenanceStore),
NewAlertmanagerProviderFactories(sqlstore, orgGetter, nfManager),
config.Alertmanager.Provider,
)
if err != nil {

View File

@@ -1,97 +0,0 @@
package sqlmigration
import (
"context"
"github.com/SigNoz/signoz/pkg/factory"
"github.com/SigNoz/signoz/pkg/sqlschema"
"github.com/SigNoz/signoz/pkg/sqlstore"
"github.com/uptrace/bun"
"github.com/uptrace/bun/migrate"
)
type addLabelExpressionToPlannedMaintenance struct {
sqlstore sqlstore.SQLStore
sqlschema sqlschema.SQLSchema
}
func NewAddLabelExpressionToPlannedMaintenanceFactory(sqlstore sqlstore.SQLStore, sqlschema sqlschema.SQLSchema) factory.ProviderFactory[SQLMigration, Config] {
return factory.NewProviderFactory(
factory.MustNewName("add_label_expr_to_planned"),
func(ctx context.Context, ps factory.ProviderSettings, c Config) (SQLMigration, error) {
return &addLabelExpressionToPlannedMaintenance{
sqlstore: sqlstore,
sqlschema: sqlschema,
}, nil
},
)
}
func (migration *addLabelExpressionToPlannedMaintenance) Register(migrations *migrate.Migrations) error {
if err := migrations.Register(migration.Up, migration.Down); err != nil {
return err
}
return nil
}
func (migration *addLabelExpressionToPlannedMaintenance) Up(ctx context.Context, db *bun.DB) error {
tx, err := db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer func() {
_ = tx.Rollback()
}()
table, _, err := migration.sqlschema.GetTable(ctx, sqlschema.TableName("planned_maintenance"))
if err != nil {
return err
}
column := &sqlschema.Column{
Name: sqlschema.ColumnName("label_expression"),
DataType: sqlschema.DataTypeText,
Nullable: true,
}
sqls := migration.sqlschema.Operator().AddColumn(table, nil, column, nil)
for _, sql := range sqls {
if _, err := tx.ExecContext(ctx, string(sql)); err != nil {
return err
}
}
return tx.Commit()
}
func (migration *addLabelExpressionToPlannedMaintenance) Down(ctx context.Context, db *bun.DB) error {
tx, err := db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer func() {
_ = tx.Rollback()
}()
table, _, err := migration.sqlschema.GetTable(ctx, sqlschema.TableName("planned_maintenance"))
if err != nil {
return err
}
column := &sqlschema.Column{
Name: sqlschema.ColumnName("label_expression"),
DataType: sqlschema.DataTypeText,
Nullable: true,
}
sqls := migration.sqlschema.Operator().DropColumn(table, column)
for _, sql := range sqls {
if _, err := tx.ExecContext(ctx, string(sql)); err != nil {
return err
}
}
return tx.Commit()
}

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