Compare commits

...

7 Commits

Author SHA1 Message Date
Ashwin Bhatkal
c217cc96c3 chore: variable store set function (#10174)
Some checks are pending
build-staging / prepare (push) Waiting to run
build-staging / js-build (push) Blocked by required conditions
build-staging / go-build (push) Blocked by required conditions
build-staging / staging (push) Blocked by required conditions
Release Drafter / update_release_draft (push) Waiting to run
* chore: variable store set function

* chore: fix var
2026-02-03 14:49:44 +00:00
Vikrant Gupta
580cf32eb5 feat(authz): migrate rbac to authz (#10134)
* feat(authz): migrate rbac to authz

* feat(authz): handle public dashboard migration

* feat(authz): fix integration tests

* feat(authz): fix integration tests

* feat(authz): keep the error same as today
2026-02-03 19:54:07 +05:30
Abhi kumar
6d3580cbfa feat: added a new tooltip plugin (#10167)
* feat: added a new tooltip plugin

* fix: pr review comments

* fix: pr review comments

* fix: pr review comments

* chore: remove global.d.ts override
2026-02-03 14:06:16 +00:00
Abhi kumar
6c5d36caa9 fix: small fixes for scale + legend sizing (#10168)
* fix: small fixes for scale + legend sizing

* fix: minor naming fix
2026-02-03 17:55:17 +05:30
Abhishek Kumar Singh
c4a6c7e277 test(integration): alert firing verification fixture (#10131)
* chore: fixture for notification channel

* chore: return notification channel info in Create notification channel API

* fix: change scope of create channel fixture to function level

* test: added fixture for creating alert rule

* chore: added debug message on assertion failure

* refactor: improve error handling in webhook notification channel deletion

* fix: enhance error handling in alert rule creation and deletion

* chore: ran py linter and fmt

* chore: ran py linter and fmt

* fix: add timeout to alert rule creation and deletion requests

* fix: silenced pylint on too broad exception

* fix: suppress pylint warnings for broad exception handling in alert rule deletion

* test: added fixture for inserting alert data

* refactor: added fixture for getting test data file path

* feat: add alerts to integration CI workflow

* chore: linter fixes

* chore: changed scope for get_testdata_file_path

* feat: alert firing verification fixture

* feat: broken fixture for collect firing alerts

* chore: py-formatter

* chore: py-formatter

* refactor: updated expected alert to dataclass

* chore: updated get_testdata_file_path fixture to a util function

* chore: removed wrong ref

* chore: lint and formatted

* chore: moved utils function to alertutils from fixtures

* chore: return firing alert from collect alert func

---------

Co-authored-by: Srikanth Chekuri <srikanth.chekuri92@gmail.com>
2026-02-03 16:51:39 +05:30
SagarRajput-7
c9cd974dca feat: sidebar enhancement (#10157)
Some checks failed
build-staging / prepare (push) Has been cancelled
build-staging / js-build (push) Has been cancelled
build-staging / go-build (push) Has been cancelled
build-staging / staging (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
* feat: sidebar enhancement (#9748)

* fix: sidebar enhancement

* fix: new source btn changes

* fix: shortcut order changes

* fix: changes in more section collapse behaviour

* fix: sidebar shortcut changes, consistency, cleanup in collapse mode

* fix: sidebar pin, tooltip and other changes

* feat: updated alignment issues

* fix: sidenav enhancement - fixes

* fix: code fix

* fix: sidenav enhancement

* feat: addressed comments and feedback

* feat: fix default shortcut empty issue

* feat: code clean and improvements

* feat: refactor and cleanup

* feat: refactor and addressed comment

* feat: removed isscrolled

* feat: corrected the ref intialization
2026-02-03 07:58:21 +00:00
Abhi kumar
5b3f121431 feat: added line styling options for uplot (#10166) 2026-02-03 13:14:35 +05:30
51 changed files with 2754 additions and 333 deletions

View File

@@ -42,10 +42,11 @@ jobs:
- callbackauthn
- cloudintegrations
- dashboard
- querier
- ttl
- preference
- logspipelines
- preference
- querier
- role
- ttl
- alerts
sqlstore-provider:
- postgres

View File

@@ -79,7 +79,7 @@ func (module *module) CreatePublic(ctx context.Context, orgID valuer.UUID, publi
authtypes.MustNewSelector(authtypes.TypeMetaResource, publicDashboard.ID.String()),
)
err = module.roleSetter.PatchObjects(ctx, orgID, role.ID, authtypes.RelationRead, []*authtypes.Object{additionObject}, nil)
err = module.roleSetter.PatchObjects(ctx, orgID, role.Name, authtypes.RelationRead, []*authtypes.Object{additionObject}, nil)
if err != nil {
return err
}
@@ -208,7 +208,7 @@ func (module *module) DeletePublic(ctx context.Context, orgID valuer.UUID, dashb
authtypes.MustNewSelector(authtypes.TypeMetaResource, publicDashboard.ID.String()),
)
err = module.roleSetter.PatchObjects(ctx, orgID, role.ID, authtypes.RelationRead, nil, []*authtypes.Object{deletionObject})
err = module.roleSetter.PatchObjects(ctx, orgID, role.Name, authtypes.RelationRead, nil, []*authtypes.Object{deletionObject})
if err != nil {
return err
}
@@ -285,7 +285,7 @@ func (module *module) deletePublic(ctx context.Context, orgID valuer.UUID, dashb
authtypes.MustNewSelector(authtypes.TypeMetaResource, publicDashboard.ID.String()),
)
err = module.roleSetter.PatchObjects(ctx, orgID, role.ID, authtypes.RelationRead, nil, []*authtypes.Object{deletionObject})
err = module.roleSetter.PatchObjects(ctx, orgID, role.Name, authtypes.RelationRead, nil, []*authtypes.Object{deletionObject})
if err != nil {
return err
}

View File

@@ -116,18 +116,18 @@ func (setter *setter) Patch(ctx context.Context, orgID valuer.UUID, role *rolety
return setter.store.Update(ctx, orgID, roletypes.NewStorableRoleFromRole(role))
}
func (setter *setter) PatchObjects(ctx context.Context, orgID valuer.UUID, id valuer.UUID, relation authtypes.Relation, additions, deletions []*authtypes.Object) error {
func (setter *setter) PatchObjects(ctx context.Context, orgID valuer.UUID, name string, relation authtypes.Relation, additions, deletions []*authtypes.Object) error {
_, err := setter.licensing.GetActive(ctx, orgID)
if err != nil {
return errors.New(errors.TypeLicenseUnavailable, errors.CodeLicenseUnavailable, "a valid license is not available").WithAdditional("this feature requires a valid license").WithAdditional(err.Error())
}
additionTuples, err := roletypes.GetAdditionTuples(id, orgID, relation, additions)
additionTuples, err := roletypes.GetAdditionTuples(name, orgID, relation, additions)
if err != nil {
return err
}
deletionTuples, err := roletypes.GetDeletionTuples(id, orgID, relation, deletions)
deletionTuples, err := roletypes.GetDeletionTuples(name, orgID, relation, deletions)
if err != nil {
return err
}

View File

@@ -48,7 +48,7 @@
}
.app-content {
width: calc(100% - 64px); // width of the sidebar
width: calc(100% - 54px); // width of the sidebar
z-index: 0;
margin: 0 auto;

View File

@@ -1,5 +1,5 @@
import { MAX_LEGEND_WIDTH } from 'lib/uPlotV2/components/Legend/Legend';
import { LegendConfig, LegendPosition } from 'lib/uPlotV2/components/types';
export interface ChartDimensions {
width: number;
height: number;
@@ -13,7 +13,6 @@ const DEFAULT_AVG_LABEL_LENGTH = 15;
const LEGEND_GAP = 16;
const LEGEND_PADDING = 12;
const LEGEND_LINE_HEIGHT = 36;
const MAX_LEGEND_WIDTH = 400;
/**
* Average text width from series labels (for legendsPerSet).

View File

@@ -168,7 +168,7 @@
.ant-pagination {
position: fixed;
bottom: 0;
width: calc(100% - 64px);
width: calc(100% - 54px);
background: rgb(18, 19, 23);
padding: 16px;
margin: 0;

View File

@@ -442,7 +442,7 @@
.ant-pagination {
position: fixed;
bottom: 0;
width: calc(100% - 64px);
width: calc(100% - 54px);
background: var(--bg-ink-500);
padding: 16px;
margin: 0;

View File

@@ -58,7 +58,7 @@
overflow-y: auto;
box-sizing: border-box;
height: calc(100% - 64px);
height: calc(100% - 54px);
&::-webkit-scrollbar {
height: 1rem;

View File

@@ -194,7 +194,7 @@
.ant-pagination {
position: fixed;
bottom: 0;
width: calc(100% - 64px);
width: calc(100% - 54px);
background: var(--bg-ink-500);
padding: 16px;
margin: 0;

View File

@@ -13,6 +13,12 @@
.nav-item-active-marker {
background: #4e74f8;
}
.nav-item-data {
.nav-item-label {
color: var(--bg-vanilla-100, #fff);
}
}
}
&.disabled {
@@ -27,14 +33,14 @@
.nav-item-data {
color: white;
background: var(--Slate-500, #161922);
background: var(--bg-slate-500, #161922);
}
}
&.active {
.nav-item-data {
color: white;
background: var(--Slate-500, #161922);
background: var(--bg-slate-500, #161922);
// color: #3f5ecc;
}
}
@@ -50,9 +56,9 @@
.nav-item-data {
flex-grow: 1;
max-width: calc(100% - 24px);
max-width: calc(100% - 20px);
display: flex;
margin: 0px 8px;
margin: 0px 0px 0px 6px;
padding: 2px 8px;
flex-direction: row;
align-items: center;
@@ -68,7 +74,7 @@
background: transparent;
transition: 0.2s all linear;
transition: 0.08s all ease;
border-radius: 3px;
@@ -100,7 +106,7 @@
&:hover {
.nav-item-label {
color: var(--Vanilla-100, #fff);
color: var(--bg-vanilla-100, #fff);
}
.nav-item-pin-icon {
@@ -120,6 +126,12 @@
.nav-item-active-marker {
background: #4e74f8;
}
.nav-item-data {
.nav-item-label {
color: var(--bg-slate-500);
}
}
}
&:hover {

View File

@@ -1,11 +1,12 @@
/* eslint-disable jsx-a11y/no-static-element-interactions */
/* eslint-disable jsx-a11y/click-events-have-key-events */
import { Tag } from 'antd';
import { Tag, Tooltip } from 'antd';
import cx from 'classnames';
import { Pin, PinOff } from 'lucide-react';
import { SidebarItem } from '../sideNav.types';
import './NavItem.styles.scss';
import './NavItem.styles.scss';
export default function NavItem({
@@ -74,21 +75,25 @@ export default function NavItem({
)}
{onTogglePin && !isPinned && (
<Pin
size={12}
className="nav-item-pin-icon"
onClick={handleTogglePinClick}
color="var(--Vanilla-400, #c0c1c3)"
/>
<Tooltip title="Add to shortcuts" placement="right">
<Pin
size={12}
className="nav-item-pin-icon"
onClick={handleTogglePinClick}
color="var(--Vanilla-400, #c0c1c3)"
/>
</Tooltip>
)}
{onTogglePin && isPinned && (
<PinOff
size={12}
className="nav-item-pin-icon"
onClick={handleTogglePinClick}
color="var(--Vanilla-400, #c0c1c3)"
/>
<Tooltip title="Remove from shortcuts" placement="right">
<PinOff
size={12}
className="nav-item-pin-icon"
onClick={handleTogglePinClick}
color="var(--Vanilla-400, #c0c1c3)"
/>
</Tooltip>
)}
</div>
</div>

View File

@@ -1,5 +1,5 @@
.sidenav-container {
width: 64px;
width: 54px;
height: 100%;
position: relative;
z-index: 1;
@@ -10,47 +10,60 @@
}
.sideNav {
flex: 0 0 64px;
flex: 0 0 54px;
height: 100%;
max-width: 64px;
min-width: 64px;
width: 64px;
border-right: 1px solid var(--Slate-500, #161922);
background: var(--Ink-500, #0b0c0e);
max-width: 54px;
min-width: 54px;
width: 54px;
border-right: 1px solid var(--bg-slate-500, #161922);
background: var(--bg-ink-500, #0b0c0e);
padding-bottom: 48px;
transition: all 0.2s, background 0s, border 0s;
transition: all 0.08s ease, background 0s, border 0s;
.brand-container {
padding: 8px 18px;
padding: 8px 15px;
max-width: 100%;
background: transparent;
}
.brand-company-meta {
display: flex;
gap: 8px;
align-items: center;
gap: 6px;
flex-shrink: 0;
width: 100%;
justify-content: center;
}
.brand {
display: flex;
align-items: center;
justify-content: center;
max-width: 100%;
overflow: hidden;
gap: 32px;
height: 32px;
width: 100%;
.brand-logo {
display: flex;
align-items: center;
justify-content: space-between;
justify-content: center;
gap: 8px;
flex-shrink: 0;
width: 20px;
height: 16px;
position: relative;
cursor: pointer;
img {
height: 16px;
width: auto;
display: block;
}
.brand-logo-name {
@@ -66,6 +79,10 @@
.brand-title-section {
display: none;
flex-shrink: 0;
align-items: center;
gap: 0;
position: relative;
.license-type {
display: flex;
@@ -76,7 +93,7 @@
color: var(--bg-vanilla-100);
border-radius: 4px 0px 0px 4px;
background: var(--Slate-400, #1d212d);
background: var(--bg-slate-400, #1d212d);
text-align: center;
font-family: Inter;
@@ -98,11 +115,11 @@
gap: 6px;
border-radius: 0px 4px 4px 0px;
background: var(--Slate-300, #242834);
background: var(--bg-slate-300, #242834);
}
.version {
color: var(--Vanilla-400, #c0c1c3);
color: var(--bg-vanilla-400, #c0c1c3);
text-align: center;
font-variant-numeric: lining-nums tabular-nums slashed-zero;
font-feature-settings: 'salt' on;
@@ -156,24 +173,48 @@
.get-started-nav-items {
display: flex;
margin: 4px 13px 12px 10px;
margin: 4px 10px 12px 8px;
.get-started-btn {
display: flex;
align-items: center;
justify-content: center;
padding: 8px;
margin-left: 2px;
gap: 8px;
width: 100%;
height: 32px;
border-radius: 3px;
border: 1px solid var(--Slate-400, #1d212d);
background: var(--Slate-500, #161922);
border: 1px solid var(--bg-slate-400, #1d212d);
background: var(--bg-slate-500, #161922);
box-shadow: none !important;
color: var(--bg-vanilla-400, #c0c1c3);
svg {
color: var(--bg-vanilla-400, #c0c1c3);
}
.nav-item-label {
color: var(--bg-vanilla-400, #c0c1c3);
}
&:hover:not(:disabled) {
background: var(--bg-slate-400, #1d212d);
border-color: var(--bg-slate-400, #1d212d);
color: var(--bg-vanilla-100, #fff);
svg {
color: var(--bg-vanilla-100, #fff);
}
.nav-item-label {
color: var(--bg-vanilla-100, #fff);
}
}
}
}
@@ -192,6 +233,10 @@
width: 100%;
}
.nav-item {
margin-bottom: 6px;
}
.nav-top-section {
display: flex;
flex-direction: column;
@@ -227,7 +272,7 @@
}
.nav-section-title {
color: var(--Slate-50, #62687c);
color: var(--bg-slate-50, #62687c);
font-family: Inter;
font-size: 11px;
font-style: normal;
@@ -241,7 +286,7 @@
align-items: center;
gap: 8px;
padding: 0 20px;
padding: 0 17px;
.nav-section-title-text {
display: none;
@@ -250,11 +295,17 @@
.nav-section-title-icon {
display: flex;
align-items: center;
transition: opacity 0.08s ease, transform 0.08s ease;
&.reorder {
display: none;
cursor: pointer;
margin-left: auto;
transition: color 0.2s;
&:hover {
color: var(--bg-vanilla-100, #fff);
}
}
}
@@ -268,7 +319,7 @@
}
.nav-section-subtitle {
color: var(--Vanilla-400, #c0c1c3);
color: var(--bg-vanilla-400, #c0c1c3);
font-family: Inter;
font-size: 11px;
font-style: normal;
@@ -276,20 +327,20 @@
line-height: 14px; /* 150% */
letter-spacing: 0.4px;
padding: 0 20px;
padding: 6px 20px;
opacity: 0.6;
display: none;
transition: all 0.3s, background 0s, border 0s;
transition-delay: 0.1s;
transition: all 0.08s ease, background 0s, border 0s;
transition-delay: 0.03s;
}
.nav-items-section {
margin-top: 8px;
display: flex;
flex-direction: column;
gap: 4px;
transition: all 0.08s ease;
}
}
@@ -302,7 +353,7 @@
.nav-items-section {
opacity: 0;
transform: translateY(-10px);
transition: all 0.4s ease;
transition: all 0.1s ease;
overflow: hidden;
height: 0;
}
@@ -312,11 +363,34 @@
.nav-items-section {
opacity: 1;
transform: translateY(0);
transition: all 0.4s ease;
transition: all 0.1s ease;
overflow: hidden;
height: auto;
}
}
&.sidebar-collapsed {
.nav-title-section {
display: none;
}
.nav-items-section {
margin-top: 0;
opacity: 1;
transform: translateY(0);
transition: all 0.08s ease;
height: auto;
overflow: visible;
}
}
}
.shortcut-nav-items {
&.sidebar-collapsed {
.nav-items-section {
margin-top: 0;
}
}
}
.scroll-for-more-container {
@@ -326,7 +400,7 @@
width: 100%;
bottom: 12px;
bottom: 8px;
margin-left: 50px;
margin-left: 43px;
.scroll-for-more {
display: flex;
@@ -370,8 +444,6 @@
overflow-y: auto;
overflow-x: hidden;
padding-top: 12px;
.secondary-nav-items {
display: flex;
flex-direction: column;
@@ -381,10 +453,10 @@
overflow-x: hidden;
padding: 8px 0;
max-width: 100%;
width: 64px;
width: 54px;
// width: 100%; // temp
transition: all 0.2s, background 0s, border 0s;
transition: all 0.08s ease, background 0s, border 0s;
background: linear-gradient(180deg, rgba(11, 12, 14, 0) 0%, #0b0c0e 27.11%);
@@ -413,7 +485,7 @@
&.scroll-available {
.nav-bottom-section {
border-top: 1px solid var(--Slate-500, #161922);
border-top: 1px solid var(--bg-slate-500, #161922);
}
}
}
@@ -424,24 +496,53 @@
}
&.collapsed {
flex: 0 0 64px;
max-width: 64px;
min-width: 64px;
width: 64px;
flex: 0 0 54px;
max-width: 54px;
min-width: 54px;
width: 54px;
.nav-wrapper {
.nav-top-section {
.shortcut-nav-items {
.nav-section-title,
.nav-section-title {
display: none;
}
.nav-section-subtitle {
display: none;
}
.nav-items-section {
display: flex;
margin-top: 0;
}
.nav-title-section {
margin-top: 0;
margin-bottom: 0;
gap: 0;
}
}
.more-nav-items {
.nav-section-title {
display: none;
}
.nav-items-section {
display: flex;
margin-top: 0;
}
.nav-title-section {
display: none;
}
}
}
.nav-bottom-section {
.secondary-nav-items {
width: 64px;
width: 54px;
}
}
}
@@ -466,7 +567,7 @@
border-radius: 12px;
background: var(--Robin-500, #4e74f8);
color: var(--Vanilla-100, #fff);
color: var(--bg-vanilla-100, #fff);
font-variant-numeric: lining-nums tabular-nums slashed-zero;
font-feature-settings: 'case' on, 'cpsp' on, 'dlig' on, 'salt' on;
font-family: Inter;
@@ -479,7 +580,7 @@
}
.sidenav-beta-tag {
color: var(--Vanilla-100, #fff);
color: var(--bg-vanilla-100, #fff);
font-variant-numeric: lining-nums tabular-nums slashed-zero;
font-feature-settings: 'case' on, 'cpsp' on, 'dlig' on, 'salt' on;
font-family: Inter;
@@ -494,7 +595,47 @@
background: var(--bg-slate-300);
}
&:hover {
&:not(.pinned) {
.nav-item {
.nav-item-data {
justify-content: center;
}
}
.shortcut-nav-items,
.more-nav-items {
.nav-section-title {
padding: 0 17px;
.nav-section-title-icon {
display: none;
}
}
}
&.dropdown-open {
.nav-item {
.nav-item-data {
flex-grow: 1;
justify-content: flex-start;
}
}
.shortcut-nav-items,
.more-nav-items {
.nav-section-title {
padding: 0 17px;
.nav-section-title-icon {
display: flex;
}
}
}
}
}
&:not(.pinned):hover,
&.dropdown-open {
flex: 0 0 240px;
max-width: 240px;
min-width: 240px;
@@ -505,8 +646,17 @@
z-index: 10;
background: #0b0c0e;
.brand-container {
padding: 8px 15px;
}
.brand {
justify-content: space-between;
justify-content: flex-start;
.brand-company-meta {
justify-content: flex-start;
width: 100%;
}
.brand-title-section {
display: flex;
@@ -533,6 +683,11 @@
.nav-section-title-icon {
&.reorder {
display: flex;
transition: color 0.2s;
&:hover {
color: var(--bg-vanilla-100, #fff);
}
}
}
}
@@ -574,7 +729,7 @@
flex-direction: row;
gap: 3px;
border-radius: 20px;
background: var(--Slate-400, #1d212d);
background: var(--bg-slate-400, #1d212d);
/* Drop Shadow */
box-shadow: 0px 103px 12px 0px rgba(0, 0, 0, 0.01),
@@ -590,7 +745,7 @@
width: 140px;
.scroll-for-more-label {
color: var(--Vanilla-400, #c0c1c3);
color: var(--bg-vanilla-400, #c0c1c3);
font-family: Inter;
font-size: 12px;
font-style: normal;
@@ -631,6 +786,13 @@
align-items: flex-start;
}
}
.nav-item {
.nav-item-data {
flex-grow: 1;
justify-content: flex-start;
}
}
}
.get-started-nav-items {
@@ -664,8 +826,17 @@
z-index: 10;
background: #0b0c0e;
.brand-container {
padding: 8px 15px;
}
.brand {
justify-content: space-between;
justify-content: flex-start;
.brand-company-meta {
justify-content: flex-start;
width: 100%;
}
.brand-title-section {
display: flex;
@@ -692,6 +863,11 @@
.nav-section-title-icon {
&.reorder {
display: flex;
transition: color 0.2s;
&:hover {
color: var(--bg-vanilla-100, #fff);
}
}
}
}
@@ -733,7 +909,7 @@
flex-direction: row;
gap: 3px;
border-radius: 20px;
background: var(--Slate-400, #1d212d);
background: var(--bg-slate-400, #1d212d);
/* Drop Shadow */
box-shadow: 0px 103px 12px 0px rgba(0, 0, 0, 0.01),
@@ -751,7 +927,7 @@
.scroll-for-more-label {
display: block;
color: var(--Vanilla-400, #c0c1c3);
color: var(--bg-vanilla-400, #c0c1c3);
font-family: Inter;
font-size: 12px;
font-style: normal;
@@ -856,7 +1032,7 @@
.ant-dropdown-menu-item {
.ant-dropdown-menu-title-content {
color: var(--Vanilla-400, #c0c1c3);
color: var(--bg-vanilla-400, #c0c1c3);
font-family: Inter;
font-size: 12px;
font-style: normal;
@@ -864,6 +1040,12 @@
line-height: normal;
letter-spacing: 0.14px;
}
&:hover:not(.ant-dropdown-menu-item-disabled) {
.ant-dropdown-menu-title-content {
color: var(--bg-vanilla-100, #fff);
}
}
}
}
}
@@ -875,7 +1057,7 @@
gap: 8px;
.user-settings-dropdown-label-text {
color: var(--Slate-50, #62687c);
color: var(--bg-slate-50, #62687c);
font-family: Inter;
font-size: 10px;
font-family: Inter;
@@ -887,7 +1069,7 @@
}
.user-settings-dropdown-label-email {
color: var(--Vanilla-400, #c0c1c3);
color: var(--bg-vanilla-400, #c0c1c3);
font-family: Inter;
font-size: 12px;
font-style: normal;
@@ -897,12 +1079,16 @@
}
.ant-dropdown-menu-item-divider {
background-color: var(--Slate-500, #161922) !important;
background-color: var(--bg-slate-500, #161922) !important;
}
.ant-dropdown-menu-item-disabled {
opacity: 0.7;
}
.ant-dropdown-menu {
width: 100% !important;
}
}
.settings-dropdown,
@@ -912,6 +1098,27 @@
}
}
.secondary-nav-items {
.nav-item {
position: relative;
.nav-item-active-marker {
position: absolute;
left: -5px;
top: 50%;
transform: translateY(-50%);
margin: 0;
width: 8px;
height: 24px;
z-index: 1;
}
}
.nav-item-data {
margin-left: 8px !important;
}
}
.reorder-shortcut-nav-items-modal {
width: 384px !important;
@@ -1028,7 +1235,6 @@
display: flex;
align-items: center;
border-radius: 2px;
border-radius: 2px;
background: var(--Robin-500, #4e74f8) !important;
color: var(--bg-vanilla-100) !important;
font-family: Inter;
@@ -1038,10 +1244,10 @@
line-height: 24px;
&.secondary-btn {
background-color: var(--Slate-500, #161922) !important;
background-color: var(--bg-slate-500, #161922) !important;
border: 1px solid var(--bg-slate-500) !important;
color: var(--Vanilla-400, #c0c1c3) !important;
color: var(--bg-vanilla-400, #c0c1c3) !important;
/* button/ small */
font-family: Inter;
@@ -1064,6 +1270,10 @@
}
}
.help-support-dropdown li.ant-dropdown-menu-item-divider {
background-color: var(--bg-slate-500, #161922) !important;
}
.lightMode {
.sideNav {
background: var(--bg-vanilla-100);
@@ -1095,8 +1305,32 @@
.get-started-nav-items {
.get-started-btn {
border: 1px solid var(--bg-vanilla-300);
background: var(--bg-vanilla-100);
color: var(--bg-ink-400);
background: var(--bg-vanilla-200);
color: var(--bg-slate-50, #62687c);
svg {
color: var(--bg-slate-50, #62687c);
}
.nav-item-label {
color: var(--bg-ink-400, #62687c);
}
// Hover state (light mode)
&:hover:not(:disabled) {
background: var(--bg-vanilla-300);
border-color: var(--bg-vanilla-300);
color: var(--bg-slate-500, #161922);
svg {
color: var(--bg-slate-500, #161922);
}
.nav-item-label {
color: var(--bg-slate-500, #161922);
}
}
}
}
@@ -1108,7 +1342,25 @@
}
}
.brand-container {
background: transparent;
}
.nav-wrapper {
.nav-top-section {
.shortcut-nav-items {
.nav-section-title {
.nav-section-title-icon {
&.reorder {
&:hover {
color: var(--bg-slate-400, #1d212d);
}
}
}
}
}
}
.secondary-nav-items {
border-top: 1px solid var(--bg-vanilla-300);
@@ -1123,8 +1375,43 @@
}
}
&:hover {
&.pinned {
.nav-wrapper {
.nav-top-section {
.shortcut-nav-items {
.nav-section-title {
.nav-section-title-icon {
&.reorder {
&:hover {
color: var(--bg-slate-400, #1d212d);
}
}
}
}
}
}
}
}
&:not(.pinned):hover,
&.dropdown-open {
background: var(--bg-vanilla-100);
.nav-wrapper {
.nav-top-section {
.shortcut-nav-items {
.nav-section-title {
.nav-section-title-icon {
&.reorder {
&:hover {
color: var(--bg-slate-400, #1d212d);
}
}
}
}
}
}
}
}
}
@@ -1134,6 +1421,12 @@
.ant-dropdown-menu-title-content {
color: var(--bg-ink-400);
}
&:hover:not(.ant-dropdown-menu-item-disabled) {
.ant-dropdown-menu-title-content {
color: var(--bg-ink-500);
}
}
}
}
}
@@ -1210,6 +1503,10 @@
color: var(--bg-ink-400);
}
}
.help-support-dropdown li.ant-dropdown-menu-item-divider {
background-color: var(--bg-vanilla-300) !important;
}
}
.version-tooltip-overlay {
@@ -1222,7 +1519,7 @@
border-radius: 2px;
border: 1px solid var(--bg-slate-500);
color: var(--Vanilla-100, #fff);
color: var(--bg-vanilla-100, #fff);
font-family: Inter;
font-size: 11px;
font-style: normal;
@@ -1237,7 +1534,7 @@
gap: 4px;
.version-update-notification-tooltip-title {
color: var(--Vanilla-100, #fff);
color: var(--bg-vanilla-100, #fff);
font-family: Inter;
font-size: 11px;
font-style: normal;
@@ -1247,7 +1544,7 @@
}
.version-update-notification-tooltip-content {
color: var(--Vanilla-100, #fff);
color: var(--bg-vanilla-100, #fff);
font-family: Inter;
font-size: 10px;
font-style: normal;

View File

@@ -157,18 +157,27 @@ function SideNav({ isPinned }: { isPinned: boolean }): JSX.Element {
DefaultHelpSupportDropdownMenuItems,
);
const [pinnedMenuItems, setPinnedMenuItems] = useState<SidebarItem[]>([]);
const [tempPinnedMenuItems, setTempPinnedMenuItems] = useState<SidebarItem[]>(
[],
);
const [secondaryMenuItems, setSecondaryMenuItems] = useState<SidebarItem[]>(
[],
);
const [hasScroll, setHasScroll] = useState(false);
const navTopSectionRef = useRef<HTMLDivElement>(null);
const [isDropdownOpen, setIsDropdownOpen] = useState(false);
const [isHovered, setIsHovered] = useState(false);
const [pinnedMenuItems, setPinnedMenuItems] = useState<SidebarItem[]>([]);
const [secondaryMenuItems, setSecondaryMenuItems] = useState<SidebarItem[]>(
[],
);
const handleMouseEnter = useCallback(() => {
setIsHovered(true);
}, []);
const handleMouseLeave = useCallback(() => {
setIsHovered(false);
}, []);
const checkScroll = useCallback((): void => {
if (navTopSectionRef.current) {
@@ -217,63 +226,68 @@ function SideNav({ isPinned }: { isPinned: boolean }): JSX.Element {
const isAdmin = user.role === USER_ROLES.ADMIN;
const isEditor = user.role === USER_ROLES.EDITOR;
useEffect(() => {
const navShortcuts = (userPreferences?.find(
// Compute initial pinned items and secondary menu items synchronously to avoid flash
const computedPinnedMenuItems = useMemo(() => {
const navShortcutsPreference = userPreferences?.find(
(preference) => preference.name === USER_PREFERENCES.NAV_SHORTCUTS,
)?.value as unknown) as string[];
);
const navShortcuts = (navShortcutsPreference?.value as unknown) as
| string[]
| undefined;
const shouldShowIntegrations =
(isCloudUser || isEnterpriseSelfHostedUser) && (isAdmin || isEditor);
// If userPreferences not loaded yet, return empty to avoid showing defaults before preferences load
if (userPreferences === null) {
return [];
}
if (navShortcuts && isArray(navShortcuts) && navShortcuts.length > 0) {
// nav shortcuts is array of strings
const pinnedItems = navShortcuts
// If preference exists with non-empty array, use stored shortcuts
if (isArray(navShortcuts) && navShortcuts.length > 0) {
return navShortcuts
.map((shortcut) =>
defaultMoreMenuItems.find((item) => item.itemKey === shortcut),
)
.filter((item): item is SidebarItem => item !== undefined);
// Set pinned items in the order they were stored
setPinnedMenuItems(pinnedItems);
setSecondaryMenuItems(
defaultMoreMenuItems.map((item) => ({
...item,
isPinned: pinnedItems.some((pinned) => pinned.itemKey === item.itemKey),
isEnabled:
item.key === ROUTES.INTEGRATIONS
? shouldShowIntegrations
: item.isEnabled,
})),
);
} else {
// Set default pinned items
const defaultPinnedItems = defaultMoreMenuItems.filter(
(item) => item.isPinned,
);
setPinnedMenuItems(defaultPinnedItems);
setSecondaryMenuItems(
defaultMoreMenuItems.map((item) => ({
...item,
isPinned: defaultPinnedItems.some(
(pinned) => pinned.itemKey === item.itemKey,
),
isEnabled:
item.key === ROUTES.INTEGRATIONS
? shouldShowIntegrations
: item.isEnabled,
})),
);
}
// No preference, or empty array → use defaults
return defaultMoreMenuItems.filter((item) => item.isPinned);
}, [userPreferences]);
const computedSecondaryMenuItems = useMemo(() => {
const shouldShowIntegrationsValue =
(isCloudUser || isEnterpriseSelfHostedUser) && (isAdmin || isEditor);
return defaultMoreMenuItems.map((item) => ({
...item,
isPinned: computedPinnedMenuItems.some(
(pinned) => pinned.itemKey === item.itemKey,
),
isEnabled:
item.key === ROUTES.INTEGRATIONS
? shouldShowIntegrationsValue
: item.isEnabled,
}));
}, [
userPreferences,
computedPinnedMenuItems,
isCloudUser,
isEnterpriseSelfHostedUser,
isAdmin,
isEditor,
]);
// Track if we've done the initial sync (to avoid overwriting user actions during session)
const hasInitializedRef = useRef(false);
// Sync state only on initial load when userPreferences first becomes available
useEffect(() => {
// Only sync once: when userPreferences loads for the first time
if (!hasInitializedRef.current && userPreferences !== null) {
setPinnedMenuItems(computedPinnedMenuItems);
setSecondaryMenuItems(computedSecondaryMenuItems);
hasInitializedRef.current = true;
}
}, [computedPinnedMenuItems, computedSecondaryMenuItems, userPreferences]);
const isOnboardingV3Enabled = featureFlags?.find(
(flag) => flag.name === FeatureKeys.ONBOARDING_V3,
)?.active;
@@ -327,6 +341,17 @@ function SideNav({ isPinned }: { isPinned: boolean }): JSX.Element {
.map((item) => item.itemKey)
.filter(Boolean) as string[];
// Update context immediately (optimistically) so computed values reflect the change
updateUserPreferenceInContext({
name: USER_PREFERENCES.NAV_SHORTCUTS,
description: USER_PREFERENCES.NAV_SHORTCUTS,
valueType: 'array',
defaultValue: false,
allowedValues: [],
allowedScopes: ['user'],
value: navShortcuts,
});
updateUserPreferenceMutation(
{
name: USER_PREFERENCES.NAV_SHORTCUTS,
@@ -335,6 +360,7 @@ function SideNav({ isPinned }: { isPinned: boolean }): JSX.Element {
{
onSuccess: (response) => {
if (response.data) {
// Update context again on success to ensure consistency
updateUserPreferenceInContext({
name: USER_PREFERENCES.NAV_SHORTCUTS,
description: USER_PREFERENCES.NAV_SHORTCUTS,
@@ -368,13 +394,13 @@ function SideNav({ isPinned }: { isPinned: boolean }): JSX.Element {
if (isCurrentlyPinned) {
return prevItems.filter((i) => i.key !== item.key);
}
return [item, ...prevItems];
return [...prevItems, item];
});
// Get the updated pinned menu items for preference update
const updatedPinnedItems = pinnedMenuItems.some((i) => i.key === item.key)
? pinnedMenuItems.filter((i) => i.key !== item.key)
: [item, ...pinnedMenuItems];
: [...pinnedMenuItems, item];
// Update user preference with the ordered list of item keys
updateNavShortcutsPreference(updatedPinnedItems);
@@ -455,6 +481,10 @@ function SideNav({ isPinned }: { isPinned: boolean }): JSX.Element {
pathname,
]);
const isSettingsPage = useMemo(() => pathname.startsWith(ROUTES.SETTINGS), [
pathname,
]);
const userSettingsDropdownMenuItems: MenuProps['items'] = useMemo(
() =>
[
@@ -594,7 +624,7 @@ function SideNav({ isPinned }: { isPinned: boolean }): JSX.Element {
},
{
type: 'group',
label: "WHAT's NEW",
label: "WHAT'S NEW",
},
...dropdownItems,
{
@@ -750,6 +780,15 @@ function SideNav({ isPinned }: { isPinned: boolean }): JSX.Element {
[secondaryMenuItems],
);
// Get active "More" items that should be visible in collapsed state
const activeMoreMenuItems = useMemo(
() => moreMenuItems.filter((item) => activeMenuKey === item.key),
[moreMenuItems, activeMenuKey],
);
// Check if sidebar is collapsed (not pinned, not hovered, and no dropdown open)
const isCollapsed = !isPinned && !isHovered && !isDropdownOpen;
const renderNavItems = (
items: SidebarItem[],
allowPin?: boolean,
@@ -901,7 +940,15 @@ function SideNav({ isPinned }: { isPinned: boolean }): JSX.Element {
return (
<div className={cx('sidenav-container', isPinned && 'pinned')}>
<div className={cx('sideNav', isPinned && 'pinned')}>
<div
className={cx(
'sideNav',
isPinned && 'pinned',
isDropdownOpen && 'dropdown-open',
)}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
>
<div className="brand-container">
<div className="brand">
<div className="brand-company-meta">
@@ -999,35 +1046,43 @@ function SideNav({ isPinned }: { isPinned: boolean }): JSX.Element {
{renderNavItems(primaryMenuItems)}
</div>
<div className="shortcut-nav-items">
<div className="nav-title-section">
<div className="nav-section-title">
<div className="nav-section-title-icon">
<MousePointerClick size={16} />
</div>
{(pinnedMenuItems.length > 0 || !isCollapsed) && (
<div
className={cx('shortcut-nav-items', isCollapsed && 'sidebar-collapsed')}
>
{!isCollapsed && (
<div className="nav-title-section">
<div className="nav-section-title">
<div className="nav-section-title-icon">
<MousePointerClick size={16} />
</div>
<div className="nav-section-title-text">SHORTCUTS</div>
<div className="nav-section-title-text">SHORTCUTS</div>
{pinnedMenuItems.length > 1 && (
<div
className="nav-section-title-icon reorder"
onClick={(): void => {
logEvent('Sidebar V2: Manage shortcuts clicked', {});
setIsReorderShortcutNavItemsModalOpen(true);
}}
>
<Logs size={16} />
{pinnedMenuItems.length > 1 && (
<Tooltip title="Manage shortcuts" placement="right">
<div
className="nav-section-title-icon reorder"
onClick={(): void => {
logEvent('Sidebar V2: Manage shortcuts clicked', {});
setIsReorderShortcutNavItemsModalOpen(true);
}}
>
<Logs size={16} />
</div>
</Tooltip>
)}
</div>
)}
</div>
{pinnedMenuItems.length === 0 && (
<div className="nav-section-subtitle">
You have not added any shortcuts yet.
{pinnedMenuItems.length === 0 && (
<div className="nav-section-subtitle">
You have not added any shortcuts yet.
</div>
)}
</div>
)}
{pinnedMenuItems.length > 0 && (
{(pinnedMenuItems.length > 0 || isCollapsed) && (
<div className="nav-items-section">
{renderNavItems(
pinnedMenuItems.filter((item) => item.isEnabled),
@@ -1036,46 +1091,60 @@ function SideNav({ isPinned }: { isPinned: boolean }): JSX.Element {
</div>
)}
</div>
</div>
)}
{moreMenuItems.length > 0 && (
<div
className={cx(
'more-nav-items',
isMoreMenuCollapsed ? 'collapsed' : 'expanded',
isCollapsed && 'sidebar-collapsed',
)}
>
<div className="nav-title-section">
<div
className="nav-section-title"
onClick={(): void => {
logEvent('Sidebar V2: More menu clicked', {
action: isMoreMenuCollapsed ? 'expand' : 'collapse',
});
setIsMoreMenuCollapsed(!isMoreMenuCollapsed);
}}
>
<div className="nav-section-title-icon">
<Ellipsis size={16} />
</div>
{!isCollapsed && (
<div className="nav-title-section">
<div
className="nav-section-title"
onClick={(): void => {
// Only allow toggling when sidebar is open (pinned, hovered, or dropdown open)
if (isCollapsed) {
return;
}
const newCollapsedState = !isMoreMenuCollapsed;
logEvent('Sidebar V2: More menu clicked', {
action: isMoreMenuCollapsed ? 'expand' : 'collapse',
});
setIsMoreMenuCollapsed(newCollapsedState);
}}
>
<div className="nav-section-title-icon">
<Ellipsis size={16} />
</div>
<div className="nav-section-title-text">MORE</div>
<div className="nav-section-title-text">MORE</div>
<div className="collapse-expand-section-icon">
{isMoreMenuCollapsed ? (
<ChevronDown size={16} />
) : (
<ChevronUp size={16} />
)}
<div className="collapse-expand-section-icon">
{isMoreMenuCollapsed ? (
<ChevronDown size={16} />
) : (
<ChevronUp size={16} />
)}
</div>
</div>
</div>
</div>
)}
<div className="nav-items-section">
{renderNavItems(
moreMenuItems.filter((item) => item.isEnabled),
true,
)}
{/* Show all items when expanded, only active items when collapsed */}
{isCollapsed
? renderNavItems(
activeMoreMenuItems.filter((item) => item.isEnabled),
true,
)
: renderNavItems(
moreMenuItems.filter((item) => item.isEnabled),
true,
)}
</div>
</div>
)}
@@ -1102,6 +1171,7 @@ function SideNav({ isPinned }: { isPinned: boolean }): JSX.Element {
placement="topLeft"
overlayClassName="nav-dropdown-overlay help-support-dropdown"
trigger={['click']}
onOpenChange={(open): void => setIsDropdownOpen(open)}
>
<div className="nav-item">
<div className="nav-item-data" data-testid="help-support-nav-item">
@@ -1122,8 +1192,10 @@ function SideNav({ isPinned }: { isPinned: boolean }): JSX.Element {
placement="topLeft"
overlayClassName="nav-dropdown-overlay settings-dropdown"
trigger={['click']}
onOpenChange={(open): void => setIsDropdownOpen(open)}
>
<div className="nav-item">
<div className={cx('nav-item', isSettingsPage && 'active')}>
<div className="nav-item-active-marker" />
<div className="nav-item-data" data-testid="settings-nav-item">
<div className="nav-item-icon">{userSettingsMenuItem.icon}</div>

View File

@@ -59,7 +59,6 @@
gap: 6px;
padding: 4px 8px;
border-radius: 4px;
max-width: min(400px, 100%);
cursor: pointer;
&.legend-item-off {

View File

@@ -11,6 +11,7 @@ import { useLegendActions } from './useLegendActions';
import './Legend.styles.scss';
export const MAX_LEGEND_WIDTH = 320;
const LEGENDS_PER_SET_DEFAULT = 5;
export default function Legend({
@@ -66,6 +67,7 @@ export default function Legend({
'legend-item-off': !item.show,
'legend-item-focused': focusedSeriesIndex === item.seriesIndex,
})}
style={{ maxWidth: `min(${MAX_LEGEND_WIDTH}px, 100%)` }}
>
<div
className="legend-marker"

View File

@@ -39,7 +39,7 @@ export default function Tooltip({
data: uPlotInstance.data,
series: uPlotInstance.series,
dataIndexes,
activeSeriesIdx: seriesIndex,
activeSeriesIndex: seriesIndex,
uPlotInstance,
yAxisUnit,
decimalPrecision,

View File

@@ -9,10 +9,10 @@ const FALLBACK_SERIES_COLOR = '#000000';
export function resolveSeriesColor(
stroke: Series.Stroke | undefined,
u: uPlot,
seriesIdx: number,
seriesIndex: number,
): string {
if (typeof stroke === 'function') {
return String(stroke(u, seriesIdx));
return String(stroke(u, seriesIndex));
}
if (typeof stroke === 'string') {
return stroke;
@@ -24,7 +24,7 @@ export function buildTooltipContent({
data,
series,
dataIndexes,
activeSeriesIdx,
activeSeriesIndex,
uPlotInstance,
yAxisUnit,
decimalPrecision,
@@ -32,7 +32,7 @@ export function buildTooltipContent({
data: AlignedData;
series: Series[];
dataIndexes: Array<number | null>;
activeSeriesIdx: number | null;
activeSeriesIndex: number | null;
uPlotInstance: uPlot;
yAxisUnit: string;
decimalPrecision?: PrecisionOption;
@@ -40,28 +40,28 @@ export function buildTooltipContent({
const active: TooltipContentItem[] = [];
const rest: TooltipContentItem[] = [];
for (let idx = 1; idx < series.length; idx += 1) {
const s = series[idx];
for (let index = 1; index < series.length; index += 1) {
const s = series[index];
if (!s?.show) {
continue;
}
const dataIdx = dataIndexes[idx];
const dataIndex = dataIndexes[index];
// Skip series with no data at the current cursor position
if (dataIdx === null) {
if (dataIndex === null) {
continue;
}
const raw = data[idx]?.[dataIdx];
const raw = data[index]?.[dataIndex];
const value = Number(raw);
const displayValue = Number.isNaN(value) ? 0 : value;
const isActive = idx === activeSeriesIdx;
const isActive = index === activeSeriesIndex;
const item: TooltipContentItem = {
label: String(s.label ?? ''),
value: displayValue,
tooltipValue: getToolTipValue(displayValue, yAxisUnit, decimalPrecision),
color: resolveSeriesColor(s.stroke, uPlotInstance, idx),
color: resolveSeriesColor(s.stroke, uPlotInstance, index),
isActive,
};

View File

@@ -40,8 +40,8 @@ export class UPlotScaleBuilder extends ConfigBuilder<
range,
thresholds,
logBase = 10,
padMinBy = 0.1,
padMaxBy = 0.1,
padMinBy = 0,
padMaxBy = 0.05,
} = this.props;
// Special handling for time scales (X axis)

View File

@@ -5,8 +5,8 @@ import uPlot, { Series } from 'uplot';
import {
ConfigBuilder,
DrawStyle,
FillStyle,
LineInterpolation,
LineStyle,
SeriesProps,
VisibilityMode,
} from './types';
@@ -16,23 +16,29 @@ import {
* Handles creation of series settings
*/
export class UPlotSeriesBuilder extends ConfigBuilder<SeriesProps, Series> {
private buildLineConfig(
lineColor: string,
lineWidth?: number,
lineStyle?: { fill?: FillStyle; dash?: number[] },
): Partial<Series> {
private buildLineConfig({
lineColor,
lineWidth,
lineStyle,
lineCap,
}: {
lineColor: string;
lineWidth?: number;
lineStyle?: LineStyle;
lineCap?: Series.Cap;
}): Partial<Series> {
const lineConfig: Partial<Series> = {
stroke: lineColor,
width: lineWidth ?? 2,
};
if (lineStyle && lineStyle.fill !== FillStyle.Solid) {
if (lineStyle.fill === FillStyle.Dot) {
lineConfig.cap = 'round';
}
lineConfig.dash = lineStyle.dash ?? [10, 10];
if (lineStyle === LineStyle.Dashed) {
lineConfig.dash = [10, 10];
}
if (lineCap) {
lineConfig.cap = lineCap;
}
return lineConfig;
}
@@ -138,6 +144,7 @@ export class UPlotSeriesBuilder extends ConfigBuilder<SeriesProps, Series> {
lineInterpolation,
lineWidth,
lineStyle,
lineCap,
showPoints,
pointSize,
scaleKey,
@@ -148,7 +155,12 @@ export class UPlotSeriesBuilder extends ConfigBuilder<SeriesProps, Series> {
const lineColor = this.getLineColor();
const lineConfig = this.buildLineConfig(lineColor, lineWidth, lineStyle);
const lineConfig = this.buildLineConfig({
lineColor,
lineWidth,
lineStyle,
lineCap,
});
const pathConfig = this.buildPathConfig({
pathBuilder,
drawStyle,

View File

@@ -92,16 +92,9 @@ export interface ScaleProps {
* Props for configuring a series
*/
export enum FillStyle {
export enum LineStyle {
Solid = 'solid',
Dash = 'dash',
Dot = 'dot',
Square = 'square',
}
export interface LineStyle {
dash?: Array<number>;
fill?: FillStyle;
Dashed = 'dashed',
}
export enum DrawStyle {
@@ -141,6 +134,7 @@ export interface SeriesProps {
lineInterpolation?: LineInterpolation;
lineStyle?: LineStyle;
lineWidth?: number;
lineCap?: Series.Cap;
// Points config
pointColor?: string;

View File

@@ -0,0 +1,13 @@
.tooltip-plugin-container {
top: 0;
left: 0;
z-index: 1070;
white-space: pre;
border-radius: 4px;
position: fixed;
overflow: auto;
&.pinned {
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.2);
}
}

View File

@@ -0,0 +1,359 @@
import { useLayoutEffect, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import cx from 'classnames';
import uPlot from 'uplot';
import {
createInitialControllerState,
createSetCursorHandler,
createSetLegendHandler,
createSetSeriesHandler,
isScrollEventInPlot,
updatePlotVisibility,
updateWindowSize,
} from './tooltipController';
import {
DashboardCursorSync,
TooltipControllerContext,
TooltipControllerState,
TooltipLayoutInfo,
TooltipPluginProps,
TooltipViewState,
} from './types';
import { createInitialViewState, createLayoutObserver } from './utils';
import './TooltipPlugin.styles.scss';
const INTERACTIVE_CONTAINER_CLASSNAME = '.tooltip-plugin-container';
// Delay before hiding an unpinned tooltip when the cursor briefly leaves
// the plot this avoids flicker when moving between nearby points.
const HOVER_DISMISS_DELAY_MS = 100;
// eslint-disable-next-line sonarjs/cognitive-complexity
export default function TooltipPlugin({
config,
render,
maxWidth = 300,
maxHeight = 400,
syncMode = DashboardCursorSync.None,
syncKey = '_tooltip_sync_global_',
canPinTooltip = false,
}: TooltipPluginProps): JSX.Element | null {
const containerRef = useRef<HTMLDivElement>(null);
const portalRoot = useRef<HTMLElement>(document.body);
const rafId = useRef<number | null>(null);
const layoutRef = useRef<TooltipLayoutInfo>();
const renderRef = useRef(render);
renderRef.current = render;
// React-managed snapshot of what should be rendered. The controller
// owns the interaction state and calls `updateState` when a visible
// change should trigger a React re-render.
const [viewState, setState] = useState<TooltipViewState>(
createInitialViewState,
);
const { plot, isHovering, isPinned, contents, style } = viewState;
/**
* Merge a partial view update into the current React state.
* Style is merged shallowly so callers can update transform /
* pointerEvents without having to rebuild the whole object.
*/
function updateState(updates: Partial<TooltipViewState>): void {
setState((prev) => ({
...prev,
...updates,
style: { ...prev.style, ...updates.style },
}));
}
useLayoutEffect((): (() => void) => {
layoutRef.current?.observer.disconnect();
layoutRef.current = createLayoutObserver(layoutRef);
// Controller holds the mutable interaction state for this tooltip
// instance. It is intentionally *not* React state so uPlot hooks
// and DOM listeners can update it freely without triggering a
// render on every mouse move.
const controller: TooltipControllerState = createInitialControllerState();
const syncTooltipWithDashboard = syncMode === DashboardCursorSync.Tooltip;
// Enable uPlot's built-in cursor sync when requested so that
// crosshair / tooltip can follow the dashboard-wide cursor.
if (syncMode !== DashboardCursorSync.None && config.scales[0]?.props.time) {
config.setCursor({
sync: { key: syncKey, scales: ['x', null] },
});
}
// Dismiss the tooltip when the user clicks / presses a key
// outside the tooltip container while it is pinned.
const onOutsideInteraction = (event: Event): void => {
const target = event.target as HTMLElement;
if (!target.closest(INTERACTIVE_CONTAINER_CLASSNAME)) {
dismissTooltip();
}
};
// When pinned we want the tooltip to be mouse-interactive
// (for copying values etc.), otherwise it should ignore
// pointer events so the chart remains fully clickable.
function updatePointerEvents(): void {
controller.style = {
...controller.style,
pointerEvents: controller.pinned ? 'all' : 'none',
};
}
// Lock uPlot's internal cursor when the tooltip is pinned so
// subsequent mouse moves do not move the crosshair.
function updateCursorLock(): void {
if (controller.plot) {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore uPlot cursor lock is not working as expected
controller.plot.cursor._lock = controller.pinned;
}
}
// Attach / detach global listeners when pin state changes so
// we can detect when the user interacts outside the tooltip.
function toggleOutsideListeners(enable: boolean): void {
if (enable) {
document.addEventListener('mousedown', onOutsideInteraction, true);
document.addEventListener('keydown', onOutsideInteraction, true);
} else {
document.removeEventListener('mousedown', onOutsideInteraction, true);
document.removeEventListener('keydown', onOutsideInteraction, true);
}
}
// Centralised helper that applies all side effects that depend
// on whether the tooltip is currently pinned.
function applyPinnedSideEffects(): void {
updatePointerEvents();
updateCursorLock();
toggleOutsideListeners(controller.pinned);
}
// Hide the tooltip and reset the uPlot cursor. This is used
// both when the user unpins and when interaction ends.
function dismissTooltip(): void {
const isPinnedBeforeDismiss = controller.pinned;
controller.pinned = false;
controller.hoverActive = false;
if (controller.plot) {
controller.plot.setCursor({ left: -10, top: -10 });
}
scheduleRender(isPinnedBeforeDismiss);
}
// Build the React node to be rendered inside the tooltip by
// delegating to the caller-provided `render` function.
function createTooltipContents(): React.ReactNode {
if (!controller.hoverActive || !controller.plot) {
return null;
}
return renderRef.current({
uPlotInstance: controller.plot,
dataIndexes: controller.seriesIndexes,
seriesIndex: controller.focusedSeriesIndex,
isPinned: controller.pinned,
dismiss: dismissTooltip,
viaSync: controller.cursorDrivenBySync,
});
}
// Push the latest controller state into React so the tooltip's
// DOM representation catches up with the interaction state.
function performRender(): void {
controller.renderScheduled = false;
rafId.current = null;
if (controller.pendingPinnedUpdate) {
applyPinnedSideEffects();
controller.pendingPinnedUpdate = false;
}
updateState({
style: controller.style,
isPinned: controller.pinned,
isHovering: controller.hoverActive,
contents: createTooltipContents(),
dismiss: dismissTooltip,
});
}
// Throttle React re-renders:
// - use rAF while hovering for smooth updates
// - use a small timeout when hiding to avoid flicker when
// briefly leaving and re-entering the plot.
function scheduleRender(updatePinned = false): void {
if (!controller.renderScheduled) {
if (!controller.hoverActive) {
setTimeout(performRender, HOVER_DISMISS_DELAY_MS);
} else {
if (rafId.current != null) {
cancelAnimationFrame(rafId.current);
}
rafId.current = requestAnimationFrame(performRender);
}
controller.renderScheduled = true;
}
if (updatePinned) {
controller.pendingPinnedUpdate = true;
}
}
// Keep controller's windowWidth / windowHeight in sync so that
// tooltip positioning can respect the current viewport size.
const handleWindowResize = (): void => {
updateWindowSize(controller);
};
// When the user scrolls, recompute plot visibility and hide
// the tooltip if the scroll originated from inside the plot.
const handleScroll = (event: Event): void => {
updatePlotVisibility(controller);
if (controller.hoverActive && isScrollEventInPlot(event, controller)) {
dismissTooltip();
}
};
// When pinning is enabled, a click on the plot overlay while
// hovering converts the transient tooltip into a pinned one.
const handleUPlotOverClick = (u: uPlot, event: MouseEvent): void => {
if (
event.target === u.over &&
controller.hoverActive &&
!controller.pinned &&
controller.focusedSeriesIndex != null
) {
setTimeout(() => {
controller.pinned = true;
scheduleRender(true);
}, 0);
}
};
let overClickHandler: ((event: MouseEvent) => void) | null = null;
// Called once per uPlot instance; used to store the instance
// on the controller and optionally attach the pinning handler.
const handleInit = (u: uPlot): void => {
controller.plot = u;
updateState({ plot: u });
if (canPinTooltip) {
overClickHandler = (event: MouseEvent): void =>
handleUPlotOverClick(u, event);
u.over.addEventListener('click', overClickHandler);
}
};
// If the underlying data changes we drop any pinned tooltip,
// since the contents may no longer match the new series data.
const handleSetData = (): void => {
if (controller.pinned) {
dismissTooltip();
}
};
// Shared context object passed down into all uPlot hook
// handlers so they can interact with the controller and
// schedule React updates when needed.
const ctx: TooltipControllerContext = {
controller,
layoutRef,
containerRef,
rafId,
updateState,
renderRef,
syncMode,
syncKey,
canPinTooltip,
createTooltipContents,
scheduleRender,
dismissTooltip,
};
const handleSetSeries = createSetSeriesHandler(ctx, syncTooltipWithDashboard);
const handleSetLegend = createSetLegendHandler(ctx, syncTooltipWithDashboard);
const handleSetCursor = createSetCursorHandler(ctx);
handleWindowResize();
const removeReadyHook = config.addHook('ready', (): void =>
updatePlotVisibility(controller),
);
const removeInitHook = config.addHook('init', handleInit);
const removeSetDataHook = config.addHook('setData', handleSetData);
const removeSetSeriesHook = config.addHook('setSeries', handleSetSeries);
const removeSetLegendHook = config.addHook('setLegend', handleSetLegend);
const removeSetCursorHook = config.addHook('setCursor', handleSetCursor);
window.addEventListener('resize', handleWindowResize);
window.addEventListener('scroll', handleScroll, true);
return (): void => {
layoutRef.current?.observer.disconnect();
window.removeEventListener('resize', handleWindowResize);
window.removeEventListener('scroll', handleScroll, true);
document.removeEventListener('mousedown', onOutsideInteraction, true);
document.removeEventListener('keydown', onOutsideInteraction, true);
if (rafId.current != null) {
cancelAnimationFrame(rafId.current);
rafId.current = null;
}
removeReadyHook();
removeInitHook();
removeSetDataHook();
removeSetSeriesHook();
removeSetLegendHook();
removeSetCursorHook();
if (controller.plot && overClickHandler) {
controller.plot.over.removeEventListener('click', overClickHandler);
overClickHandler = null;
}
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [config]);
useLayoutEffect((): void => {
if (!plot || !layoutRef.current) {
return;
}
const layout = layoutRef.current;
if (containerRef.current) {
layout.observer.disconnect();
layout.observer.observe(containerRef.current);
const { width, height } = containerRef.current.getBoundingClientRect();
layout.width = width;
layout.height = height;
} else {
layout.width = 0;
layout.height = 0;
}
}, [isHovering, plot]);
if (!plot || !isHovering) {
return null;
}
return createPortal(
<div
className={cx('tooltip-plugin-container', { pinned: isPinned })}
style={{
...style,
maxWidth: `${maxWidth}px`,
maxHeight: `${maxHeight}px`,
width: '100%',
}}
aria-live="polite"
aria-atomic="true"
ref={containerRef}
>
{contents}
</div>,
portalRoot.current,
);
}

View File

@@ -0,0 +1,206 @@
import uPlot from 'uplot';
import { TooltipControllerContext, TooltipControllerState } from './types';
import {
buildTransform,
calculateTooltipOffset,
isPlotInViewport,
} from './utils';
const WINDOW_OFFSET = 16;
export function createInitialControllerState(): TooltipControllerState {
return {
plot: null,
hoverActive: false,
anySeriesActive: false,
pinned: false,
style: { transform: '', pointerEvents: 'none' },
horizontalOffset: 0,
verticalOffset: 0,
seriesIndexes: [],
focusedSeriesIndex: null,
cursorDrivenBySync: false,
plotWithinViewport: false,
windowWidth: window.innerWidth - WINDOW_OFFSET,
windowHeight: window.innerHeight - WINDOW_OFFSET,
renderScheduled: false,
pendingPinnedUpdate: false,
};
}
/**
* Keep track of the current window size and clear hover state
* when the user resizes while hovering (to avoid an orphan tooltip).
*/
export function updateWindowSize(controller: TooltipControllerState): void {
if (controller.hoverActive && !controller.pinned) {
controller.hoverActive = false;
}
controller.windowWidth = window.innerWidth - WINDOW_OFFSET;
controller.windowHeight = window.innerHeight - WINDOW_OFFSET;
}
/**
* Mark whether the plot is currently inside the viewport.
* This is used to decide if a synced tooltip should be shown at all.
*/
export function updatePlotVisibility(controller: TooltipControllerState): void {
if (!controller.plot) {
controller.plotWithinViewport = false;
return;
}
controller.plotWithinViewport = isPlotInViewport(
controller.plot.rect,
controller.windowWidth,
controller.windowHeight,
);
}
/**
* Helper to detect whether a scroll event actually happened inside
* the plot container. Used so we only dismiss the tooltip when the
* user scrolls the chart, not the whole page.
*/
export function isScrollEventInPlot(
event: Event,
controller: TooltipControllerState,
): boolean {
return (
event.target instanceof Node &&
controller.plot !== null &&
event.target.contains(controller.plot.root)
);
}
export function shouldShowTooltipForSync(
controller: TooltipControllerState,
syncTooltipWithDashboard: boolean,
): boolean {
return (
controller.plotWithinViewport &&
controller.anySeriesActive &&
syncTooltipWithDashboard
);
}
export function shouldShowTooltipForInteraction(
controller: TooltipControllerState,
): boolean {
return controller.focusedSeriesIndex != null || controller.anySeriesActive;
}
export function updateHoverState(
controller: TooltipControllerState,
syncTooltipWithDashboard: boolean,
): void {
// When the cursor is driven by dashboardlevel sync, we only show
// the tooltip if the plot is in viewport and at least one series
// is active. Otherwise we fall back to local interaction logic.
controller.hoverActive = controller.cursorDrivenBySync
? shouldShowTooltipForSync(controller, syncTooltipWithDashboard)
: shouldShowTooltipForInteraction(controller);
}
export function createSetCursorHandler(
ctx: TooltipControllerContext,
): (u: uPlot) => void {
return (u: uPlot): void => {
const { controller, layoutRef, containerRef } = ctx;
controller.cursorDrivenBySync = u.cursor.event == null;
if (!controller.hoverActive) {
return;
}
const { left = -10, top = -10 } = u.cursor;
if (left < 0 && top < 0) {
return;
}
const clientX = u.rect.left + left;
const clientY = u.rect.top + top;
const layout = layoutRef.current;
if (!layout) {
return;
}
const { width: layoutWidth, height: layoutHeight } = layout;
const offsets = calculateTooltipOffset(
clientX,
clientY,
layoutWidth,
layoutHeight,
controller.horizontalOffset,
controller.verticalOffset,
controller.windowWidth,
controller.windowHeight,
);
controller.horizontalOffset = offsets.horizontalOffset;
controller.verticalOffset = offsets.verticalOffset;
const transform = buildTransform(
clientX,
clientY,
controller.horizontalOffset,
controller.verticalOffset,
);
// If the DOM node is mounted we move it directly to avoid
// going through React; otherwise we cache the transform in
// controller.style and ask the plugin to rerender.
if (containerRef.current) {
containerRef.current.style.transform = transform;
} else {
controller.style = { ...controller.style, transform };
ctx.scheduleRender();
}
};
}
export function createSetLegendHandler(
ctx: TooltipControllerContext,
syncTooltipWithDashboard: boolean,
): (u: uPlot) => void {
return (u: uPlot): void => {
const { controller } = ctx;
if (!controller.plot?.cursor?.idxs) {
return;
}
controller.seriesIndexes = controller.plot.cursor.idxs.slice();
controller.anySeriesActive = controller.seriesIndexes.some(
(v, i) => i > 0 && v != null,
);
controller.cursorDrivenBySync = u.cursor.event == null;
// Track transitions into / out of hover so we can avoid
// unnecessary renders when nothing visible has changed.
const previousHover = controller.hoverActive;
updateHoverState(controller, syncTooltipWithDashboard);
if (controller.hoverActive || controller.hoverActive !== previousHover) {
ctx.scheduleRender();
}
};
}
export function createSetSeriesHandler(
ctx: TooltipControllerContext,
syncTooltipWithDashboard: boolean,
): (u: uPlot, seriesIdx: number | null, opts: uPlot.Series) => void {
return (u: uPlot, seriesIdx: number | null, opts: uPlot.Series): void => {
const { controller } = ctx;
if (!('focus' in opts)) {
return;
}
// Remember which series is focused so we can drive hover
// logic even when the tooltip is being synced externally.
controller.focusedSeriesIndex = seriesIdx ?? null;
controller.cursorDrivenBySync = u.cursor.event == null;
updateHoverState(controller, syncTooltipWithDashboard);
ctx.scheduleRender();
};
}

View File

@@ -0,0 +1,92 @@
import { CSSProperties } from 'react';
import { TooltipRenderArgs } from '../../components/types';
import { UPlotConfigBuilder } from '../../config/UPlotConfigBuilder';
export const TOOLTIP_OFFSET = 10;
export enum DashboardCursorSync {
Crosshair,
None,
Tooltip,
}
export interface TooltipViewState {
plot?: uPlot | null;
style: Partial<CSSProperties>;
isHovering: boolean;
isPinned: boolean;
dismiss: () => void;
contents?: React.ReactNode;
}
export interface TooltipLayoutInfo {
observer: ResizeObserver;
width: number;
height: number;
}
export interface TooltipPluginProps {
config: UPlotConfigBuilder;
canPinTooltip?: boolean;
syncMode?: DashboardCursorSync;
syncKey?: string;
render: (args: TooltipRenderArgs) => React.ReactNode;
maxWidth?: number;
maxHeight?: number;
}
/**
* Mutable, non-React state that drives tooltip behaviour:
* - whether the tooltip is active / pinned
* - where it should be positioned
* - which series / data indexes are active
*
* This state lives outside of React so that uPlot hooks and DOM
* event handlers can update it freely without causing rerenders
* on every tiny interaction. React is only updated when a render
* is explicitly scheduled from the plugin.
*/
export interface TooltipControllerState {
plot: uPlot | null;
hoverActive: boolean;
anySeriesActive: boolean;
pinned: boolean;
style: TooltipViewState['style'];
horizontalOffset: number;
verticalOffset: number;
seriesIndexes: Array<number | null>;
focusedSeriesIndex: number | null;
cursorDrivenBySync: boolean;
plotWithinViewport: boolean;
windowWidth: number;
windowHeight: number;
renderScheduled: boolean;
pendingPinnedUpdate: boolean;
}
/**
* Context passed to uPlot hook handlers.
*
* It gives the handlers access to:
* - the shared controller state
* - layout / container refs
* - the React `updateState` function
* - render & dismiss helpers from the plugin
*/
export interface TooltipControllerContext {
controller: TooltipControllerState;
layoutRef: React.MutableRefObject<TooltipLayoutInfo | undefined>;
containerRef: React.RefObject<HTMLDivElement | null>;
rafId: React.MutableRefObject<number | null>;
updateState: (updates: Partial<TooltipViewState>) => void;
renderRef: React.MutableRefObject<
(args: TooltipRenderArgs) => React.ReactNode
>;
syncMode: DashboardCursorSync;
syncKey: string;
canPinTooltip: boolean;
createTooltipContents: () => React.ReactNode;
scheduleRender: (updatePinned?: boolean) => void;
dismissTooltip: () => void;
}

View File

@@ -0,0 +1,159 @@
import { TOOLTIP_OFFSET, TooltipLayoutInfo, TooltipViewState } from './types';
export function isPlotInViewport(
rect: uPlot.BBox,
windowWidth: number,
windowHeight: number,
): boolean {
return (
rect.top + rect.height <= windowHeight &&
rect.top >= 0 &&
rect.left >= 0 &&
rect.left + rect.width <= windowWidth
);
}
export function calculateVerticalOffset(
currentOffset: number,
clientY: number,
tooltipHeight: number,
windowHeight: number,
): number {
const height = tooltipHeight + TOOLTIP_OFFSET;
if (currentOffset !== 0) {
if (clientY + height < windowHeight || clientY - height < 0) {
return 0;
}
if (currentOffset !== -height) {
return -height;
}
return currentOffset;
}
if (clientY + height > windowHeight && clientY - height >= 0) {
return -height;
}
return 0;
}
export function calculateHorizontalOffset(
currentOffset: number,
clientX: number,
tooltipWidth: number,
windowWidth: number,
): number {
const width = tooltipWidth + TOOLTIP_OFFSET;
if (currentOffset !== 0) {
if (clientX + width < windowWidth || clientX - width < 0) {
return 0;
}
if (currentOffset !== -width) {
return -width;
}
return currentOffset;
}
if (clientX + width > windowWidth && clientX - width >= 0) {
return -width;
}
return 0;
}
export function calculateTooltipOffset(
clientX: number,
clientY: number,
tooltipWidth: number,
tooltipHeight: number,
currentHorizontalOffset: number,
currentVerticalOffset: number,
windowWidth: number,
windowHeight: number,
): { horizontalOffset: number; verticalOffset: number } {
return {
horizontalOffset: calculateHorizontalOffset(
currentHorizontalOffset,
clientX,
tooltipWidth,
windowWidth,
),
verticalOffset: calculateVerticalOffset(
currentVerticalOffset,
clientY,
tooltipHeight,
windowHeight,
),
};
}
export function buildTransform(
clientX: number,
clientY: number,
hOffset: number,
vOffset: number,
): string {
const translateX =
clientX + (hOffset === 0 ? TOOLTIP_OFFSET : -TOOLTIP_OFFSET);
const translateY =
clientY + (vOffset === 0 ? TOOLTIP_OFFSET : -TOOLTIP_OFFSET);
const reflectX = hOffset === 0 ? '' : 'translateX(-100%)';
const reflectY = vOffset === 0 ? '' : 'translateY(-100%)';
return `translateX(${translateX}px) ${reflectX} translateY(${translateY}px) ${reflectY}`;
}
/**
* React view state for the tooltip.
*
* This is the minimal data needed to render:
* - current position / CSS style
* - whether the tooltip is visible or pinned
* - the React node to show as contents
* - the associated uPlot instance (for children)
*
* All interaction logic lives in the controller; that logic calls
* `updateState` to push the latest snapshot into React.
*/
export function createInitialViewState(): TooltipViewState {
return {
style: { transform: '', pointerEvents: 'none' },
isHovering: false,
isPinned: false,
contents: null,
plot: null,
dismiss: (): void => {},
};
}
/**
* Creates and wires a ResizeObserver that keeps track of the rendered
* tooltip size. This is used by the controller to place the tooltip
* on the correct side of the cursor and avoid clipping the viewport.
*/
export function createLayoutObserver(
layoutRef: React.MutableRefObject<TooltipLayoutInfo | undefined>,
): TooltipLayoutInfo {
const layout: TooltipLayoutInfo = {
width: 0,
height: 0,
observer: new ResizeObserver((entries) => {
const current = layoutRef.current;
if (!current) {
return;
}
for (const entry of entries) {
if (entry.borderBoxSize?.length) {
current.width = entry.borderBoxSize[0].inlineSize;
current.height = entry.borderBoxSize[0].blockSize;
} else {
current.width = entry.contentRect.width;
current.height = entry.contentRect.height;
}
}
}),
};
return layout;
}

View File

@@ -33,6 +33,19 @@
height: calc(100vh - 48px);
border-right: 1px solid var(--Slate-500, #161922);
background: var(--Ink-500, #0b0c0e);
margin-top: 4px;
.nav-item {
.nav-item-data {
margin: 0px 8px 0px 4px;
}
&.active {
.nav-item-data .nav-item-label {
color: var(--bg-vanilla-100, #fff);
}
}
}
}
.settings-page-content {
@@ -81,6 +94,14 @@
.settings-page-sidenav {
border-right: 1px solid var(--bg-vanilla-300);
background: var(--bg-vanilla-100);
.nav-item {
&.active {
.nav-item-data .nav-item-label {
color: var(--bg-ink-500);
}
}
}
}
.settings-page-content {

View File

@@ -13,7 +13,7 @@ import { SidebarItem } from 'container/SideNav/sideNav.types';
import useComponentPermission from 'hooks/useComponentPermission';
import { useGetTenantLicense } from 'hooks/useGetTenantLicense';
import history from 'lib/history';
import { Wrench } from 'lucide-react';
import { Cog } from 'lucide-react';
import { useAppContext } from 'providers/App/App';
import { USER_ROLES } from 'types/roles';
@@ -236,7 +236,7 @@ function SettingsPage(): JSX.Element {
className="settings-page-header-title"
data-testid="settings-page-title"
>
<Wrench size={16} />
<Cog size={16} />
Settings
</div>
</header>

View File

@@ -46,7 +46,7 @@ import { GlobalReducer } from 'types/reducer/globalTime';
import { v4 as generateUUID } from 'uuid';
import { useDashboardVariables } from '../../hooks/dashboard/useDashboardVariables';
import { updateDashboardVariablesStore } from './store/dashboardVariablesStore';
import { setDashboardVariablesStore } from './store/dashboardVariablesStore';
import {
DashboardSortOrder,
IDashboardContext,
@@ -205,7 +205,7 @@ export function DashboardProvider({
const updatedVariables = selectedDashboard?.data.variables || {};
if (!isEqual(existingVariables, updatedVariables)) {
updateDashboardVariablesStore(updatedVariables);
setDashboardVariablesStore(updatedVariables);
}
}, [selectedDashboard]);

View File

@@ -7,11 +7,8 @@ export type IDashboardVariables = Record<string, IDashboardVariable>;
export const dashboardVariablesStore = createStore<IDashboardVariables>({});
export function updateDashboardVariablesStore(
export function setDashboardVariablesStore(
variables: Partial<IDashboardVariables>,
): void {
dashboardVariablesStore.update((currentVariables) => ({
...currentVariables,
...variables,
}));
dashboardVariablesStore.set(() => ({ ...variables }));
}

2
go.mod
View File

@@ -231,7 +231,7 @@ require (
github.com/natefinch/wrap v0.2.0 // indirect
github.com/oklog/run v1.1.0 // indirect
github.com/oklog/ulid v1.3.1 // indirect
github.com/oklog/ulid/v2 v2.1.1 // indirect
github.com/oklog/ulid/v2 v2.1.1
github.com/open-feature/go-sdk v1.17.0
github.com/open-telemetry/opentelemetry-collector-contrib/internal/coreinternal v0.128.0 // indirect
github.com/open-telemetry/opentelemetry-collector-contrib/internal/exp/metrics v0.128.0 // indirect

View File

@@ -152,7 +152,7 @@ func (provider *provider) BatchCheck(ctx context.Context, tupleReq []*openfgav1.
}
}
return errors.Newf(errors.TypeForbidden, authtypes.ErrCodeAuthZForbidden, "none of the subjects are allowed for requested access")
return errors.Newf(errors.TypeForbidden, authtypes.ErrCodeAuthZForbidden, "subjects are not authorized for requested access")
}

View File

@@ -5,10 +5,13 @@ import (
"net/http"
"github.com/SigNoz/signoz/pkg/authz"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/http/render"
"github.com/SigNoz/signoz/pkg/modules/organization"
"github.com/SigNoz/signoz/pkg/modules/role"
"github.com/SigNoz/signoz/pkg/types/authtypes"
"github.com/SigNoz/signoz/pkg/types/ctxtypes"
"github.com/SigNoz/signoz/pkg/types/roletypes"
"github.com/SigNoz/signoz/pkg/valuer"
"github.com/gorilla/mux"
)
@@ -34,14 +37,48 @@ func NewAuthZ(logger *slog.Logger, orgGetter organization.Getter, authzService a
func (middleware *AuthZ) ViewAccess(next http.HandlerFunc) http.HandlerFunc {
return http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) {
claims, err := authtypes.ClaimsFromContext(req.Context())
ctx := req.Context()
claims, err := authtypes.ClaimsFromContext(ctx)
if err != nil {
render.Error(rw, err)
return
}
if err := claims.IsViewer(); err != nil {
middleware.logger.WarnContext(req.Context(), authzDeniedMessage, "claims", claims)
commentCtx := ctxtypes.CommentFromContext(ctx)
authtype, ok := commentCtx.Map()["auth_type"]
if ok && authtype == ctxtypes.AuthTypeAPIKey.StringValue() {
if err := claims.IsViewer(); err != nil {
middleware.logger.WarnContext(ctx, authzDeniedMessage, "claims", claims)
render.Error(rw, err)
return
}
next(rw, req)
return
}
selectors := []authtypes.Selector{
authtypes.MustNewSelector(authtypes.TypeRole, roletypes.SigNozAdminRoleName),
authtypes.MustNewSelector(authtypes.TypeRole, roletypes.SigNozEditorRoleName),
authtypes.MustNewSelector(authtypes.TypeRole, roletypes.SigNozViewerRoleName),
}
err = middleware.authzService.CheckWithTupleCreation(
ctx,
claims,
valuer.MustNewUUID(claims.OrgID),
authtypes.RelationAssignee,
authtypes.TypeableRole,
selectors,
selectors,
)
if err != nil {
middleware.logger.WarnContext(ctx, authzDeniedMessage, "claims", claims)
if errors.Asc(err, authtypes.ErrCodeAuthZForbidden) {
render.Error(rw, errors.New(errors.TypeForbidden, authtypes.ErrCodeAuthZForbidden, "only viewers/editors/admins can access this resource"))
return
}
render.Error(rw, err)
return
}
@@ -52,14 +89,47 @@ func (middleware *AuthZ) ViewAccess(next http.HandlerFunc) http.HandlerFunc {
func (middleware *AuthZ) EditAccess(next http.HandlerFunc) http.HandlerFunc {
return http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) {
claims, err := authtypes.ClaimsFromContext(req.Context())
ctx := req.Context()
claims, err := authtypes.ClaimsFromContext(ctx)
if err != nil {
render.Error(rw, err)
return
}
if err := claims.IsEditor(); err != nil {
middleware.logger.WarnContext(req.Context(), authzDeniedMessage, "claims", claims)
commentCtx := ctxtypes.CommentFromContext(ctx)
authtype, ok := commentCtx.Map()["auth_type"]
if ok && authtype == ctxtypes.AuthTypeAPIKey.StringValue() {
if err := claims.IsEditor(); err != nil {
middleware.logger.WarnContext(ctx, authzDeniedMessage, "claims", claims)
render.Error(rw, err)
return
}
next(rw, req)
return
}
selectors := []authtypes.Selector{
authtypes.MustNewSelector(authtypes.TypeRole, roletypes.SigNozAdminRoleName),
authtypes.MustNewSelector(authtypes.TypeRole, roletypes.SigNozEditorRoleName),
}
err = middleware.authzService.CheckWithTupleCreation(
ctx,
claims,
valuer.MustNewUUID(claims.OrgID),
authtypes.RelationAssignee,
authtypes.TypeableRole,
selectors,
selectors,
)
if err != nil {
middleware.logger.WarnContext(ctx, authzDeniedMessage, "claims", claims)
if errors.Asc(err, authtypes.ErrCodeAuthZForbidden) {
render.Error(rw, errors.New(errors.TypeForbidden, authtypes.ErrCodeAuthZForbidden, "only editors/admins can access this resource"))
return
}
render.Error(rw, err)
return
}
@@ -70,14 +140,46 @@ func (middleware *AuthZ) EditAccess(next http.HandlerFunc) http.HandlerFunc {
func (middleware *AuthZ) AdminAccess(next http.HandlerFunc) http.HandlerFunc {
return http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) {
claims, err := authtypes.ClaimsFromContext(req.Context())
ctx := req.Context()
claims, err := authtypes.ClaimsFromContext(ctx)
if err != nil {
render.Error(rw, err)
return
}
if err := claims.IsAdmin(); err != nil {
middleware.logger.WarnContext(req.Context(), authzDeniedMessage, "claims", claims)
commentCtx := ctxtypes.CommentFromContext(ctx)
authtype, ok := commentCtx.Map()["auth_type"]
if ok && authtype == ctxtypes.AuthTypeAPIKey.StringValue() {
if err := claims.IsAdmin(); err != nil {
middleware.logger.WarnContext(ctx, authzDeniedMessage, "claims", claims)
render.Error(rw, err)
return
}
next(rw, req)
return
}
selectors := []authtypes.Selector{
authtypes.MustNewSelector(authtypes.TypeRole, roletypes.SigNozAdminRoleName),
}
err = middleware.authzService.CheckWithTupleCreation(
ctx,
claims,
valuer.MustNewUUID(claims.OrgID),
authtypes.RelationAssignee,
authtypes.TypeableRole,
selectors,
selectors,
)
if err != nil {
middleware.logger.WarnContext(ctx, authzDeniedMessage, "claims", claims)
if errors.Asc(err, authtypes.ErrCodeAuthZForbidden) {
render.Error(rw, errors.New(errors.TypeForbidden, authtypes.ErrCodeAuthZForbidden, "only admins can access this resource"))
return
}
render.Error(rw, err)
return
}
@@ -120,30 +222,18 @@ func (middleware *AuthZ) Check(next http.HandlerFunc, relation authtypes.Relatio
return
}
orgId, err := valuer.NewUUID(claims.OrgID)
if err != nil {
render.Error(rw, err)
return
}
selectors, err := cb(req, claims)
if err != nil {
render.Error(rw, err)
return
}
roles, err := middleware.roleGetter.ListByOrgIDAndNames(req.Context(), orgId, roles)
if err != nil {
render.Error(rw, err)
return
}
roleSelectors := []authtypes.Selector{}
for _, role := range roles {
selectors = append(selectors, authtypes.MustNewSelector(authtypes.TypeRole, role.ID.String()))
roleSelectors = append(roleSelectors, authtypes.MustNewSelector(authtypes.TypeRole, role))
}
err = middleware.authzService.CheckWithTupleCreation(ctx, claims, orgId, relation, typeable, selectors, roleSelectors)
err = middleware.authzService.CheckWithTupleCreation(ctx, claims, valuer.MustNewUUID(claims.OrgID), relation, typeable, selectors, roleSelectors)
if err != nil {
render.Error(rw, err)
return
@@ -162,13 +252,18 @@ func (middleware *AuthZ) CheckWithoutClaims(next http.HandlerFunc, relation auth
return
}
selectors, orgID, err := cb(req, orgs)
selectors, orgId, err := cb(req, orgs)
if err != nil {
render.Error(rw, err)
return
}
err = middleware.authzService.CheckWithTupleCreationWithoutClaims(ctx, orgID, relation, typeable, selectors, selectors)
roleSelectors := []authtypes.Selector{}
for _, role := range roles {
roleSelectors = append(roleSelectors, authtypes.MustNewSelector(authtypes.TypeRole, role))
}
err = middleware.authzService.CheckWithTupleCreationWithoutClaims(ctx, orgId, relation, typeable, selectors, roleSelectors)
if err != nil {
render.Error(rw, err)
return

View File

@@ -7,6 +7,7 @@ import (
"github.com/SigNoz/signoz/pkg/modules/organization"
"github.com/SigNoz/signoz/pkg/modules/quickfilter"
"github.com/SigNoz/signoz/pkg/types"
"github.com/SigNoz/signoz/pkg/valuer"
)
type setter struct {
@@ -19,7 +20,7 @@ func NewSetter(store types.OrganizationStore, alertmanager alertmanager.Alertman
return &setter{store: store, alertmanager: alertmanager, quickfilter: quickfilter}
}
func (module *setter) Create(ctx context.Context, organization *types.Organization) error {
func (module *setter) Create(ctx context.Context, organization *types.Organization, createManagedRoles func(context.Context, valuer.UUID) error) error {
if err := module.store.Create(ctx, organization); err != nil {
return err
}
@@ -32,6 +33,10 @@ func (module *setter) Create(ctx context.Context, organization *types.Organizati
return err
}
if err := createManagedRoles(ctx, organization.ID); err != nil {
return err
}
return nil
}

View File

@@ -18,7 +18,7 @@ type Getter interface {
type Setter interface {
// Create creates the given organization
Create(context.Context, *types.Organization) error
Create(context.Context, *types.Organization, func(context.Context, valuer.UUID) error) error
// Update updates the given organization
Update(context.Context, *types.Organization) error

View File

@@ -20,31 +20,11 @@ func NewGranter(store roletypes.Store, authz authz.AuthZ) role.Granter {
}
func (granter *granter) Grant(ctx context.Context, orgID valuer.UUID, name string, subject string) error {
role, err := granter.store.GetByOrgIDAndName(ctx, orgID, name)
if err != nil {
return err
}
tuples, err := authtypes.TypeableRole.Tuples(
subject,
authtypes.RelationAssignee,
[]authtypes.Selector{
authtypes.MustNewSelector(authtypes.TypeRole, role.ID.StringValue()),
},
orgID,
)
if err != nil {
return err
}
return granter.authz.Write(ctx, tuples, nil)
}
func (granter *granter) GrantByID(ctx context.Context, orgID valuer.UUID, id valuer.UUID, subject string) error {
tuples, err := authtypes.TypeableRole.Tuples(
subject,
authtypes.RelationAssignee,
[]authtypes.Selector{
authtypes.MustNewSelector(authtypes.TypeRole, id.StringValue()),
authtypes.MustNewSelector(authtypes.TypeRole, name),
},
orgID,
)
@@ -69,16 +49,11 @@ func (granter *granter) ModifyGrant(ctx context.Context, orgID valuer.UUID, exis
}
func (granter *granter) Revoke(ctx context.Context, orgID valuer.UUID, name string, subject string) error {
role, err := granter.store.GetByOrgIDAndName(ctx, orgID, name)
if err != nil {
return err
}
tuples, err := authtypes.TypeableRole.Tuples(
subject,
authtypes.RelationAssignee,
[]authtypes.Selector{
authtypes.MustNewSelector(authtypes.TypeRole, role.ID.StringValue()),
authtypes.MustNewSelector(authtypes.TypeRole, name),
},
orgID,
)

View File

@@ -169,7 +169,7 @@ func (handler *handler) Patch(rw http.ResponseWriter, r *http.Request) {
return
}
err = role.PatchMetadata(req.Name, req.Description)
err = role.PatchMetadata(req.Description)
if err != nil {
render.Error(rw, err)
return
@@ -222,7 +222,7 @@ func (handler *handler) PatchObjects(rw http.ResponseWriter, r *http.Request) {
return
}
err = handler.setter.PatchObjects(ctx, valuer.MustNewUUID(claims.OrgID), id, relation, patchableObjects.Additions, patchableObjects.Deletions)
err = handler.setter.PatchObjects(ctx, valuer.MustNewUUID(claims.OrgID), role.Name, relation, patchableObjects.Additions, patchableObjects.Deletions)
if err != nil {
render.Error(rw, err)
return

View File

@@ -40,7 +40,7 @@ func (setter *setter) Patch(_ context.Context, _ valuer.UUID, _ *roletypes.Role)
return errors.Newf(errors.TypeUnsupported, roletypes.ErrCodeRoleUnsupported, "not implemented")
}
func (setter *setter) PatchObjects(_ context.Context, _ valuer.UUID, _ valuer.UUID, _ authtypes.Relation, _, _ []*authtypes.Object) error {
func (setter *setter) PatchObjects(_ context.Context, _ valuer.UUID, _ string, _ authtypes.Relation, _, _ []*authtypes.Object) error {
return errors.Newf(errors.TypeUnsupported, roletypes.ErrCodeRoleUnsupported, "not implemented")
}

View File

@@ -26,7 +26,7 @@ type Setter interface {
Patch(context.Context, valuer.UUID, *roletypes.Role) error
// Patches the objects in authorization server associated with the given role and relation
PatchObjects(context.Context, valuer.UUID, valuer.UUID, authtypes.Relation, []*authtypes.Object, []*authtypes.Object) error
PatchObjects(context.Context, valuer.UUID, string, authtypes.Relation, []*authtypes.Object, []*authtypes.Object) error
// Deletes the role and tuples in authorization server.
Delete(context.Context, valuer.UUID, valuer.UUID) error
@@ -52,9 +52,6 @@ type Granter interface {
// Grants a role to the subject based on role name.
Grant(context.Context, valuer.UUID, string, string) error
// Grants a role to the subject based on role id.
GrantByID(context.Context, valuer.UUID, valuer.UUID, string) error
// Revokes a granted role from the subject based on role name.
Revoke(context.Context, valuer.UUID, string, string) error

View File

@@ -17,7 +17,9 @@ import (
root "github.com/SigNoz/signoz/pkg/modules/user"
"github.com/SigNoz/signoz/pkg/tokenizer"
"github.com/SigNoz/signoz/pkg/types"
"github.com/SigNoz/signoz/pkg/types/authtypes"
"github.com/SigNoz/signoz/pkg/types/emailtypes"
"github.com/SigNoz/signoz/pkg/types/roletypes"
"github.com/SigNoz/signoz/pkg/valuer"
"github.com/dustin/go-humanize"
"golang.org/x/text/cases"
@@ -169,6 +171,12 @@ func (m *Module) DeleteInvite(ctx context.Context, orgID string, id valuer.UUID)
func (module *Module) CreateUser(ctx context.Context, input *types.User, opts ...root.CreateUserOption) error {
createUserOpts := root.NewCreateUserOptions(opts...)
// since assign is idempotant multiple calls to assign won't cause issues in case of retries.
err := module.granter.Grant(ctx, input.OrgID, roletypes.MustGetSigNozManagedRoleFromExistingRole(input.Role), authtypes.MustNewSubject(authtypes.TypeableUser, input.ID.StringValue(), input.OrgID, nil))
if err != nil {
return err
}
if err := module.store.RunInTx(ctx, func(ctx context.Context) error {
if err := module.store.CreateUser(ctx, input); err != nil {
return err
@@ -229,6 +237,18 @@ func (m *Module) UpdateUser(ctx context.Context, orgID valuer.UUID, id string, u
}
}
if user.Role != existingUser.Role {
err = m.granter.ModifyGrant(ctx,
orgID,
roletypes.MustGetSigNozManagedRoleFromExistingRole(existingUser.Role),
roletypes.MustGetSigNozManagedRoleFromExistingRole(user.Role),
authtypes.MustNewSubject(authtypes.TypeableUser, id, orgID, nil),
)
if err != nil {
return nil, err
}
}
user.UpdatedAt = time.Now()
updatedUser, err := m.store.UpdateUser(ctx, orgID, id, user)
if err != nil {
@@ -280,6 +300,12 @@ func (module *Module) DeleteUser(ctx context.Context, orgID valuer.UUID, id stri
return errors.New(errors.TypeForbidden, errors.CodeForbidden, "cannot delete the last admin")
}
// since revoke is idempotant multiple calls to revoke won't cause issues in case of retries
err = module.granter.Revoke(ctx, orgID, roletypes.MustGetSigNozManagedRoleFromExistingRole(user.Role), authtypes.MustNewSubject(authtypes.TypeableUser, id, orgID, nil))
if err != nil {
return err
}
if err := module.store.DeleteUser(ctx, orgID.String(), user.ID.StringValue()); err != nil {
return err
}
@@ -477,13 +503,26 @@ func (module *Module) CreateFirstUser(ctx context.Context, organization *types.O
return nil, err
}
managedRoles := roletypes.NewManagedRoles(organization.ID)
err = module.granter.Grant(ctx, organization.ID, roletypes.SigNozAdminRoleName, authtypes.MustNewSubject(authtypes.TypeableUser, user.ID.StringValue(), user.OrgID, nil))
if err != nil {
return nil, err
}
if err = module.store.RunInTx(ctx, func(ctx context.Context) error {
err = module.orgSetter.Create(ctx, organization)
err = module.orgSetter.Create(ctx, organization, func(ctx context.Context, orgID valuer.UUID) error {
err = module.granter.CreateManagedRoles(ctx, orgID, managedRoles)
if err != nil {
return err
}
return nil
})
if err != nil {
return err
}
err = module.CreateUser(ctx, user, root.WithFactorPassword(password))
err = module.createUserWithoutGrant(ctx, user, root.WithFactorPassword(password))
if err != nil {
return err
}
@@ -510,3 +549,28 @@ func (module *Module) Collect(ctx context.Context, orgID valuer.UUID) (map[strin
return stats, nil
}
func (module *Module) createUserWithoutGrant(ctx context.Context, input *types.User, opts ...root.CreateUserOption) error {
createUserOpts := root.NewCreateUserOptions(opts...)
if err := module.store.RunInTx(ctx, func(ctx context.Context) error {
if err := module.store.CreateUser(ctx, input); err != nil {
return err
}
if createUserOpts.FactorPassword != nil {
if err := module.store.CreatePassword(ctx, createUserOpts.FactorPassword); err != nil {
return err
}
}
return nil
}); err != nil {
return err
}
traitsOrProperties := types.NewTraitsFromUser(input)
module.analytics.IdentifyUser(ctx, input.OrgID.String(), input.ID.String(), traitsOrProperties)
module.analytics.TrackUser(ctx, input.OrgID.String(), input.ID.String(), "User Created", traitsOrProperties)
return nil
}

View File

@@ -162,6 +162,10 @@ func NewSQLMigrationProviderFactories(
sqlmigration.NewUpdateOrgPreferenceFactory(sqlstore, sqlschema),
sqlmigration.NewRenameOrgDomainsFactory(sqlstore, sqlschema),
sqlmigration.NewAddResetPasswordTokenExpiryFactory(sqlstore, sqlschema),
sqlmigration.NewAddManagedRolesFactory(sqlstore, sqlschema),
sqlmigration.NewAddAuthzIndexFactory(sqlstore, sqlschema),
sqlmigration.NewMigrateRbacToAuthzFactory(sqlstore),
sqlmigration.NewMigratePublicDashboardsFactory(sqlstore),
)
}

View File

@@ -0,0 +1,101 @@
package sqlmigration
import (
"context"
"database/sql"
"github.com/SigNoz/signoz/pkg/factory"
"github.com/SigNoz/signoz/pkg/sqlschema"
"github.com/SigNoz/signoz/pkg/sqlstore"
"github.com/SigNoz/signoz/pkg/types/roletypes"
"github.com/SigNoz/signoz/pkg/valuer"
"github.com/uptrace/bun"
"github.com/uptrace/bun/migrate"
)
type addManagedRoles struct {
sqlstore sqlstore.SQLStore
sqlschema sqlschema.SQLSchema
}
func NewAddManagedRolesFactory(sqlstore sqlstore.SQLStore, sqlschema sqlschema.SQLSchema) factory.ProviderFactory[SQLMigration, Config] {
return factory.NewProviderFactory(factory.MustNewName("add_managed_roles"), func(ctx context.Context, ps factory.ProviderSettings, c Config) (SQLMigration, error) {
return newAddManagedRoles(ctx, ps, c, sqlstore, sqlschema)
})
}
func newAddManagedRoles(_ context.Context, _ factory.ProviderSettings, _ Config, sqlStore sqlstore.SQLStore, sqlSchema sqlschema.SQLSchema) (SQLMigration, error) {
return &addManagedRoles{sqlstore: sqlStore, sqlschema: sqlSchema}, nil
}
func (migration *addManagedRoles) Register(migrations *migrate.Migrations) error {
if err := migrations.Register(migration.Up, migration.Down); err != nil {
return err
}
return nil
}
func (migration *addManagedRoles) Up(ctx context.Context, db *bun.DB) error {
tx, err := db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer func() {
_ = tx.Rollback()
}()
var orgIDs []string
err = tx.NewSelect().
Table("organizations").
Column("id").
Scan(ctx, &orgIDs)
if err != nil && err != sql.ErrNoRows {
return err
}
managedRoles := []*roletypes.StorableRole{}
for _, orgIDStr := range orgIDs {
orgID, err := valuer.NewUUID(orgIDStr)
if err != nil {
return err
}
// signoz admin
signozAdminRole := roletypes.NewRole(roletypes.SigNozAdminRoleName, roletypes.SigNozAdminRoleDescription, roletypes.RoleTypeManaged, orgID)
managedRoles = append(managedRoles, roletypes.NewStorableRoleFromRole(signozAdminRole))
// signoz editor
signozEditorRole := roletypes.NewRole(roletypes.SigNozEditorRoleName, roletypes.SigNozEditorRoleDescription, roletypes.RoleTypeManaged, orgID)
managedRoles = append(managedRoles, roletypes.NewStorableRoleFromRole(signozEditorRole))
// signoz viewer
signozViewerRole := roletypes.NewRole(roletypes.SigNozViewerRoleName, roletypes.SigNozViewerRoleDescription, roletypes.RoleTypeManaged, orgID)
managedRoles = append(managedRoles, roletypes.NewStorableRoleFromRole(signozViewerRole))
// signoz anonymous
signozAnonymousRole := roletypes.NewRole(roletypes.SigNozAnonymousRoleName, roletypes.SigNozAnonymousRoleDescription, roletypes.RoleTypeManaged, orgID)
managedRoles = append(managedRoles, roletypes.NewStorableRoleFromRole(signozAnonymousRole))
}
if len(managedRoles) > 0 {
_, err = tx.NewInsert().
Model(&managedRoles).
On("CONFLICT (org_id, name) DO UPDATE").
Set("description = EXCLUDED.description, type = EXCLUDED.type, updated_at = EXCLUDED.updated_at").
Exec(ctx)
if err != nil {
return err
}
}
if err := tx.Commit(); err != nil {
return err
}
return nil
}
func (migration *addManagedRoles) Down(_ context.Context, _ *bun.DB) error {
return nil
}

View File

@@ -0,0 +1,74 @@
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/dialect"
"github.com/uptrace/bun/migrate"
)
type addAuthzIndex struct {
sqlstore sqlstore.SQLStore
sqlschema sqlschema.SQLSchema
}
func NewAddAuthzIndexFactory(sqlstore sqlstore.SQLStore, sqlschema sqlschema.SQLSchema) factory.ProviderFactory[SQLMigration, Config] {
return factory.NewProviderFactory(factory.MustNewName("add_authz_index"), func(ctx context.Context, ps factory.ProviderSettings, c Config) (SQLMigration, error) {
return newAddAuthzIndex(ctx, ps, c, sqlstore, sqlschema)
})
}
func newAddAuthzIndex(_ context.Context, _ factory.ProviderSettings, _ Config, sqlstore sqlstore.SQLStore, sqlschema sqlschema.SQLSchema) (SQLMigration, error) {
return &addAuthzIndex{
sqlstore: sqlstore,
sqlschema: sqlschema,
}, nil
}
func (migration *addAuthzIndex) Register(migrations *migrate.Migrations) error {
if err := migrations.Register(migration.Up, migration.Down); err != nil {
return err
}
return nil
}
func (migration *addAuthzIndex) Up(ctx context.Context, db *bun.DB) error {
if migration.sqlstore.BunDB().Dialect().Name() != dialect.PG {
return nil
}
tx, err := db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer func() {
_ = tx.Rollback()
}()
sqls := [][]byte{}
indexSQLs := migration.sqlschema.Operator().CreateIndex(&sqlschema.UniqueIndex{TableName: "tuple", ColumnNames: []sqlschema.ColumnName{"ulid"}})
sqls = append(sqls, indexSQLs...)
for _, sql := range sqls {
if _, err := tx.ExecContext(ctx, string(sql)); err != nil {
return err
}
}
err = tx.Commit()
if err != nil {
return err
}
return nil
}
func (migration *addAuthzIndex) Down(context.Context, *bun.DB) error {
return nil
}

View File

@@ -0,0 +1,225 @@
package sqlmigration
import (
"context"
"database/sql"
"time"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/factory"
"github.com/SigNoz/signoz/pkg/sqlstore"
"github.com/SigNoz/signoz/pkg/types/authtypes"
"github.com/oklog/ulid/v2"
"github.com/uptrace/bun"
"github.com/uptrace/bun/dialect"
"github.com/uptrace/bun/migrate"
)
type migrateRbacToAuthz struct {
sqlstore sqlstore.SQLStore
}
var (
existingRoleToSigNozManagedRoleMap = map[string]string{
"ADMIN": "signoz-admin",
"EDITOR": "signoz-editor",
"VIEWER": "signoz-viewer",
}
)
func NewMigrateRbacToAuthzFactory(sqlstore sqlstore.SQLStore) factory.ProviderFactory[SQLMigration, Config] {
return factory.NewProviderFactory(factory.MustNewName("migrate_rbac_to_authz"), func(ctx context.Context, ps factory.ProviderSettings, c Config) (SQLMigration, error) {
return newMigrateRbacToAuthz(ctx, ps, c, sqlstore)
})
}
func newMigrateRbacToAuthz(_ context.Context, _ factory.ProviderSettings, _ Config, sqlstore sqlstore.SQLStore) (SQLMigration, error) {
return &migrateRbacToAuthz{
sqlstore: sqlstore,
}, nil
}
func (migration *migrateRbacToAuthz) Register(migrations *migrate.Migrations) error {
if err := migrations.Register(migration.Up, migration.Down); err != nil {
return err
}
return nil
}
func (migration *migrateRbacToAuthz) Up(ctx context.Context, db *bun.DB) error {
tx, err := db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer func() {
_ = tx.Rollback()
}()
// for upgrades from version where authz service wasn't introduced the store won't be present, hence we need to ensure store exists.
var storeID string
err = tx.QueryRowContext(ctx, `SELECT id FROM store WHERE name = ? LIMIT 1`, "signoz").Scan(&storeID)
if err != nil && err != sql.ErrNoRows {
return err
}
if storeID == "" {
// based on openfga ids to avoid any scan issues.
// ref: https://github.com/openfga/openfga/blob/main/pkg/server/commands/create_store.go#L45
storeID = ulid.Make().String()
_, err := tx.ExecContext(ctx, `INSERT INTO store (id, name, created_at, updated_at) VALUES (?, ?, ?, ?)`, storeID, "signoz", time.Now().UTC(), time.Now().UTC())
if err != nil {
return err
}
}
// fetch all the orgs for which we need to insert user role grant tuples.
orgIDs := []string{}
rows, err := tx.QueryContext(ctx, `SELECT id FROM organizations`)
if err != nil {
return err
}
defer rows.Close()
for rows.Next() {
var orgID string
if err := rows.Scan(&orgID); err != nil {
return err
}
orgIDs = append(orgIDs, orgID)
}
type tuple struct {
OrgID string
Type string
ID string
RoleName string
}
tuples := []tuple{}
for _, orgID := range orgIDs {
userRows, err := tx.QueryContext(ctx, `
SELECT id, role FROM users WHERE org_id = ?`, orgID)
if err != nil {
return err
}
defer userRows.Close()
for userRows.Next() {
var id, role string
if err := userRows.Scan(&id, &role); err != nil {
return err
}
managedRole, ok := existingRoleToSigNozManagedRoleMap[role]
if !ok {
return errors.Newf(errors.TypeInternal, errors.CodeInternal, "invalid role assignment: %s for user_id: %s", role, id)
}
tuples = append(tuples, tuple{
OrgID: orgID,
ID: id,
Type: "user",
RoleName: managedRole,
})
}
tuples = append(tuples, tuple{
OrgID: orgID,
ID: authtypes.AnonymousUser.StringValue(),
Type: "anonymous",
RoleName: "signoz-anonymous",
})
}
_, err = tx.ExecContext(ctx, `DELETE FROM tuple`)
if err != nil {
return err
}
_, err = tx.ExecContext(ctx, `DELETE FROM changelog`)
if err != nil {
return err
}
for _, tuple := range tuples {
// based on openfga tuple and changelog id's are same for writes.
// ref: https://github.com/openfga/openfga/blob/main/pkg/storage/sqlite/sqlite.go#L467
entropy := ulid.DefaultEntropy()
now := time.Now().UTC()
tupleID := ulid.MustNew(ulid.Timestamp(now), entropy).String()
if migration.sqlstore.BunDB().Dialect().Name() == dialect.PG {
result, err := tx.ExecContext(ctx, `
INSERT INTO tuple (store, object_type, object_id, relation, _user, user_type, ulid, inserted_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT (store, object_type, object_id, relation, _user) DO NOTHING`,
storeID, "role", "organization/"+tuple.OrgID+"/role/"+tuple.RoleName, "assignee", tuple.Type+":organization/"+tuple.OrgID+"/"+tuple.Type+"/"+tuple.ID, "user", tupleID, now,
)
if err != nil {
return err
}
rowsAffected, err := result.RowsAffected()
if err != nil {
return err
}
if rowsAffected == 0 {
continue
}
_, err = tx.ExecContext(ctx, `
INSERT INTO changelog (store, object_type, object_id, relation, _user, operation, ulid, inserted_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT (store, ulid, object_type) DO NOTHING`,
storeID, "role", "organization/"+tuple.OrgID+"/role/"+tuple.RoleName, "assignee", tuple.Type+":organization/"+tuple.OrgID+"/"+tuple.Type+"/"+tuple.ID, "TUPLE_OPERATION_WRITE", tupleID, now,
)
if err != nil {
return err
}
} else {
result, err := tx.ExecContext(ctx, `
INSERT INTO tuple (store, object_type, object_id, relation, user_object_type, user_object_id, user_relation, user_type, ulid, inserted_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT (store, object_type, object_id, relation, user_object_type, user_object_id, user_relation) DO NOTHING`,
storeID, "role", "organization/"+tuple.OrgID+"/role/"+tuple.RoleName, "assignee", tuple.Type, "organization/"+tuple.OrgID+"/"+tuple.Type+"/"+tuple.ID, "", "user", tupleID, now,
)
if err != nil {
return err
}
rowsAffected, err := result.RowsAffected()
if err != nil {
return err
}
if rowsAffected == 0 {
continue
}
_, err = tx.ExecContext(ctx, `
INSERT INTO changelog (store, object_type, object_id, relation, user_object_type, user_object_id, user_relation, operation, ulid, inserted_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT (store, ulid, object_type) DO NOTHING`,
storeID, "role", "organization/"+tuple.OrgID+"/role/"+tuple.RoleName, "assignee", tuple.Type, "organization/"+tuple.OrgID+"/"+tuple.Type+"/"+tuple.ID, "", 0, tupleID, now,
)
if err != nil {
return err
}
}
}
err = tx.Commit()
if err != nil {
return err
}
return nil
}
func (migration *migrateRbacToAuthz) Down(context.Context, *bun.DB) error {
return nil
}

View File

@@ -0,0 +1,183 @@
package sqlmigration
import (
"context"
"time"
"github.com/SigNoz/signoz/pkg/factory"
"github.com/SigNoz/signoz/pkg/sqlstore"
"github.com/oklog/ulid/v2"
"github.com/uptrace/bun"
"github.com/uptrace/bun/dialect"
"github.com/uptrace/bun/migrate"
)
type migratePublicDashboards struct {
sqlstore sqlstore.SQLStore
}
func NewMigratePublicDashboardsFactory(sqlstore sqlstore.SQLStore) factory.ProviderFactory[SQLMigration, Config] {
return factory.NewProviderFactory(factory.MustNewName("migrate_public_dashboards"), func(ctx context.Context, ps factory.ProviderSettings, c Config) (SQLMigration, error) {
return newMigratePublicDashboards(ctx, ps, c, sqlstore)
})
}
func newMigratePublicDashboards(_ context.Context, _ factory.ProviderSettings, _ Config, sqlstore sqlstore.SQLStore) (SQLMigration, error) {
return &migratePublicDashboards{
sqlstore: sqlstore,
}, nil
}
func (migration *migratePublicDashboards) Register(migrations *migrate.Migrations) error {
if err := migrations.Register(migration.Up, migration.Down); err != nil {
return err
}
return nil
}
func (migration *migratePublicDashboards) Up(ctx context.Context, db *bun.DB) error {
tx, err := db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer func() {
_ = tx.Rollback()
}()
var storeID string
err = tx.QueryRowContext(ctx, `SELECT id FROM store WHERE name = ? LIMIT 1`, "signoz").Scan(&storeID)
if err != nil {
return err
}
// fetch all the orgs for which we need to insert user role grant tuples.
orgIDs := []string{}
rows, err := tx.QueryContext(ctx, `SELECT id FROM organizations`)
if err != nil {
return err
}
defer rows.Close()
for rows.Next() {
var orgID string
if err := rows.Scan(&orgID); err != nil {
return err
}
orgIDs = append(orgIDs, orgID)
}
type tuple struct {
OrgID string
DashboardID string
RoleName string
}
tuples := []tuple{}
for _, orgID := range orgIDs {
publicDashboards, err := tx.QueryContext(ctx, `
SELECT public_dashboard.id
FROM public_dashboard
INNER JOIN dashboard ON dashboard.id = public_dashboard.dashboard_id
WHERE dashboard.org_id = ?`, orgID)
if err != nil {
return err
}
defer publicDashboards.Close()
for publicDashboards.Next() {
var id string
if err := publicDashboards.Scan(&id); err != nil {
return err
}
tuples = append(tuples, tuple{
OrgID: orgID,
DashboardID: id,
RoleName: "signoz-anonymous",
})
}
}
for _, tuple := range tuples {
// based on openfga tuple and changelog id's are same for writes.
// ref: https://github.com/openfga/openfga/blob/main/pkg/storage/sqlite/sqlite.go#L467
entropy := ulid.DefaultEntropy()
now := time.Now().UTC()
tupleID := ulid.MustNew(ulid.Timestamp(now), entropy).String()
if migration.sqlstore.BunDB().Dialect().Name() == dialect.PG {
result, err := tx.ExecContext(ctx, `
INSERT INTO tuple (store, object_type, object_id, relation, _user, user_type, ulid, inserted_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT (store, object_type, object_id, relation, _user) DO NOTHING`,
storeID, "metaresource", "organization/"+tuple.OrgID+"/public-dashboard/"+tuple.DashboardID, "read", "role:organization/"+tuple.OrgID+"/role/"+tuple.RoleName+"#assignee", "userset", tupleID, now,
)
if err != nil {
return err
}
rowsAffected, err := result.RowsAffected()
if err != nil {
return err
}
if rowsAffected == 0 {
continue
}
_, err = tx.ExecContext(ctx, `
INSERT INTO changelog (store, object_type, object_id, relation, _user, operation, ulid, inserted_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT (store, ulid, object_type) DO NOTHING`,
storeID, "metaresource", "organization/"+tuple.OrgID+"/public-dashboard/"+tuple.DashboardID, "read", "role:organization/"+tuple.OrgID+"/role/"+tuple.RoleName+"#assignee", "TUPLE_OPERATION_WRITE", tupleID, now,
)
if err != nil {
return err
}
} else {
result, err := tx.ExecContext(ctx, `
INSERT INTO tuple (store, object_type, object_id, relation, user_object_type, user_object_id, user_relation, user_type, ulid, inserted_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT (store, object_type, object_id, relation, user_object_type, user_object_id, user_relation) DO NOTHING`,
storeID, "metaresource", "organization/"+tuple.OrgID+"/public-dashboard/"+tuple.DashboardID, "read", "role", "organization/"+tuple.OrgID+"/role/"+tuple.RoleName, "assignee", "userset", tupleID, now,
)
if err != nil {
return err
}
rowsAffected, err := result.RowsAffected()
if err != nil {
return err
}
if rowsAffected == 0 {
continue
}
_, err = tx.ExecContext(ctx, `
INSERT INTO changelog (store, object_type, object_id, relation, user_object_type, user_object_id, user_relation, operation, ulid, inserted_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT (store, ulid, object_type) DO NOTHING`,
storeID, "metaresource", "organization/"+tuple.OrgID+"/public-dashboard/"+tuple.DashboardID, "read", "role", "organization/"+tuple.OrgID+"/role/"+tuple.RoleName, "assignee", 0, tupleID, now,
)
if err != nil {
return err
}
}
}
err = tx.Commit()
if err != nil {
return err
}
return nil
}
func (migration *migratePublicDashboards) Down(context.Context, *bun.DB) error {
return nil
}

View File

@@ -21,7 +21,7 @@ var (
var (
typeUserSelectorRegex = regexp.MustCompile(`^[0-9a-f]{8}(?:\-[0-9a-f]{4}){3}-[0-9a-f]{12}$`)
typeRoleSelectorRegex = regexp.MustCompile(`^[0-9a-f]{8}(?:\-[0-9a-f]{4}){3}-[0-9a-f]{12}$`)
typeRoleSelectorRegex = regexp.MustCompile(`^[a-z-]{1,50}$`)
typeAnonymousSelectorRegex = regexp.MustCompile(`^\*$`)
typeOrganizationSelectorRegex = regexp.MustCompile(`^[0-9a-f]{8}(?:\-[0-9a-f]{4}){3}-[0-9a-f]{12}$`)
typeMetaResourceSelectorRegex = regexp.MustCompile(`^(^[0-9a-f]{8}(?:\-[0-9a-f]{4}){3}-[0-9a-f]{12}$|\*)$`)

View File

@@ -24,7 +24,7 @@ var (
)
var (
RoleNameRegex = regexp.MustCompile("^[a-z-]{1,50}$")
roleNameRegex = regexp.MustCompile("^[a-z-]{1,50}$")
)
var (
@@ -81,7 +81,6 @@ type PostableRole struct {
}
type PatchableRole struct {
Name *string `json:"name"`
Description *string `json:"description"`
}
@@ -138,15 +137,12 @@ func NewManagedRoles(orgID valuer.UUID) []*Role {
}
func (role *Role) PatchMetadata(name, description *string) error {
func (role *Role) PatchMetadata(description *string) error {
err := role.CanEditDelete()
if err != nil {
return err
}
if name != nil {
role.Name = *name
}
if description != nil {
role.Description = *description
}
@@ -202,8 +198,8 @@ func (role *PostableRole) UnmarshalJSON(data []byte) error {
return errors.New(errors.TypeInvalidInput, ErrCodeRoleInvalidInput, "name is missing from the request")
}
if match := RoleNameRegex.MatchString(shadowRole.Name); !match {
return errors.Newf(errors.TypeInvalidInput, ErrCodeRoleInvalidInput, "name must conform to the regex: %s", RoleNameRegex.String())
if match := roleNameRegex.MatchString(shadowRole.Name); !match {
return errors.Newf(errors.TypeInvalidInput, ErrCodeRoleInvalidInput, "name must conform to the regex: %s", roleNameRegex.String())
}
role.Name = shadowRole.Name
@@ -214,7 +210,6 @@ func (role *PostableRole) UnmarshalJSON(data []byte) error {
func (role *PatchableRole) UnmarshalJSON(data []byte) error {
type shadowPatchableRole struct {
Name *string `json:"name"`
Description *string `json:"description"`
}
@@ -223,23 +218,16 @@ func (role *PatchableRole) UnmarshalJSON(data []byte) error {
return err
}
if shadowRole.Name == nil && shadowRole.Description == nil {
return errors.New(errors.TypeInvalidInput, ErrCodeRoleEmptyPatch, "empty role patch request received, at least one of name or description must be present")
if shadowRole.Description == nil {
return errors.New(errors.TypeInvalidInput, ErrCodeRoleEmptyPatch, "empty role patch request received, description must be present")
}
if shadowRole.Name != nil {
if match := RoleNameRegex.MatchString(*shadowRole.Name); !match {
return errors.Newf(errors.TypeInvalidInput, ErrCodeRoleInvalidInput, "name must conform to the regex: %s", RoleNameRegex.String())
}
}
role.Name = shadowRole.Name
role.Description = shadowRole.Description
return nil
}
func GetAdditionTuples(id valuer.UUID, orgID valuer.UUID, relation authtypes.Relation, additions []*authtypes.Object) ([]*openfgav1.TupleKey, error) {
func GetAdditionTuples(name string, orgID valuer.UUID, relation authtypes.Relation, additions []*authtypes.Object) ([]*openfgav1.TupleKey, error) {
tuples := make([]*openfgav1.TupleKey, 0)
for _, object := range additions {
@@ -247,7 +235,7 @@ func GetAdditionTuples(id valuer.UUID, orgID valuer.UUID, relation authtypes.Rel
transactionTuples, err := typeable.Tuples(
authtypes.MustNewSubject(
authtypes.TypeableRole,
id.String(),
name,
orgID,
&authtypes.RelationAssignee,
),
@@ -265,7 +253,7 @@ func GetAdditionTuples(id valuer.UUID, orgID valuer.UUID, relation authtypes.Rel
return tuples, nil
}
func GetDeletionTuples(id valuer.UUID, orgID valuer.UUID, relation authtypes.Relation, deletions []*authtypes.Object) ([]*openfgav1.TupleKey, error) {
func GetDeletionTuples(name string, orgID valuer.UUID, relation authtypes.Relation, deletions []*authtypes.Object) ([]*openfgav1.TupleKey, error) {
tuples := make([]*openfgav1.TupleKey, 0)
for _, object := range deletions {
@@ -273,7 +261,7 @@ func GetDeletionTuples(id valuer.UUID, orgID valuer.UUID, relation authtypes.Rel
transactionTuples, err := typeable.Tuples(
authtypes.MustNewSubject(
authtypes.TypeableRole,
id.String(),
name,
orgID,
&authtypes.RelationAssignee,
),

View File

@@ -0,0 +1,135 @@
import base64
import json
import time
from datetime import datetime, timedelta
from http import HTTPStatus
from typing import List
import requests
from fixtures import types
from fixtures.logger import setup_logger
logger = setup_logger(__name__)
def collect_webhook_firing_alerts(
webhook_test_container: types.TestContainerDocker, notification_channel_name: str
) -> List[types.FiringAlert]:
# Prepare the endpoint path for the channel name, for alerts tests we have
# used different paths for receiving alerts from each channel so that
# multiple rules can be tested in isolation.
rule_webhook_endpoint = f"/alert/{notification_channel_name}"
url = webhook_test_container.host_configs["8080"].get("__admin/requests/find")
req = {
"method": "POST",
"url": rule_webhook_endpoint,
}
res = requests.post(url, json=req, timeout=5)
assert res.status_code == HTTPStatus.OK, (
f"Failed to collect firing alerts for notification channel {notification_channel_name}, "
f"status code: {res.status_code}, response: {res.text}"
)
response = res.json()
alerts = []
for req in response["requests"]:
alert_body_base64 = req["bodyAsBase64"]
alert_body = base64.b64decode(alert_body_base64).decode("utf-8")
# remove newlines from the alert body
alert_body = alert_body.replace("\n", "")
alert_dict = json.loads(alert_body) # parse the alert body into a dictionary
for a in alert_dict["alerts"]:
alerts.append(types.FiringAlert(labels=a["labels"]))
return alerts
def _verify_alerts_labels(
firing_alerts: list[dict[str, str]], expected_alerts: list[dict[str, str]]
) -> tuple[int, list[dict[str, str]]]:
"""
Checks how many of the expected alerts have been fired.
Returns the count of expected alerts that have been fired.
"""
fired_count = 0
missing_alerts = []
for alert in expected_alerts:
is_alert_fired = False
for fired_alert in firing_alerts:
# Check if current expected alert is present in the fired alerts
if all(
key in fired_alert and fired_alert[key] == value
for key, value in alert.items()
):
is_alert_fired = True
break
if is_alert_fired:
fired_count += 1
else:
missing_alerts.append(alert)
return (fired_count, missing_alerts)
def verify_webhook_alert_expectation(
test_alert_container: types.TestContainerDocker,
notification_channel_name: str,
alert_expectations: types.AlertExpectation,
) -> bool:
# time to wait till the expected alerts are fired
time_to_wait = datetime.now() + timedelta(
seconds=alert_expectations.wait_time_seconds
)
expected_alerts_labels = [
alert.labels for alert in alert_expectations.expected_alerts
]
while datetime.now() < time_to_wait:
firing_alerts = collect_webhook_firing_alerts(
test_alert_container, notification_channel_name
)
firing_alert_labels = [alert.labels for alert in firing_alerts]
if alert_expectations.should_alert:
# verify the number of alerts fired, currently we're only verifying the labels of the alerts
# but there could be verification of annotations and other fields in the FiringAlert
(verified_count, missing_alerts) = _verify_alerts_labels(
firing_alert_labels, expected_alerts_labels
)
if verified_count == len(alert_expectations.expected_alerts):
logger.info(
"Got expected number of alerts: %s", {"count": verified_count}
)
return True
else:
# No alert is supposed to be fired if should_alert is False
if len(firing_alerts) > 0:
break
# wait for some time before checking again
time.sleep(10)
# We've waited but we didn't get the expected number of alerts
# check if alert was expected to be fired or not, if not then we're good
if not alert_expectations.should_alert:
assert len(firing_alerts) == 0, (
"Expected no alerts to be fired, ",
f"got {len(firing_alerts)} alerts, " f"firing alerts: {firing_alerts}",
)
logger.info("No alerts fired, as expected")
return True
# we've waited but we didn't get the expected number of alerts, raise an exception
assert verified_count == len(alert_expectations.expected_alerts), (
f"Expected {len(alert_expectations.expected_alerts)} alerts to be fired but got {verified_count} alerts, ",
f"missing alerts: {missing_alerts}, ",
f"firing alerts: {firing_alerts}",
)
return True # should not reach here

View File

@@ -1,5 +1,5 @@
from dataclasses import dataclass
from typing import Dict, Literal
from typing import Dict, List, Literal
from urllib.parse import urljoin
import clickhouse_connect
@@ -173,3 +173,21 @@ class AlertData:
type: Literal["metrics", "logs", "traces"]
# path to the data file in testdata directory
data_path: str
@dataclass(frozen=True)
class FiringAlert:
# labels of the alert that is firing
labels: dict[str, str]
# annotations and other fields can be added later as per need
@dataclass(frozen=True)
class AlertExpectation:
# whether we expect any alerts to be fired
should_alert: bool
# alerts that we expect to be fired
expected_alerts: List[FiringAlert]
# seconds to wait for the alerts to be fired, if no
# alerts are fired in the expected time, the test will fail
wait_time_seconds: int

View File

@@ -83,7 +83,7 @@ def test_create_and_get_public_dashboard(
assert row["name"] == "signoz-anonymous"
# verify the tuple creation for role
tuple_object_id = f"organization/{row["org_id"]}/role/{row["id"]}"
tuple_object_id = f"organization/{row["org_id"]}/role/signoz-anonymous"
tuple_result = conn.execute(
sql.text("SELECT * FROM tuple WHERE object_id = :object_id"),
{"object_id": tuple_object_id},
@@ -206,7 +206,6 @@ def test_public_dashboard_widget_query_range(
),
timeout=2,
)
print(resp.json())
assert resp.status_code == HTTPStatus.OK
assert resp.json().get("status") == "success"

View File

@@ -0,0 +1,98 @@
import pytest
from http import HTTPStatus
from typing import Callable
import requests
from sqlalchemy import sql
from fixtures.auth import USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD
from fixtures.types import Operation, SigNoz
def test_managed_roles_create_on_register(
signoz: SigNoz,
create_user_admin: Operation, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
):
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
# get the list of all roles.
response = requests.get(
signoz.self.host_configs["8080"].get("/api/v1/roles"),
headers={"Authorization": f"Bearer {admin_token}"},
timeout=2,
)
assert response.status_code == HTTPStatus.OK
assert response.json()["status"] == "success"
data = response.json()["data"]
# since this check happens immediately post registeration, all the managed roles should be present.
assert len(data) == 4
role_names = {role["name"] for role in data}
expected_names = {"signoz-admin", "signoz-viewer", "signoz-editor", "signoz-anonymous"}
# do the set mapping as this is order insensitive, direct list match is order-sensitive.
assert set(role_names) == expected_names
def test_root_user_signoz_admin_assignment(
request: pytest.FixtureRequest,
signoz: SigNoz,
create_user_admin: Operation, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
):
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
# Get the user from the /user/me endpoint and extract the id
user_response = requests.get(
signoz.self.host_configs["8080"].get("/api/v1/user/me"),
headers={"Authorization": f"Bearer {admin_token}"},
timeout=2,
)
assert user_response.status_code == HTTPStatus.OK
user_id = user_response.json()["data"]["id"]
response = requests.get(
signoz.self.host_configs["8080"].get("/api/v1/roles"),
headers={"Authorization": f"Bearer {admin_token}"},
timeout=2,
)
# this validates to some extent that the role assignment is complete under the assumption that middleware is functioning as expected.
assert response.status_code == HTTPStatus.OK
assert response.json()["status"] == "success"
# Loop over the roles and get the org_id and id for signoz-admin role
roles = response.json()["data"]
admin_role_entry = next((role for role in roles if role["name"] == "signoz-admin"), None)
assert admin_role_entry is not None
org_id = admin_role_entry["orgId"]
# to be super sure of authorization server, let's validate the tuples in DB as well.
# todo[@vikrantgupta25]: replace this with role memebers handler once built.
with signoz.sqlstore.conn.connect() as conn:
# verify the entry present for role assignment
tuple_object_id = f"organization/{org_id}/role/signoz-admin"
tuple_result = conn.execute(
sql.text("SELECT * FROM tuple WHERE object_id = :object_id"),
{"object_id": tuple_object_id},
)
tuple_row = tuple_result.mappings().fetchone()
assert tuple_row is not None
# check that the tuple if for role assignment
assert tuple_row['object_type'] == "role"
assert tuple_row['relation'] == "assignee"
if request.config.getoption("--sqlstore-provider") == 'sqlite':
user_object_id = f"organization/{org_id}/user/{user_id}"
assert tuple_row["user_object_type"] == "user"
assert tuple_row["user_object_id"] == user_object_id
else:
_user = f"user:organization/{org_id}/user/{user_id}"
assert tuple_row["user_type"] == "user"
assert tuple_row["_user"] == _user

View File

@@ -0,0 +1,220 @@
import pytest
from http import HTTPStatus
from typing import Callable
import requests
from sqlalchemy import sql
from fixtures.auth import USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD, USER_EDITOR_EMAIL, USER_EDITOR_PASSWORD
from fixtures.types import Operation, SigNoz
def test_user_invite_accept_role_grant(
request: pytest.FixtureRequest,
signoz: SigNoz,
create_user_admin: Operation, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
):
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
# invite a user as editor
invite_payload = {
"email": USER_EDITOR_EMAIL,
"role": "EDITOR",
}
invite_response = requests.post(
signoz.self.host_configs["8080"].get("/api/v1/invite"),
json=invite_payload,
headers={"Authorization": f"Bearer {admin_token}"},
timeout=2,
)
assert invite_response.status_code == HTTPStatus.CREATED
invite_token = invite_response.json()["data"]["token"]
# accept the invite for editor
accept_payload = {
"token": invite_token,
"password": "password123Z$",
}
accept_response = requests.post(
signoz.self.host_configs["8080"].get("/api/v1/invite/accept"),
json=accept_payload,
timeout=2,
)
assert accept_response.status_code == HTTPStatus.CREATED
# Login with editor email and password
editor_token = get_token(USER_EDITOR_EMAIL, USER_EDITOR_PASSWORD)
user_me_response = requests.get(
signoz.self.host_configs["8080"].get("/api/v1/user/me"),
headers={"Authorization": f"Bearer {editor_token}"},
timeout=2,
)
assert user_me_response.status_code == HTTPStatus.OK
editor_id = user_me_response.json()["data"]["id"]
# check the forbidden response for admin api for editor user
admin_roles_response = requests.get(
signoz.self.host_configs["8080"].get("/api/v1/roles"),
headers={"Authorization": f"Bearer {editor_token}"},
timeout=2,
)
assert admin_roles_response.status_code == HTTPStatus.FORBIDDEN
roles_response = requests.get(
signoz.self.host_configs["8080"].get("/api/v1/roles"),
headers={"Authorization": f"Bearer {admin_token}"},
timeout=2,
)
assert roles_response.status_code == HTTPStatus.OK
org_id = roles_response.json()["data"][0]["orgId"]
# check role assignment tuples in DB
with signoz.sqlstore.conn.connect() as conn:
tuple_object_id = f"organization/{org_id}/role/signoz-editor"
tuple_result = conn.execute(
sql.text("SELECT * FROM tuple WHERE object_id = :object_id"),
{"object_id": tuple_object_id},
)
tuple_row = tuple_result.mappings().fetchone()
assert tuple_row is not None
assert tuple_row['object_type'] == "role"
assert tuple_row['relation'] == "assignee"
# verify the user tuple details depending on db provider
if request.config.getoption("--sqlstore-provider") == 'sqlite':
user_object_id = f"organization/{org_id}/user/{editor_id}"
assert tuple_row["user_object_type"] == "user"
assert tuple_row["user_object_id"] == user_object_id
else:
_user = f"user:organization/{org_id}/user/{editor_id}"
assert tuple_row["user_type"] == "user"
assert tuple_row["_user"] == _user
def test_user_update_role_grant(
request: pytest.FixtureRequest,
signoz: SigNoz,
create_user_admin: Operation, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
):
# Get the editor user's id
editor_token = get_token(USER_EDITOR_EMAIL, USER_EDITOR_PASSWORD)
user_me_response = requests.get(
signoz.self.host_configs["8080"].get("/api/v1/user/me"),
headers={"Authorization": f"Bearer {editor_token}"},
timeout=2,
)
assert user_me_response.status_code == HTTPStatus.OK
editor_id = user_me_response.json()["data"]["id"]
# Get the role id for viewer
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
roles_response = requests.get(
signoz.self.host_configs["8080"].get("/api/v1/roles"),
headers={"Authorization": f"Bearer {admin_token}"},
timeout=2,
)
assert roles_response.status_code == HTTPStatus.OK
roles_data = roles_response.json()["data"]
org_id = roles_data[0]["orgId"]
# Update the user's role to viewer
update_payload = {
"role": "VIEWER"
}
update_response = requests.put(
signoz.self.host_configs["8080"].get(f"/api/v1/user/{editor_id}"),
json=update_payload,
headers={"Authorization": f"Bearer {admin_token}"},
timeout=2,
)
assert update_response.status_code == HTTPStatus.OK
# Check that user no longer has the editor role in the db
with signoz.sqlstore.conn.connect() as conn:
editor_tuple_object_id = f"organization/{org_id}/role/signoz-editor"
viewer_tuple_object_id = f"organization/{org_id}/role/signoz-viewer"
# Check there is no tuple for signoz-editor assignment
editor_tuple_result = conn.execute(
sql.text("SELECT * FROM tuple WHERE object_id = :object_id AND relation = 'assignee'"),
{"object_id": editor_tuple_object_id},
)
for row in editor_tuple_result.mappings().fetchall():
if request.config.getoption("--sqlstore-provider") == "sqlite":
user_object_id = f"organization/{org_id}/user/{editor_id}"
assert row["user_object_id"] != user_object_id
else:
_user = f"user:organization/{org_id}/user/{editor_id}"
assert row["_user"] != _user
# Check that a tuple exists for signoz-viewer assignment
viewer_tuple_result = conn.execute(
sql.text("SELECT * FROM tuple WHERE object_id = :object_id AND relation = 'assignee'"),
{"object_id": viewer_tuple_object_id},
)
row = viewer_tuple_result.mappings().fetchone()
assert row is not None
assert row['object_type'] == "role"
assert row['relation'] == "assignee"
if request.config.getoption("--sqlstore-provider") == "sqlite":
user_object_id = f"organization/{org_id}/user/{editor_id}"
assert row["user_object_type"] == "user"
assert row["user_object_id"] == user_object_id
else:
_user = f"user:organization/{org_id}/user/{editor_id}"
assert row["user_type"] == "user"
assert row["_user"] == _user
def test_user_delete_role_revoke(
request: pytest.FixtureRequest,
signoz: SigNoz,
create_user_admin: Operation, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
):
# login with editor to get the user_id and check if user exists
editor_token = get_token(USER_EDITOR_EMAIL, USER_EDITOR_PASSWORD)
user_me_response = requests.get(
signoz.self.host_configs["8080"].get("/api/v1/user/me"),
headers={"Authorization": f"Bearer {editor_token}"},
timeout=2,
)
assert user_me_response.status_code == HTTPStatus.OK
editor_id = user_me_response.json()["data"]["id"]
# delete the editor user
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
delete_response = requests.delete(
signoz.self.host_configs["8080"].get(f"/api/v1/user/{editor_id}"),
headers={"Authorization": f"Bearer {admin_token}"},
timeout=2,
)
assert delete_response.status_code == HTTPStatus.NO_CONTENT
# get the role id from roles list
roles_response = requests.get(
signoz.self.host_configs["8080"].get("/api/v1/roles"),
headers={"Authorization": f"Bearer {admin_token}"},
timeout=2,
)
assert roles_response.status_code == HTTPStatus.OK
org_id = roles_response.json()["data"][0]["orgId"]
tuple_object_id = f"organization/{org_id}/role/signoz-editor"
with signoz.sqlstore.conn.connect() as conn:
tuple_result = conn.execute(
sql.text("SELECT * FROM tuple WHERE object_id = :object_id AND relation = 'assignee'"),
{"object_id": tuple_object_id},
)
# there should NOT be any tuple for the current user assignment
tuple_rows = tuple_result.mappings().fetchall()
for row in tuple_rows:
if request.config.getoption("--sqlstore-provider") == "sqlite":
user_object_id = f"organization/{org_id}/user/{editor_id}"
assert row["user_object_id"] != user_object_id
else:
_user = f"user:organization/{org_id}/user/{editor_id}"
assert row["_user"] != _user