Compare commits

...

7 Commits

Author SHA1 Message Date
Vinícius Lourenço
670cb7aca5 feat(components): added shared components for alerts 2026-05-11 14:58:00 -03:00
Vinícius Lourenço
0daf7a12da chore(signozhq/ui): bump to v0.0.19 2026-05-11 14:47:09 -03:00
Vinícius Lourenço
cc7d7017ae feat(tanstack-table): add showPageSize flag and callbacks to pagination 2026-05-11 14:45:20 -03:00
Vinicius Lourenço
3787715fd8 fix(global-time): initialize store with default value instead of 0 (#11257) 2026-05-11 13:08:08 +00:00
shubham yadav
0fbfff353a refactor(icons): replace LoadingOutlined with SigNoz Loader (#10839)
* fix:replace LoadingOutlined to Loader signoz icon

* fix: remove unintended changes

* add correct import for icon and size

* fix test and linting

* fix test and linting

* fix test and linting

* fix test and linting

* fix linting

* fix(src): missing few places after merge main

* fix(hostslisttable): removed file added by accident

---------

Co-authored-by: Vinícius Lourenço <vinicius@signoz.io>
Co-authored-by: Vinicius Lourenço <12551007+H4ad@users.noreply.github.com>
2026-05-11 13:05:31 +00:00
Nikhil Mantri
ca8cc30c34 feat(infra-monitoring): v2 clusters list api (#11132)
* chore: baseline setup

* chore: endpoint detail update

* chore: added logic for hosts v3 api

* fix: bug fix

* chore: disk usage

* chore: added validate function

* chore: added some unit tests

* chore: return status as a string

* chore: yarn generate api

* chore: removed isSendingK8sAgentsMetricsCode

* chore: moved funcs

* chore: added validation on order by

* chore: added pods list logic

* chore: updated openapi yml

* chore: updated spec

* chore: pods api meta start time

* chore: nil pointer check

* chore: nil pointer dereference fix in req.Filter

* chore: added temporalities of metrics

* chore: added pods metrics temporality

* chore: unified composite key function

* chore: code improvements

* chore: added pods list api updates

* chore: hostStatusNone added for clarity that this field can be left empty as well in payload

* chore: yarn generate api

* chore: return errors from getMetadata and lint fix

* chore: return errors from getMetadata and lint fix

* chore: added hostName logic

* chore: modified getMetadata query

* chore: add type for response and files rearrange

* chore: warnings added passing from queryResponse warning to host lists response struct

* chore: added better metrics existence check

* chore: added a TODO remark

* chore: added required metrics check

* chore: distributed samples table to local table change for get metadata

* chore: frontend fix

* chore: endpoint correction

* chore: endpoint modification openapi

* chore: escape backtick to prevent sql injection

* chore: rearrage

* chore: improvements

* chore: validate order by to validate function

* chore: improved description

* chore: added TODOs and made filterByStatus a part of filter struct

* chore: ignore empty string hosts in get active hosts

* feat(infra-monitoring): v2 hosts list - return counts of active & inactive hosts for custom group by attributes (#10956)

* chore: add functionality for showing active and inactive counts in custom group by

* chore: bug fix

* chore: added subquery for active and total count

* chore: ignore empty string hosts in get active hosts

* fix: sinceUnixMilli for determining active hosts compute once per request

* chore: refactor code

* chore: rename HostsList -> ListHosts

* chore: rearrangement

* chore: inframonitoring types renaming

* chore: added types package

* chore: file structure further breakdown for clarity

* chore: comments correction

* chore: removed temporalities

* chore: pods code restructuring

* chore: comments resolve

* chore: added json tag required: true

* chore: removed pod metric temporalities

* chore: removed internal server error

* chore: added status unauthorized

* chore: remove a defensive nil map check, the function ensure non-nil map when err nil

* chore: cleanup and rename

* chore: make sort stable in case of tiebreaker by comparing composite group by keys

* chore: regen api client for inframonitoring

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* chore: added phase counts feature

* chore: added queries for pod phase counts in custom group by

* chore: added required tags

* chore: added support for pod phase unknown

* chore: removed pods - order by phase

* chore: improved api description to document -1 as no data in numeric fields

* fix: rebase fixes

* chore: added unknown phase count

* fix: isPodUIDInGroupBy in buildPodRecords

* chore: 3 cte --> 2 cte

* chore: pod phase with local table of time series as counts

* chore: comment correction

* chore: corrected comment

* chore: value column for samples table added

* chore: removed query G for phase counts

* chore: rename variable

* chore: added PodPhaseNum constants to types

* feat(infra-monitoring): v2 pods list apis - phase counts when custom grouping (#11088)

* chore: added phase counts feature

* chore: added queries for pod phase counts in custom group by

* chore: added unknown phase count

* fix: isPodUIDInGroupBy in buildPodRecords

* chore: 3 cte --> 2 cte

* chore: pod phase with local table of time series as counts

* chore: comment correction

* chore: corrected comment

* chore: value column for samples table added

* chore: removed query G for phase counts

* chore: rename variable

* chore: added PodPhaseNum constants to types

* chore: nodes list v2 full blown

* chore: metadata fix

* chore: updated comment

* chore: namespaces code

* chore: v2 nodes api

* chore: rename

* chore: v2 clusters list api

* chore: namespaces code

* chore: rename

* chore: review clusters PR

* chore: added pod phase counts

* chore: for pods and nodes, replace none with no_data

* chore: node and pod counts structs added

* chore: namespace record uses PodCountsByPhase

* chore: cluster record uses PodCountsByPhase, NodeCountsByReadiness

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: Ashwin Bhatkal <ashwin96@gmail.com>
2026-05-11 12:10:42 +00:00
SagarRajput-7
85a8611f2e feat(authz): enable multi role assignment for service account (#11231)
* feat(sa): made service account role selection multiselect, handling multiple api calls parallely

* feat(authz): enable multi role assignemnt for service accounts

* feat(authz): update integration tests

---------

Co-authored-by: vikrantgupta25 <vikrant@signoz.io>
2026-05-11 11:49:32 +00:00
72 changed files with 2413 additions and 340 deletions

View File

@@ -2520,6 +2520,65 @@ components:
enabled:
type: boolean
type: object
InframonitoringtypesClusterRecord:
properties:
clusterCPU:
format: double
type: number
clusterCPUAllocatable:
format: double
type: number
clusterMemory:
format: double
type: number
clusterMemoryAllocatable:
format: double
type: number
clusterName:
type: string
meta:
additionalProperties:
type: string
nullable: true
type: object
nodeCountsByReadiness:
$ref: '#/components/schemas/InframonitoringtypesNodeCountsByReadiness'
podCountsByPhase:
$ref: '#/components/schemas/InframonitoringtypesPodCountsByPhase'
required:
- clusterName
- clusterCPU
- clusterCPUAllocatable
- clusterMemory
- clusterMemoryAllocatable
- nodeCountsByReadiness
- podCountsByPhase
- meta
type: object
InframonitoringtypesClusters:
properties:
endTimeBeforeRetention:
type: boolean
records:
items:
$ref: '#/components/schemas/InframonitoringtypesClusterRecord'
nullable: true
type: array
requiredMetricsCheck:
$ref: '#/components/schemas/InframonitoringtypesRequiredMetricsCheck'
total:
type: integer
type:
$ref: '#/components/schemas/InframonitoringtypesResponseType'
warning:
$ref: '#/components/schemas/Querybuildertypesv5QueryWarnData'
required:
- type
- records
- total
- requiredMetricsCheck
- endTimeBeforeRetention
type: object
InframonitoringtypesHostFilter:
properties:
expression:
@@ -2824,6 +2883,32 @@ components:
- requiredMetricsCheck
- endTimeBeforeRetention
type: object
InframonitoringtypesPostableClusters:
properties:
end:
format: int64
type: integer
filter:
$ref: '#/components/schemas/Querybuildertypesv5Filter'
groupBy:
items:
$ref: '#/components/schemas/Querybuildertypesv5GroupByKey'
nullable: true
type: array
limit:
type: integer
offset:
type: integer
orderBy:
$ref: '#/components/schemas/Querybuildertypesv5OrderBy'
start:
format: int64
type: integer
required:
- start
- end
- limit
type: object
InframonitoringtypesPostableHosts:
properties:
end:
@@ -11746,6 +11831,77 @@ paths:
summary: Health check
tags:
- health
/api/v2/infra_monitoring/clusters:
post:
deprecated: false
description: 'Returns a paginated list of Kubernetes clusters with key aggregated
metrics derived by summing per-node values within the group: CPU usage, CPU
allocatable, memory working set, memory allocatable. Each row also reports
per-group nodeCountsByReadiness ({ ready, notReady } from each node''s latest
k8s.node.condition_ready value) and per-group podCountsByPhase ({ pending,
running, succeeded, failed, unknown } from each pod''s latest k8s.pod.phase
value). Each cluster includes metadata attributes (k8s.cluster.name). The
response type is ''list'' for the default k8s.cluster.name grouping or ''grouped_list''
for custom groupBy keys; in both modes every row aggregates nodes and pods
in the group. Supports filtering via a filter expression, custom groupBy,
ordering by cpu / cpu_allocatable / memory / memory_allocatable, and pagination
via offset/limit. Also reports missing required metrics and whether the requested
time range falls before the data retention boundary. Numeric metric fields
(clusterCPU, clusterCPUAllocatable, clusterMemory, clusterMemoryAllocatable)
return -1 as a sentinel when no data is available for that field.'
operationId: ListClusters
requestBody:
content:
application/json:
schema:
$ref: '#/components/schemas/InframonitoringtypesPostableClusters'
responses:
"200":
content:
application/json:
schema:
properties:
data:
$ref: '#/components/schemas/InframonitoringtypesClusters'
status:
type: string
required:
- status
- data
type: object
description: OK
"400":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Bad Request
"401":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Unauthorized
"403":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Forbidden
"500":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Internal Server Error
security:
- api_key:
- VIEWER
- tokenizer:
- VIEWER
summary: List Clusters for Infra Monitoring
tags:
- inframonitoring
/api/v2/infra_monitoring/hosts:
post:
deprecated: false

View File

@@ -50,7 +50,7 @@
"@signozhq/design-tokens": "2.1.4",
"@signozhq/icons": "0.1.0",
"@signozhq/resizable": "0.0.2",
"@signozhq/ui": "0.0.18",
"@signozhq/ui": "0.0.19",
"@tanstack/react-table": "8.21.3",
"@tanstack/react-virtual": "3.13.22",
"@uiw/codemirror-theme-copilot": "4.23.11",

View File

@@ -12,10 +12,12 @@ import type {
} from 'react-query';
import type {
InframonitoringtypesPostableClustersDTO,
InframonitoringtypesPostableHostsDTO,
InframonitoringtypesPostableNamespacesDTO,
InframonitoringtypesPostableNodesDTO,
InframonitoringtypesPostablePodsDTO,
ListClusters200,
ListHosts200,
ListNamespaces200,
ListNodes200,
@@ -26,6 +28,90 @@ import type {
import { GeneratedAPIInstance } from '../../../generatedAPIInstance';
import type { ErrorType, BodyType } from '../../../generatedAPIInstance';
/**
* Returns a paginated list of Kubernetes clusters with key aggregated metrics derived by summing per-node values within the group: CPU usage, CPU allocatable, memory working set, memory allocatable. Each row also reports per-group nodeCountsByReadiness ({ ready, notReady } from each node's latest k8s.node.condition_ready value) and per-group podCountsByPhase ({ pending, running, succeeded, failed, unknown } from each pod's latest k8s.pod.phase value). Each cluster includes metadata attributes (k8s.cluster.name). The response type is 'list' for the default k8s.cluster.name grouping or 'grouped_list' for custom groupBy keys; in both modes every row aggregates nodes and pods in the group. Supports filtering via a filter expression, custom groupBy, ordering by cpu / cpu_allocatable / memory / memory_allocatable, and pagination via offset/limit. Also reports missing required metrics and whether the requested time range falls before the data retention boundary. Numeric metric fields (clusterCPU, clusterCPUAllocatable, clusterMemory, clusterMemoryAllocatable) return -1 as a sentinel when no data is available for that field.
* @summary List Clusters for Infra Monitoring
*/
export const listClusters = (
inframonitoringtypesPostableClustersDTO: BodyType<InframonitoringtypesPostableClustersDTO>,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<ListClusters200>({
url: `/api/v2/infra_monitoring/clusters`,
method: 'POST',
headers: { 'Content-Type': 'application/json' },
data: inframonitoringtypesPostableClustersDTO,
signal,
});
};
export const getListClustersMutationOptions = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof listClusters>>,
TError,
{ data: BodyType<InframonitoringtypesPostableClustersDTO> },
TContext
>;
}): UseMutationOptions<
Awaited<ReturnType<typeof listClusters>>,
TError,
{ data: BodyType<InframonitoringtypesPostableClustersDTO> },
TContext
> => {
const mutationKey = ['listClusters'];
const { mutation: mutationOptions } = options
? options.mutation &&
'mutationKey' in options.mutation &&
options.mutation.mutationKey
? options
: { ...options, mutation: { ...options.mutation, mutationKey } }
: { mutation: { mutationKey } };
const mutationFn: MutationFunction<
Awaited<ReturnType<typeof listClusters>>,
{ data: BodyType<InframonitoringtypesPostableClustersDTO> }
> = (props) => {
const { data } = props ?? {};
return listClusters(data);
};
return { mutationFn, ...mutationOptions };
};
export type ListClustersMutationResult = NonNullable<
Awaited<ReturnType<typeof listClusters>>
>;
export type ListClustersMutationBody =
BodyType<InframonitoringtypesPostableClustersDTO>;
export type ListClustersMutationError = ErrorType<RenderErrorResponseDTO>;
/**
* @summary List Clusters for Infra Monitoring
*/
export const useListClusters = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof listClusters>>,
TError,
{ data: BodyType<InframonitoringtypesPostableClustersDTO> },
TContext
>;
}): UseMutationResult<
Awaited<ReturnType<typeof listClusters>>,
TError,
{ data: BodyType<InframonitoringtypesPostableClustersDTO> },
TContext
> => {
const mutationOptions = getListClustersMutationOptions(options);
return useMutation(mutationOptions);
};
/**
* Returns a paginated list of hosts with key infrastructure metrics: CPU usage (%), memory usage (%), I/O wait (%), disk usage (%), and 15-minute load average. Each host includes its current status (active/inactive based on metrics reported in the last 10 minutes) and metadata attributes (e.g., os.type). Supports filtering via a filter expression, filtering by host status, custom groupBy to aggregate hosts by any attribute, ordering by any of the five metrics, and pagination via offset/limit. The response type is 'list' for the default host.name grouping or 'grouped_list' for custom groupBy keys. Also reports missing required metrics and whether the requested time range falls before the data retention boundary. Numeric metric fields (cpu, memory, wait, load15, diskUsage) return -1 as a sentinel when no data is available for that field.
* @summary List Hosts for Infra Monitoring

View File

@@ -4568,6 +4568,66 @@ export interface GlobaltypesTokenizerConfigDTO {
enabled?: boolean;
}
/**
* @nullable
*/
export type InframonitoringtypesClusterRecordDTOMeta = {
[key: string]: string;
} | null;
export interface InframonitoringtypesClusterRecordDTO {
/**
* @type number
* @format double
*/
clusterCPU: number;
/**
* @type number
* @format double
*/
clusterCPUAllocatable: number;
/**
* @type number
* @format double
*/
clusterMemory: number;
/**
* @type number
* @format double
*/
clusterMemoryAllocatable: number;
/**
* @type string
*/
clusterName: string;
/**
* @type object
* @nullable true
*/
meta: InframonitoringtypesClusterRecordDTOMeta;
nodeCountsByReadiness: InframonitoringtypesNodeCountsByReadinessDTO;
podCountsByPhase: InframonitoringtypesPodCountsByPhaseDTO;
}
export interface InframonitoringtypesClustersDTO {
/**
* @type boolean
*/
endTimeBeforeRetention: boolean;
/**
* @type array
* @nullable true
*/
records: InframonitoringtypesClusterRecordDTO[] | null;
requiredMetricsCheck: InframonitoringtypesRequiredMetricsCheckDTO;
/**
* @type integer
*/
total: number;
type: InframonitoringtypesResponseTypeDTO;
warning?: Querybuildertypesv5QueryWarnDataDTO;
}
export interface InframonitoringtypesHostFilterDTO {
/**
* @type string
@@ -4885,6 +4945,34 @@ export interface InframonitoringtypesPodsDTO {
warning?: Querybuildertypesv5QueryWarnDataDTO;
}
export interface InframonitoringtypesPostableClustersDTO {
/**
* @type integer
* @format int64
*/
end: number;
filter?: Querybuildertypesv5FilterDTO;
/**
* @type array
* @nullable true
*/
groupBy?: Querybuildertypesv5GroupByKeyDTO[] | null;
/**
* @type integer
*/
limit: number;
/**
* @type integer
*/
offset?: number;
orderBy?: Querybuildertypesv5OrderByDTO;
/**
* @type integer
* @format int64
*/
start: number;
}
export interface InframonitoringtypesPostableHostsDTO {
/**
* @type integer
@@ -9324,6 +9412,14 @@ export type Healthz503 = {
status: string;
};
export type ListClusters200 = {
data: InframonitoringtypesClustersDTO;
/**
* @type string
*/
status: string;
};
export type ListHosts200 = {
data: InframonitoringtypesHostsDTO;
/**

View File

@@ -0,0 +1,36 @@
.labelColumn {
display: flex;
gap: 4px;
align-items: center;
overflow-x: auto;
max-width: 100%;
}
.labelBadge {
cursor: default;
font-size: 12px;
--badge-display: inline;
max-width: 180px;
min-width: 100px;
text-overflow: ellipsis;
}
.labelPopover {
display: flex;
flex-direction: column;
gap: 6px;
padding: 8px;
max-height: 300px;
overflow-y: auto;
}
.labelBadgePopover {
font-size: 12px;
}
.labelValue {
text-overflow: ellipsis;
overflow: hidden;
}

View File

@@ -0,0 +1,89 @@
import { TooltipProvider } from '@signozhq/ui/tooltip';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import LabelColumn from './LabelColumn';
function renderWithProviders(
ui: React.ReactElement,
): ReturnType<typeof render> {
return render(<TooltipProvider>{ui}</TooltipProvider>);
}
describe('LabelColumn', () => {
it('should render all labels when 5 or fewer', () => {
const labels = ['env', 'service', 'region'];
renderWithProviders(<LabelColumn labels={labels} />);
expect(screen.getByTestId('label-tag-env')).toBeInTheDocument();
expect(screen.getByTestId('label-tag-service')).toBeInTheDocument();
expect(screen.getByTestId('label-tag-region')).toBeInTheDocument();
});
it('should truncate labels and show +N badge when more than 5 labels', () => {
const labels = ['env', 'service', 'region', 'team', 'owner', 'version'];
renderWithProviders(<LabelColumn labels={labels} />);
// First 3 visible
expect(screen.getByTestId('label-tag-env')).toBeInTheDocument();
expect(screen.getByTestId('label-tag-service')).toBeInTheDocument();
expect(screen.getByTestId('label-tag-region')).toBeInTheDocument();
// +3 badge for remaining
expect(screen.getByTestId('label-overflow-badge')).toHaveTextContent('+3');
});
it('should render label with value when value prop provided', () => {
const labels = ['env'];
const value = { env: 'production' };
renderWithProviders(<LabelColumn labels={labels} value={value} />);
expect(screen.getByTestId('label-tag-env')).toHaveTextContent(
'env: production',
);
});
it('should render labels without value when value is not provided for that label', () => {
const labels = ['env', 'service'];
const value = { env: 'production' };
renderWithProviders(<LabelColumn labels={labels} value={value} />);
expect(screen.getByTestId('label-tag-env')).toHaveTextContent(
'env: production',
);
expect(screen.getByTestId('label-tag-service')).toHaveTextContent('service');
});
it('should show popover with all labels when clicking +N badge', async () => {
const user = userEvent.setup();
const labels = ['env', 'service', 'region', 'team', 'owner', 'version'];
renderWithProviders(<LabelColumn labels={labels} />);
await user.click(screen.getByTestId('label-overflow-badge'));
// All labels should appear in popover
expect(screen.getByTestId('label-popover')).toBeInTheDocument();
expect(screen.getByTestId('label-popover-item-env')).toBeInTheDocument();
expect(screen.getByTestId('label-popover-item-version')).toBeInTheDocument();
});
it('should render empty when no labels provided', () => {
renderWithProviders(<LabelColumn labels={[]} />);
const column = screen.getByTestId('label-column');
expect(column.children).toHaveLength(0);
});
it('should use primary color by default', () => {
const labels = ['env'];
renderWithProviders(<LabelColumn labels={labels} />);
expect(screen.getByTestId('label-tag-env')).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,123 @@
import {
Badge,
Popover,
PopoverContent,
PopoverTrigger,
TooltipSimple,
} from '@signozhq/ui';
import styles from './LabelColumn.module.scss';
export interface LabelColumnProps {
labels: string[];
color?:
| 'primary'
| 'secondary'
| 'success'
| 'error'
| 'warning'
| 'robin'
| 'forest'
| 'amber'
| 'sienna'
| 'cherry'
| 'sakura'
| 'aqua'
| 'vanilla';
value?: { [key: string]: string };
}
function getLabelRenderingValue(label: string, value?: string): JSX.Element {
const title = value ? `${label}: ${value}` : label;
const content = value ? `${label}: ${value}` : label;
return (
<span title={title} className={styles.labelValue}>
{content}
</span>
);
}
function getLabelAndValueContent(label: string, value?: string): string {
return value ? `${label}: ${value}` : label;
}
function LabelTag({
label,
value,
color,
}: {
label: string;
color?: LabelColumnProps['color'];
value?: LabelColumnProps['value'];
}): JSX.Element {
const tooltipTitle = value?.[label] ? `${label}: ${value[label]}` : label;
return (
<TooltipSimple title={tooltipTitle}>
<Badge
color={color}
className={styles.labelBadge}
variant="outline"
data-testid={`label-tag-${label}`}
>
{getLabelRenderingValue(label, value?.[label])}
</Badge>
</TooltipSimple>
);
}
const MAX_LABELS_TO_DISPLAY = 5;
function LabelColumn({
labels,
value,
color = 'primary',
}: LabelColumnProps): JSX.Element {
const visibleLabels =
labels.length > MAX_LABELS_TO_DISPLAY ? labels.slice(0, 3) : labels;
const remainingLabels =
labels.length > MAX_LABELS_TO_DISPLAY ? labels.slice(3) : [];
return (
<div className={styles.labelColumn} data-testid="label-column">
{visibleLabels.map((label) => (
<LabelTag key={label} label={label} color={color} value={value} />
))}
{remainingLabels.length > 0 && (
<Popover>
<PopoverTrigger asChild>
<Badge
color={color}
className={styles.labelBadge}
variant="outline"
data-testid="label-overflow-badge"
>
+{remainingLabels.length}
</Badge>
</PopoverTrigger>
<PopoverContent
side="bottom"
align="end"
className={styles.labelPopover}
data-testid="label-popover"
>
{labels.map((label) => (
<Badge
key={label}
color={color}
className={styles.labelBadgePopover}
variant="outline"
data-testid={`label-popover-item-${label}`}
>
{getLabelAndValueContent(label, value?.[label])}
</Badge>
))}
</PopoverContent>
</Popover>
)}
</div>
);
}
export default LabelColumn;

View File

@@ -0,0 +1,2 @@
export { default } from './LabelColumn';
export type { LabelColumnProps } from './LabelColumn';

View File

@@ -0,0 +1,4 @@
.lastUpdated {
font-size: 12px;
color: var(--l2-foreground);
}

View File

@@ -0,0 +1,105 @@
import { render, screen, act } from '@testing-library/react';
import LastUpdatedText from './LastUpdatedText';
describe('LastUpdatedText', () => {
beforeEach(() => {
jest.useFakeTimers();
});
afterEach(() => {
jest.useRealTimers();
});
it('should return null when updatedAt is null', () => {
const { container } = render(<LastUpdatedText updatedAt={null} />);
expect(container.firstChild).toBeNull();
});
it('should render formatted time distance', () => {
const now = Date.now();
const fiveMinutesAgo = now - 5 * 60 * 1000;
jest.setSystemTime(now);
render(<LastUpdatedText updatedAt={fiveMinutesAgo} />);
expect(screen.getByTestId('last-updated-text')).toHaveTextContent(
/Updated.*5 minutes ago/,
);
});
it('should have title with ISO formatted date', () => {
const now = Date.now();
const fiveMinutesAgo = now - 5 * 60 * 1000;
jest.setSystemTime(now);
render(<LastUpdatedText updatedAt={fiveMinutesAgo} />);
expect(screen.getByTestId('last-updated-text').title).toMatch(
/^\d{4}-\d{2}-\d{2}/,
);
});
it('should update text periodically', () => {
const now = Date.now();
jest.setSystemTime(now);
render(<LastUpdatedText updatedAt={now} />);
expect(screen.getByTestId('last-updated-text')).toHaveTextContent(
/Updated.*less than a minute ago/,
);
act(() => {
jest.advanceTimersByTime(61000);
});
expect(screen.getByTestId('last-updated-text')).toHaveTextContent(
/Updated.*1 minute ago/,
);
});
it('should cleanup interval on unmount', () => {
const clearIntervalSpy = jest.spyOn(global, 'clearInterval');
const now = Date.now();
jest.setSystemTime(now);
const { unmount } = render(<LastUpdatedText updatedAt={now} />);
unmount();
expect(clearIntervalSpy).toHaveBeenCalled();
clearIntervalSpy.mockRestore();
});
it('should render with recent timestamp', () => {
const now = Date.now();
const tenSecondsAgo = now - 10 * 1000;
jest.setSystemTime(now);
render(<LastUpdatedText updatedAt={tenSecondsAgo} />);
expect(screen.getByTestId('last-updated-text')).toHaveTextContent(
/Updated.*less than a minute ago/,
);
});
it('should render with hour-old timestamp', () => {
const now = Date.now();
const oneHourAgo = now - 60 * 60 * 1000;
jest.setSystemTime(now);
render(<LastUpdatedText updatedAt={oneHourAgo} />);
expect(screen.getByTestId('last-updated-text')).toHaveTextContent(
/Updated.*1 hour ago/,
);
});
});

View File

@@ -0,0 +1,63 @@
import { memo, useEffect, useMemo, useRef, useState } from 'react';
import { formatDistanceToNow, formatISO } from 'date-fns';
import styles from './LastUpdatedText.module.scss';
interface LastUpdatedTextProps {
updatedAt: number | null;
}
const LastUpdatedText = memo(function LastUpdatedText({
updatedAt,
}: LastUpdatedTextProps): JSX.Element | null {
const [text, setText] = useState('');
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
const lastUpdatedAtDate = useMemo(() => {
if (!updatedAt) {
return '-';
}
try {
return formatISO(updatedAt);
} catch (e) {
console.error(e);
return 'Failed to parse date.';
}
}, [updatedAt]);
useEffect(() => {
if (!updatedAt) {
setText('');
return;
}
const updateText = (): void => {
setText(formatDistanceToNow(updatedAt, { addSuffix: true }));
};
updateText();
intervalRef.current = setInterval(updateText, 1000);
return (): void => {
if (intervalRef.current) {
clearInterval(intervalRef.current);
}
};
}, [updatedAt]);
if (!text) {
return null;
}
return (
<span
className={styles.lastUpdated}
title={lastUpdatedAtDate}
data-testid="last-updated-text"
>
Updated {text}
</span>
);
});
export default LastUpdatedText;

View File

@@ -0,0 +1 @@
export { default } from './LastUpdatedText';

View File

@@ -0,0 +1,50 @@
.statCard {
display: flex;
flex-direction: column;
align-items: flex-start;
justify-content: center;
gap: var(--spacing-1);
padding: var(--spacing-4) var(--spacing-7);
background: var(--l2-background);
border-radius: 4px;
border: 1px solid var(--l1-border);
min-width: 80px;
height: 58px;
box-sizing: border-box;
transition:
border-color 0.15s ease,
background-color 0.15s ease;
font-family: inherit;
text-align: left;
margin: 0;
-webkit-appearance: none;
appearance: none;
}
.statCardClickable {
cursor: pointer;
&:hover {
border-color: var(--l2-foreground);
}
}
.statCardActive {
border-color: var(--primary);
background: color-mix(in srgb, var(--primary) 10%, var(--l2-background));
}
.statLabel {
font-size: 11px;
font-weight: 500;
color: var(--l2-foreground);
text-transform: uppercase;
letter-spacing: 0.02em;
}
.statValue {
font-size: 18px;
font-weight: 600;
color: var(--l1-foreground);
line-height: 1.2;
}

View File

@@ -0,0 +1,101 @@
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import StatCard from './StatCard';
describe('StatCard', () => {
it('should render label and value', () => {
render(<StatCard label="Firing" value={5} />);
expect(screen.getByTestId('stat-card-label')).toHaveTextContent('Firing');
expect(screen.getByTestId('stat-card-value')).toHaveTextContent('5');
});
it('should apply custom color to value', () => {
render(<StatCard label="Firing" value={5} color="red" />);
expect(screen.getByTestId('stat-card-value')).toHaveStyle({ color: 'red' });
});
it('should not have button role when onClick is not provided', () => {
render(<StatCard label="Firing" value={5} />);
expect(screen.queryByRole('button')).not.toBeInTheDocument();
});
it('should have button role when onClick is provided', () => {
const onClick = jest.fn();
render(<StatCard label="Firing" value={5} onClick={onClick} />);
expect(screen.getByRole('button')).toBeInTheDocument();
});
it('should call onClick with exclusive: false on regular click', async () => {
const user = userEvent.setup();
const onClick = jest.fn();
render(<StatCard label="Firing" value={5} onClick={onClick} />);
await user.click(screen.getByTestId('stat-card'));
expect(onClick).toHaveBeenCalledWith({ exclusive: false });
});
it('should call onClick with exclusive: true on alt+click', async () => {
const user = userEvent.setup();
const onClick = jest.fn();
render(<StatCard label="Firing" value={5} onClick={onClick} />);
await user.keyboard('{Alt>}');
await user.click(screen.getByTestId('stat-card'));
await user.keyboard('{/Alt}');
expect(onClick).toHaveBeenCalledWith({ exclusive: true });
});
it('should call onClick on Enter key press', async () => {
const user = userEvent.setup();
const onClick = jest.fn();
render(<StatCard label="Firing" value={5} onClick={onClick} />);
const card = screen.getByTestId('stat-card');
card.focus();
await user.keyboard('{Enter}');
expect(onClick).toHaveBeenCalledWith({ exclusive: false });
});
it('should call onClick on Space key press', async () => {
const user = userEvent.setup();
const onClick = jest.fn();
render(<StatCard label="Firing" value={5} onClick={onClick} />);
const card = screen.getByTestId('stat-card');
card.focus();
await user.keyboard(' ');
expect(onClick).toHaveBeenCalledWith({ exclusive: false });
});
it('should be focusable when onClick is provided', () => {
render(<StatCard label="Firing" value={5} onClick={jest.fn()} />);
expect(screen.getByTestId('stat-card')).toHaveAttribute('tabindex', '0');
});
it('should not be focusable when onClick is not provided', () => {
render(<StatCard label="Firing" value={5} />);
expect(screen.getByTestId('stat-card')).not.toHaveAttribute('tabindex');
});
it('should not have color style when color prop is not provided', () => {
render(<StatCard label="Firing" value={5} />);
expect(screen.getByTestId('stat-card-value')).not.toHaveAttribute('style');
});
});

View File

@@ -0,0 +1,66 @@
import styles from './StatCard.module.scss';
export interface StatCardClickEvent {
exclusive: boolean;
}
interface StatCardProps {
label: string;
value: number;
color?: string;
onClick?: (event: StatCardClickEvent) => void;
isActive?: boolean;
}
function StatCard({
label,
value,
color,
onClick,
isActive,
}: StatCardProps): JSX.Element {
const cardClassName = [
styles.statCard,
onClick && styles.statCardClickable,
isActive && styles.statCardActive,
]
.filter(Boolean)
.join(' ');
const handleClick = (e: React.MouseEvent): void => {
if (onClick) {
onClick({ exclusive: e.altKey });
}
};
const handleKeyDown = (e: React.KeyboardEvent): void => {
if (onClick && (e.key === 'Enter' || e.key === ' ')) {
e.preventDefault();
onClick({ exclusive: e.altKey });
}
};
return (
<div
className={cardClassName}
onClick={onClick ? handleClick : undefined}
onKeyDown={onClick ? handleKeyDown : undefined}
role={onClick ? 'button' : undefined}
tabIndex={onClick ? 0 : undefined}
data-testid="stat-card"
>
<span className={styles.statLabel} data-testid="stat-card-label">
{label}
</span>
<span
className={styles.statValue}
style={color ? { color } : undefined}
data-testid="stat-card-value"
>
{value}
</span>
</div>
);
}
export default StatCard;

View File

@@ -0,0 +1,2 @@
export { default } from './StatCard';
export type { StatCardClickEvent } from './StatCard';

View File

@@ -0,0 +1,87 @@
import {
STATE_ORDER,
SEVERITY_ORDER,
STATE_LABELS,
STATE_COLORS,
SEVERITY_COLORS,
} from './constants';
describe('Alerts constants', () => {
describe('STATE_ORDER', () => {
it('should have correct order of states', () => {
expect(STATE_ORDER).toStrictEqual([
'firing',
'pending',
'inactive',
'disabled',
]);
});
it('should have firing as highest priority', () => {
expect(STATE_ORDER[0]).toBe('firing');
});
});
describe('SEVERITY_ORDER', () => {
it('should have correct order of severities', () => {
expect(SEVERITY_ORDER).toStrictEqual([
'critical',
'error',
'warning',
'info',
]);
});
it('should have critical as highest priority', () => {
expect(SEVERITY_ORDER[0]).toBe('critical');
});
});
describe('STATE_LABELS', () => {
it('should map firing to Firing', () => {
expect(STATE_LABELS.firing).toBe('Firing');
});
it('should map pending to Pending', () => {
expect(STATE_LABELS.pending).toBe('Pending');
});
it('should map inactive to OK', () => {
expect(STATE_LABELS.inactive).toBe('OK');
});
it('should map disabled to Disabled', () => {
expect(STATE_LABELS.disabled).toBe('Disabled');
});
});
describe('STATE_COLORS', () => {
it('should have colors for all states', () => {
expect(STATE_COLORS).toHaveProperty('firing');
expect(STATE_COLORS).toHaveProperty('pending');
expect(STATE_COLORS).toHaveProperty('inactive');
expect(STATE_COLORS).toHaveProperty('disabled');
});
it('should use CSS variables for colors', () => {
Object.values(STATE_COLORS).forEach((color) => {
expect(color).toMatch(/^var\(--/);
});
});
});
describe('SEVERITY_COLORS', () => {
it('should have colors for all severities', () => {
expect(SEVERITY_COLORS).toHaveProperty('critical');
expect(SEVERITY_COLORS).toHaveProperty('error');
expect(SEVERITY_COLORS).toHaveProperty('warning');
expect(SEVERITY_COLORS).toHaveProperty('info');
});
it('should use CSS variables for colors', () => {
Object.values(SEVERITY_COLORS).forEach((color) => {
expect(color).toMatch(/^var\(--/);
});
});
});
});

View File

@@ -0,0 +1,23 @@
export const STATE_ORDER = ['firing', 'pending', 'inactive', 'disabled'];
export const SEVERITY_ORDER = ['critical', 'error', 'warning', 'info'];
export const STATE_LABELS: Record<string, string> = {
firing: 'Firing',
pending: 'Pending',
inactive: 'OK',
disabled: 'Disabled',
};
export const STATE_COLORS: Record<string, string> = {
firing: 'var(--bg-cherry-500)',
pending: 'var(--bg-amber-500)',
inactive: 'var(--bg-forest-500)',
disabled: 'var(--l2-foreground)',
};
export const SEVERITY_COLORS: Record<string, string> = {
critical: 'var(--bg-cherry-500)',
error: 'var(--bg-cherry-400)',
warning: 'var(--bg-amber-500)',
info: 'var(--bg-robin-500)',
};

View File

@@ -0,0 +1,12 @@
export { default as StatCard } from './StatCard';
export type { StatCardClickEvent } from './StatCard';
export { default as LastUpdatedText } from './LastUpdatedText';
export { default as LabelColumn } from './LabelColumn';
export type { LabelColumnProps } from './LabelColumn';
export {
STATE_ORDER,
SEVERITY_ORDER,
STATE_LABELS,
STATE_COLORS,
SEVERITY_COLORS,
} from './constants';

View File

@@ -2,8 +2,9 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useMutation } from 'react-query';
// eslint-disable-next-line no-restricted-imports
import { useSelector } from 'react-redux';
import { LoadingOutlined, SearchOutlined } from '@ant-design/icons';
import { SearchOutlined } from '@ant-design/icons';
import { Color } from '@signozhq/design-tokens';
import { Loader } from '@signozhq/icons';
import {
Button,
Input,
@@ -516,7 +517,9 @@ export default function CeleryOverviewTable({
bordered={false}
loading={{
spinning: isLoading,
indicator: <Spin indicator={<LoadingOutlined size={14} spin />} />,
indicator: (
<Spin indicator={<Loader size={14} className="animate-spin" />} />
),
}}
locale={{
emptyText: isLoading ? null : <Typography.Text>No data</Typography.Text>,

View File

@@ -1,7 +1,8 @@
import { Typography } from '@signozhq/ui/typography';
import { ReactNode, useEffect, useState } from 'react';
import { useHistory } from 'react-router-dom';
import { CaretDownOutlined, LoadingOutlined } from '@ant-design/icons';
import { CaretDownOutlined } from '@ant-design/icons';
import { Loader } from '@signozhq/icons';
import { Modal, Select, Spin, Tooltip, Tree, TreeDataNode } from 'antd';
import { OnboardingStatusResponse } from 'api/messagingQueues/onboarding/getOnboardingStatus';
import { QueryParams } from 'constants/query';
@@ -222,7 +223,7 @@ function AttributeCheckList({
>
{loading ? (
<div className="loader-container">
<Spin indicator={<LoadingOutlined spin />} size="large" />
<Spin indicator={<Loader className="animate-spin" />} size="large" />
</div>
) : (
<div className="modal-content">

View File

@@ -7,12 +7,9 @@ import React, {
useState,
} from 'react';
import { Virtuoso } from 'react-virtuoso';
import {
DownOutlined,
LoadingOutlined,
ReloadOutlined,
} from '@ant-design/icons';
import { DownOutlined, ReloadOutlined } from '@ant-design/icons';
import { Color } from '@signozhq/design-tokens';
import { Loader } from '@signozhq/icons';
import { Button, Checkbox, Select } from 'antd';
import { Typography } from '@signozhq/ui/typography';
import cx from 'classnames';
@@ -1700,7 +1697,7 @@ const CustomMultiSelect: React.FC<CustomMultiSelectProps> = ({
{loading && (
<div className="navigation-loading">
<div className="navigation-icons">
<LoadingOutlined />
<Loader className="animate-spin" />
</div>
<div className="navigation-text">Refreshing values...</div>
</div>
@@ -1708,7 +1705,7 @@ const CustomMultiSelect: React.FC<CustomMultiSelectProps> = ({
{!loading && waitingMessage && (
<div className="navigation-loading">
<div className="navigation-icons">
<LoadingOutlined />
<Loader className="animate-spin" />
</div>
<div className="navigation-text" title={waitingMessage}>
{waitingMessage}

View File

@@ -6,13 +6,9 @@ import React, {
useRef,
useState,
} from 'react';
import {
CloseOutlined,
DownOutlined,
LoadingOutlined,
ReloadOutlined,
} from '@ant-design/icons';
import { CloseOutlined, DownOutlined, ReloadOutlined } from '@ant-design/icons';
import { Color } from '@signozhq/design-tokens';
import { Loader } from '@signozhq/icons';
import { Select } from 'antd';
import cx from 'classnames';
import TextToolTip from 'components/TextToolTip';
@@ -581,7 +577,7 @@ const CustomSelect: React.FC<CustomSelectProps> = ({
{loading && (
<div className="navigation-loading">
<div className="navigation-icons">
<LoadingOutlined />
<Loader className="animate-spin" />
</div>
<div className="navigation-text">Refreshing values...</div>
</div>
@@ -589,7 +585,7 @@ const CustomSelect: React.FC<CustomSelectProps> = ({
{!loading && waitingMessage && (
<div className="navigation-loading">
<div className="navigation-icons">
<LoadingOutlined />
<Loader className="animate-spin" />
</div>
<div className="navigation-text" title={waitingMessage}>
{waitingMessage}

View File

@@ -16,8 +16,8 @@ interface OverviewTabProps {
account: ServiceAccountRow;
localName: string;
onNameChange: (v: string) => void;
localRole: string;
onRoleChange: (v: string | undefined) => void;
localRoles: string[];
onRolesChange: (v: string[]) => void;
isDisabled: boolean;
availableRoles: AuthtypesRoleDTO[];
rolesLoading?: boolean;
@@ -31,8 +31,8 @@ function OverviewTab({
account,
localName,
onNameChange,
localRole,
onRoleChange,
localRoles,
onRolesChange,
isDisabled,
availableRoles,
rolesLoading,
@@ -95,10 +95,15 @@ function OverviewTab({
{isDisabled ? (
<div className="sa-drawer__input-wrapper sa-drawer__input-wrapper--disabled">
<div className="sa-drawer__disabled-roles">
{localRole ? (
<Badge color="vanilla">
{availableRoles.find((r) => r.id === localRole)?.name ?? localRole}
</Badge>
{localRoles.length > 0 ? (
localRoles.map((roleId) => {
const role = availableRoles.find((r) => r.id === roleId);
return (
<Badge key={roleId} color="vanilla">
{role?.name ?? roleId}
</Badge>
);
})
) : (
<span className="sa-drawer__input-text"></span>
)}
@@ -108,14 +113,15 @@ function OverviewTab({
) : (
<RolesSelect
id="sa-roles"
mode="multiple"
roles={availableRoles}
loading={rolesLoading}
isError={rolesError}
error={rolesErrorObj}
onRefetch={onRefetchRoles}
value={localRole}
onChange={onRoleChange}
placeholder="Select role"
value={localRoles}
onChange={onRolesChange}
placeholder="Select roles"
/>
)}
</div>

View File

@@ -8,9 +8,7 @@ import { ToggleGroup, ToggleGroupItem } from '@signozhq/ui/toggle-group';
import { Pagination, Skeleton } from 'antd';
import { convertToApiError } from 'api/ErrorResponseHandlerForGeneratedAPIs';
import {
getGetServiceAccountRolesQueryKey,
getListServiceAccountsQueryKey,
useDeleteServiceAccountRole,
useGetServiceAccount,
useListServiceAccountKeys,
useUpdateServiceAccount,
@@ -37,7 +35,7 @@ import {
useQueryState,
} from 'nuqs';
import APIError from 'types/api/error';
import { retryOn429, toAPIError } from 'utils/errorUtils';
import { toAPIError } from 'utils/errorUtils';
import AddKeyModal from './AddKeyModal';
import DeleteAccountModal from './DeleteAccountModal';
@@ -92,7 +90,7 @@ function ServiceAccountDrawer({
parseAsBoolean.withDefault(false),
);
const [localName, setLocalName] = useState('');
const [localRole, setLocalRole] = useState('');
const [localRoles, setLocalRoles] = useState<string[]>([]);
const [isSaving, setIsSaving] = useState(false);
const [saveErrors, setSaveErrors] = useState<SaveError[]>([]);
@@ -140,7 +138,7 @@ function ServiceAccountDrawer({
if (!account?.id) {
roleSessionRef.current = null;
} else if (account.id !== roleSessionRef.current && !isRolesLoading) {
setLocalRole(currentRoles[0]?.id ?? '');
setLocalRoles(currentRoles.map((r) => r.id).filter(Boolean) as string[]);
roleSessionRef.current = account.id;
}
}, [account?.id, currentRoles, isRolesLoading]);
@@ -151,7 +149,13 @@ function ServiceAccountDrawer({
const isDirty =
account !== null &&
(localName !== (account.name ?? '') ||
localRole !== (currentRoles[0]?.id ?? ''));
JSON.stringify([...localRoles].sort()) !==
JSON.stringify(
currentRoles
.map((r) => r.id)
.filter(Boolean)
.sort(),
));
const {
roles: availableRoles,
@@ -179,27 +183,6 @@ function ServiceAccountDrawer({
// the retry for this mutation is safe due to the api being idempotent on backend
const { mutateAsync: updateMutateAsync } = useUpdateServiceAccount();
const { mutateAsync: deleteRole } = useDeleteServiceAccountRole({
mutation: {
retry: retryOn429,
},
});
const executeRolesOperation = useCallback(
async (accountId: string): Promise<RoleUpdateFailure[]> => {
if (localRole === '' && currentRoles[0]?.id) {
await deleteRole({
pathParams: { id: accountId, rid: currentRoles[0].id },
});
await queryClient.invalidateQueries(
getGetServiceAccountRolesQueryKey({ id: accountId }),
);
return [];
}
return applyDiff([localRole].filter(Boolean), availableRoles);
},
[localRole, currentRoles, availableRoles, applyDiff, deleteRole, queryClient],
);
const retryNameUpdate = useCallback(async (): Promise<void> => {
if (!account) {
@@ -267,7 +250,7 @@ function ServiceAccountDrawer({
const retryRolesUpdate = useCallback(async (): Promise<void> => {
try {
const failures = await executeRolesOperation(selectedAccountId ?? '');
const failures = await applyDiff([...localRoles], availableRoles);
if (failures.length === 0) {
setSaveErrors((prev) => prev.filter((e) => e.context !== 'Roles update'));
} else {
@@ -283,7 +266,7 @@ function ServiceAccountDrawer({
),
);
}
}, [selectedAccountId, executeRolesOperation, failuresToSaveErrors]);
}, [localRoles, availableRoles, applyDiff, failuresToSaveErrors]);
const handleSave = useCallback(async (): Promise<void> => {
if (!account || !isDirty) {
@@ -302,7 +285,7 @@ function ServiceAccountDrawer({
const [nameResult, rolesResult] = await Promise.allSettled([
namePromise,
executeRolesOperation(account.id),
applyDiff([...localRoles], availableRoles),
]);
const errors: SaveError[] = [];
@@ -343,8 +326,10 @@ function ServiceAccountDrawer({
account,
isDirty,
localName,
localRoles,
availableRoles,
updateMutateAsync,
executeRolesOperation,
applyDiff,
refetchAccount,
onSuccess,
queryClient,
@@ -443,9 +428,9 @@ function ServiceAccountDrawer({
account={account}
localName={localName}
onNameChange={handleNameChange}
localRole={localRole}
onRoleChange={(role): void => {
setLocalRole(role ?? '');
localRoles={localRoles}
onRolesChange={(roles): void => {
setLocalRoles(roles);
clearRoleErrors();
}}
isDisabled={isDeleted}

View File

@@ -151,7 +151,7 @@ describe('ServiceAccountDrawer', () => {
});
});
it('changing roles enables Save; clicking Save sends role add request without delete', async () => {
it('adding a role fires POST for the new role and no DELETE for existing roles', async () => {
const roleSpy = jest.fn();
const deleteSpy = jest.fn();
const user = userEvent.setup({ pointerEventsCheck: 0 });
@@ -171,6 +171,7 @@ describe('ServiceAccountDrawer', () => {
await screen.findByDisplayValue('CI Bot');
// Add signoz-viewer while keeping signoz-admin selected
await user.click(screen.getByLabelText('Roles'));
await user.click(await screen.findByTitle('signoz-viewer'));
@@ -188,6 +189,43 @@ describe('ServiceAccountDrawer', () => {
});
});
it('removing a role fires DELETE for the removed role and no POST', async () => {
const roleSpy = jest.fn();
const deleteSpy = jest.fn();
const user = userEvent.setup({ pointerEventsCheck: 0 });
server.use(
rest.post(SA_ROLES_ENDPOINT, async (req, res, ctx) => {
roleSpy(await req.json());
return res(ctx.status(200), ctx.json({ status: 'success', data: {} }));
}),
rest.delete(SA_ROLE_DELETE_ENDPOINT, (_, res, ctx) => {
deleteSpy();
return res(ctx.status(200), ctx.json({ status: 'success', data: {} }));
}),
);
renderDrawer();
await screen.findByDisplayValue('CI Bot');
// Remove the signoz-admin tag from the multi-select
const adminTag = await screen.findByTitle('signoz-admin');
const removeBtn = adminTag.querySelector(
'.ant-select-selection-item-remove',
) as Element;
await user.click(removeBtn);
const saveBtn = screen.getByRole('button', { name: /Save Changes/i });
await waitFor(() => expect(saveBtn).not.toBeDisabled());
await user.click(saveBtn);
await waitFor(() => {
expect(deleteSpy).toHaveBeenCalled();
expect(roleSpy).not.toHaveBeenCalled();
});
});
it('"Delete Service Account" opens confirm dialog; confirming sends delete request', async () => {
const deleteSpy = jest.fn();
const user = userEvent.setup({ pointerEventsCheck: 0 });

View File

@@ -1,5 +1,5 @@
import { CSSProperties } from 'react';
import { LoadingOutlined } from '@ant-design/icons';
import { Loader } from '@signozhq/icons';
import { Spin, SpinProps } from 'antd';
import { SpinerStyle } from './styles';
@@ -7,7 +7,14 @@ import { SpinerStyle } from './styles';
function Spinner({ size, tip, height, style }: SpinnerProps): JSX.Element {
return (
<SpinerStyle height={height} style={style}>
<Spin spinning size={size} tip={tip} indicator={<LoadingOutlined spin />} />
<Spin
spinning
size={size}
tip={tip}
indicator={
<Loader className="animate-spin" role="img" aria-label="loading" />
}
/>
</SpinerStyle>
);
}

View File

@@ -10,7 +10,7 @@ import {
} from 'react';
import type { TableComponents } from 'react-virtuoso';
import { TableVirtuoso, TableVirtuosoHandle } from 'react-virtuoso';
import { LoadingOutlined } from '@ant-design/icons';
import { Loader } from '@signozhq/icons';
import { DndContext, pointerWithin } from '@dnd-kit/core';
import {
horizontalListSortingStrategy,
@@ -39,6 +39,7 @@ import {
} from './TanStackTableStateContext';
import {
FlatItem,
SortState,
TableRowContext,
TanStackTableHandle,
TanStackTableProps,
@@ -100,6 +101,7 @@ function TanStackTableInner<TData>(
onRowClick,
onRowClickNewTab,
onRowDeactivate,
onSort,
activeRowIndex,
renderExpandedRow,
getRowCanExpand,
@@ -127,10 +129,10 @@ function TanStackTableInner<TData>(
const {
page,
limit,
setPage,
setLimit,
setPage: internalSetPage,
setLimit: internalSetLimit,
orderBy,
setOrderBy,
setOrderBy: internalSetOrderBy,
expanded,
setExpanded,
} = useTableParams(enableQueryParams, {
@@ -138,6 +140,30 @@ function TanStackTableInner<TData>(
limit: pagination?.defaultLimit,
});
const setPage = useCallback(
(p: number) => {
internalSetPage(p);
pagination?.onPageChange?.(p);
},
[internalSetPage, pagination],
);
const setLimit = useCallback(
(l: number) => {
internalSetLimit(l);
pagination?.onLimitChange?.(l);
},
[internalSetLimit, pagination],
);
const setOrderBy = useCallback(
(sort: SortState | null) => {
internalSetOrderBy(sort);
onSort?.(sort);
},
[internalSetOrderBy, onSort],
);
const isGrouped = (groupBy?.length ?? 0) > 0;
const {
@@ -580,7 +606,10 @@ function TanStackTableInner<TData>(
className={viewStyles.tanstackLoadingOverlay}
data-testid="tanstack-infinite-loader"
>
<Spin indicator={<LoadingOutlined spin />} tip="Loading more..." />
<Spin
indicator={<Loader className="animate-spin" />}
tip="Loading more..."
/>
</div>
)}
{showPagination && pagination && (
@@ -604,14 +633,16 @@ function TanStackTableInner<TData>(
setPage(p);
}}
/>
<div className={viewStyles.paginationPageSize}>
<ComboboxSimple
value={limit?.toString()}
defaultValue="10"
onChange={(value): void => setLimit(+value)}
items={paginationPageSizeItems}
/>
</div>
{(pagination.showPageSize ?? true) && (
<div className={viewStyles.paginationPageSize}>
<ComboboxSimple
value={limit?.toString()}
defaultValue="10"
onChange={(value): void => setLimit(+value)}
items={paginationPageSizeItems}
/>
</div>
)}
{suffixPaginationContent}
</div>
)}

View File

@@ -117,6 +117,10 @@ export type PaginationProps = {
defaultLimit?: number;
showTotalCount?: boolean;
totalCountLabel?: string;
/** @default true */
showPageSize?: boolean;
onPageChange?: (page: number) => void;
onLimitChange?: (limit: number) => void;
};
export type TanstackTableQueryParamsConfig = {
@@ -160,6 +164,8 @@ export type TanStackTableProps<TData> = {
/** Called when ctrl+click or cmd+click on a row */
onRowClickNewTab?: (row: TData, itemKey: string) => void;
onRowDeactivate?: () => void;
/** Called when sort state changes */
onSort?: (sort: SortState | null) => void;
activeRowIndex?: number;
renderExpandedRow?: (
row: TData,

View File

@@ -1,6 +1,6 @@
import { useMemo, useState } from 'react';
import { QueryFunctionContext, useQueries, useQuery } from 'react-query';
import { LoadingOutlined } from '@ant-design/icons';
import { Loader } from '@signozhq/icons';
import { Spin, Switch, Table, Tooltip } from 'antd';
import { Typography } from '@signozhq/ui/typography';
import { getQueryRangeV5 } from 'api/v5/queryRange/getQueryRange';
@@ -202,7 +202,9 @@ function TopErrors({
columns={topErrorsColumnsConfig}
loading={{
spinning: isLoading || isRefetching,
indicator: <Spin indicator={<LoadingOutlined size={14} spin />} />,
indicator: (
<Spin indicator={<Loader size={14} className="animate-spin" />} />
),
}}
dataSource={isLoading || isRefetching ? [] : formattedTopErrorsData}
locale={{

View File

@@ -2,7 +2,7 @@ import { useCallback, useEffect, useMemo, useState } from 'react';
import { useQueryClient } from 'react-query';
// eslint-disable-next-line no-restricted-imports
import { useSelector } from 'react-redux';
import { LoadingOutlined } from '@ant-design/icons';
import { Loader } from '@signozhq/icons';
import { Spin, Table } from 'antd';
import logEvent from 'api/common/logEvent';
import emptyStateUrl from 'assets/Icons/emptyState.svg';
@@ -205,7 +205,9 @@ function DomainList(): JSX.Element {
columns={columnsConfig}
loading={{
spinning: isFetching || isLoading,
indicator: <Spin indicator={<LoadingOutlined size={14} spin />} />,
indicator: (
<Spin indicator={<Loader size={14} className="animate-spin" />} />
),
}}
scroll={{ x: true }}
tableLayout="fixed"

View File

@@ -2,9 +2,8 @@ import { Fragment, useCallback, useEffect, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { UseQueryResult } from 'react-query';
import { useInterval } from 'react-use';
import { LoadingOutlined } from '@ant-design/icons';
import { Compass, ScrollText } from '@signozhq/icons';
import { Button } from '@signozhq/ui/button';
import { Button } from '@signozhq/ui';
import { Compass, Loader, ScrollText } from '@signozhq/icons';
import { Modal, Spin } from 'antd';
import setRetentionApi from 'api/settings/setRetention';
import setRetentionApiV2 from 'api/settings/setRetentionV2';
@@ -480,7 +479,11 @@ function GeneralSettings({
saveButtonText:
metricsTtlValuesPayload.status === 'pending' ? (
<span>
<Spin spinning size="small" indicator={<LoadingOutlined spin />} />{' '}
<Spin
spinning
size="small"
indicator={<Loader className="animate-spin" />}
/>{' '}
{t('retention_save_button.pending', { name: 'metrics' })}
</span>
) : (
@@ -523,7 +526,11 @@ function GeneralSettings({
saveButtonText:
tracesTtlValuesPayload.status === 'pending' ? (
<span>
<Spin spinning size="small" indicator={<LoadingOutlined spin />} />{' '}
<Spin
spinning
size="small"
indicator={<Loader className="animate-spin" />}
/>{' '}
{t('retention_save_button.pending', { name: 'traces' })}
</span>
) : (
@@ -565,7 +572,11 @@ function GeneralSettings({
saveButtonText:
logsTtlValuesPayload.status === 'pending' ? (
<span>
<Spin spinning size="small" indicator={<LoadingOutlined spin />} />{' '}
<Spin
spinning
size="small"
indicator={<Loader className="animate-spin" />}
/>{' '}
{t('retention_save_button.pending', { name: 'logs' })}
</span>
) : (

View File

@@ -9,11 +9,8 @@ import React, {
import { useQueryClient } from 'react-query';
// eslint-disable-next-line no-restricted-imports
import { useSelector } from 'react-redux'; // old code, TODO: fix this correctly
import {
LoadingOutlined,
SearchOutlined,
SyncOutlined,
} from '@ant-design/icons';
import { SearchOutlined, SyncOutlined } from '@ant-design/icons';
import { Loader } from '@signozhq/icons';
import { Button, Input, Spin } from 'antd';
import cx from 'classnames';
import { ToggleGraphProps } from 'components/Graph/types';
@@ -338,7 +335,7 @@ function FullView({
)}
<div className="time-container">
{response.isFetching && (
<Spin spinning indicator={<LoadingOutlined spin />} />
<Spin spinning indicator={<Loader className="animate-spin" />} />
)}
<TimePreference
selectedTime={selectedTime}

View File

@@ -1,6 +1,6 @@
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { LoadingOutlined } from '@ant-design/icons';
import { LoaderCircle } from '@signozhq/icons';
import { Button, Input, Space } from 'antd';
import { Typography } from '@signozhq/ui/typography';
import logEvent from 'api/common/logEvent';
@@ -79,7 +79,7 @@ export function RequestDashboardBtn(): JSX.Element {
className="periscope-btn primary"
icon={
isSubmittingRequestForDashboard ? (
<LoadingOutlined />
<LoaderCircle className="animate-spin" size={12} />
) : (
<Check size={12} />
)

View File

@@ -1,6 +1,6 @@
import React, { useMemo } from 'react';
import { LoadingOutlined } from '@ant-design/icons';
import { Color } from '@signozhq/design-tokens';
import { LoaderCircle } from '@signozhq/icons';
import { Spin } from 'antd';
import { CircleCheck } from 'lucide-react';
@@ -21,7 +21,13 @@ export default function QueryStatus(
const content = useMemo((): React.ReactElement => {
if (loading) {
return <Spin spinning size="small" indicator={<LoadingOutlined spin />} />;
return (
<Spin
spinning
size="small"
indicator={<LoaderCircle className="animate-spin" />}
/>
);
}
if (error) {
return (

View File

@@ -1,5 +1,5 @@
import { ReactNode, useCallback, useMemo, useState } from 'react';
import { LoadingOutlined } from '@ant-design/icons';
import { Loader } from '@signozhq/icons';
import { Button, Popover, Spin } from 'antd';
import { Typography } from '@signozhq/ui/typography';
import { useIsDarkMode } from 'hooks/useDarkMode';
@@ -32,7 +32,13 @@ function FieldItem({
const renderContent = useMemo(() => {
if (isLoading) {
return <Spin spinning size="small" indicator={<LoadingOutlined spin />} />;
return (
<Spin
spinning
size="small"
indicator={<Loader className="animate-spin" />}
/>
);
}
if (isHovered) {

View File

@@ -1,5 +1,5 @@
import { Button } from '@signozhq/ui/button';
import { Tooltip, TooltipProvider } from '@signozhq/ui/tooltip';
import { TooltipSimple } from '@signozhq/ui';
import { Copy } from '@signozhq/icons';
import './CopyIconButton.styles.scss';
@@ -19,22 +19,20 @@ function CopyIconButton({
: 'Copy to clipboard';
return (
<TooltipProvider>
<Tooltip title={tooltipTitle}>
<span>
<Button
color="secondary"
variant="ghost"
size="icon"
aria-label={ariaLabel}
disabled={disabled}
className="mcp-copy-btn"
prefix={<Copy size={14} />}
onClick={onCopy}
/>
</span>
</Tooltip>
</TooltipProvider>
<TooltipSimple title={tooltipTitle}>
<span>
<Button
color="secondary"
variant="ghost"
size="icon"
aria-label={ariaLabel}
disabled={disabled}
className="mcp-copy-btn"
prefix={<Copy size={14} />}
onClick={onCopy}
/>
</span>
</TooltipSimple>
);
}

View File

@@ -1,5 +1,5 @@
import { useCallback } from 'react';
import { LoadingOutlined } from '@ant-design/icons';
import { Loader } from '@signozhq/icons';
import { Spin, Table, TablePaginationConfig, TableProps, Tooltip } from 'antd';
import { Typography } from '@signozhq/ui/typography';
import type { SorterResult } from 'antd/es/table/interface';
@@ -79,7 +79,7 @@ function MetricsTable({
indicator: (
<Spin
data-testid="metrics-table-loading-state"
indicator={<LoadingOutlined size={14} spin />}
indicator={<Loader size={14} className="animate-spin" />}
/>
),
}}

View File

@@ -1,11 +1,8 @@
import { useEffect, useMemo, useState } from 'react';
// eslint-disable-next-line no-restricted-imports
import { useDispatch, useSelector } from 'react-redux';
import {
CheckCircleTwoTone,
CloseCircleTwoTone,
LoadingOutlined,
} from '@ant-design/icons';
import { CheckCircleTwoTone, CloseCircleTwoTone } from '@ant-design/icons';
import { LoaderCircle } from '@signozhq/icons';
import logEvent from 'api/common/logEvent';
import MessagingQueueHealthCheck from 'components/MessagingQueueHealthCheck/MessagingQueueHealthCheck';
import { QueryParams } from 'constants/query';
@@ -341,7 +338,7 @@ export default function ConnectionStatus(): JSX.Element {
<div className="label"> Status </div>
<div className="status">
{isQueryServiceLoading && <LoadingOutlined />}
{isQueryServiceLoading && <LoaderCircle className="animate-spin" />}
{!isQueryServiceLoading &&
isReceivingData &&
(getStartedSource !== 'kafka' ? (

View File

@@ -1,6 +1,6 @@
import React, { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { LoadingOutlined } from '@ant-design/icons';
import { LoaderCircle } from '@signozhq/icons';
import { Button, Card, Form, Input, Select, Space } from 'antd';
import { Typography } from '@signozhq/ui/typography';
import logEvent from 'api/common/logEvent';
@@ -272,7 +272,7 @@ export default function DataSource(): JSX.Element {
className="periscope-btn primary"
icon={
isSubmittingRequestForDataSource ? (
<LoadingOutlined />
<LoaderCircle className="animate-spin" />
) : (
<Check size={12} />
)

View File

@@ -1,6 +1,6 @@
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { LoadingOutlined } from '@ant-design/icons';
import { LoaderCircle } from '@signozhq/icons';
import { Button, Card, Form, Input, Space } from 'antd';
import { Typography } from '@signozhq/ui/typography';
import logEvent from 'api/common/logEvent';
@@ -185,7 +185,7 @@ export default function EnvironmentDetails(): JSX.Element {
className="periscope-btn primary"
icon={
isSubmittingRequestForEnvironment ? (
<LoadingOutlined />
<LoaderCircle className="animate-spin" />
) : (
<Check size={12} />
)

View File

@@ -1,9 +1,6 @@
import { useEffect, useState } from 'react';
import {
CheckCircleTwoTone,
CloseCircleTwoTone,
LoadingOutlined,
} from '@ant-design/icons';
import { CheckCircleTwoTone, CloseCircleTwoTone } from '@ant-design/icons';
import { LoaderCircle } from '@signozhq/icons';
import logEvent from 'api/common/logEvent';
import { DEFAULT_ENTITY_VERSION } from 'constants/app';
import { PANEL_TYPES } from 'constants/queryBuilder';
@@ -245,7 +242,7 @@ export default function LogsConnectionStatus(): JSX.Element {
<div className="label"> Status </div>
<div className="status">
{(loading || isFetching) && <LoadingOutlined />}
{(loading || isFetching) && <LoaderCircle className="animate-spin" />}
{!(loading || isFetching) && isReceivingData && (
<>
<CheckCircleTwoTone twoToneColor="#52c41a" />

View File

@@ -2,9 +2,9 @@ import {
CheckCircleFilled,
CloseCircleFilled,
ExclamationCircleFilled,
LoadingOutlined,
MinusCircleFilled,
} from '@ant-design/icons';
import { Loader } from '@signozhq/icons';
import { Spin } from 'antd';
export function getDeploymentStage(value: string): string {
@@ -28,7 +28,17 @@ export function getDeploymentStageIcon(value: string): JSX.Element {
switch (value) {
case 'in_progress':
return (
<Spin indicator={<LoadingOutlined style={{ fontSize: 15 }} spin />} />
<Spin
indicator={
<Loader
size="large"
className="animate-spin"
role="img"
aria-label="loading"
data-icon="loading"
/>
}
/>
);
case 'deployed':
return <CheckCircleFilled />;

View File

@@ -1,6 +1,7 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useLocation } from 'react-router-dom';
import { LinkOutlined, LoadingOutlined } from '@ant-design/icons';
import { LinkOutlined } from '@ant-design/icons';
import { Loader } from '@signozhq/icons';
import OverlayScrollbar from 'components/OverlayScrollbar/OverlayScrollbar';
import { QueryParams } from 'constants/query';
import { PANEL_TYPES } from 'constants/queryBuilder';
@@ -230,7 +231,7 @@ const useBaseAggregateOptions = ({
key={key}
icon={
isLoading ? (
<LoadingOutlined spin />
<Loader className="animate-spin" />
) : (
<span style={{ color: aggregateData?.seriesColor }}>{icon}</span>
)

View File

@@ -1,5 +1,5 @@
import { ChangeEvent, useEffect, useMemo, useState } from 'react';
import { LoadingOutlined } from '@ant-design/icons';
import { Loader } from '@signozhq/icons';
import { Button, Input, Spin } from 'antd';
import cx from 'classnames';
import OverlayScrollbar from 'components/OverlayScrollbar/OverlayScrollbar';
@@ -215,7 +215,7 @@ function AddSpanToFunnelModal({
<Spin
className="add-span-to-funnel-modal__loading-spinner"
spinning={isFunnelDetailsLoading || isFunnelDetailsFetching}
indicator={<LoadingOutlined spin />}
indicator={<Loader className="animate-spin" />}
>
{selectedFunnelId && funnelDetails?.payload && (
<FunnelProvider

View File

@@ -1,6 +1,7 @@
import { useCallback, useState } from 'react';
import { useHistory, useLocation } from 'react-router-dom';
import { InfoCircleOutlined, LoadingOutlined } from '@ant-design/icons';
import { InfoCircleOutlined } from '@ant-design/icons';
import { Loader } from '@signozhq/icons';
import { Button, Spin, Tooltip } from 'antd';
import { Typography } from '@signozhq/ui/typography';
import { AxiosError } from 'axios';
@@ -188,7 +189,9 @@ function Filters({
/>
</div>
)}
{isFetching && <Spin indicator={<LoadingOutlined spin />} size="small" />}
{isFetching && (
<Spin indicator={<Loader className="animate-spin" />} size="small" />
)}
{error && (
<Tooltip title={(error as AxiosError)?.message || 'Something went wrong'}>
<InfoCircleOutlined size={14} />

View File

@@ -3,6 +3,7 @@ import { useQueryClient } from 'react-query';
import {
getGetServiceAccountRolesQueryKey,
useCreateServiceAccountRole,
useDeleteServiceAccountRole,
useGetServiceAccountRoles,
} from 'api/generated/services/serviceaccount';
import type { AuthtypesRoleDTO } from 'api/generated/services/sigNoz.schemas';
@@ -44,6 +45,9 @@ export function useServiceAccountRoleManager(
const { mutateAsync: createRole } = useCreateServiceAccountRole({
mutation: { retry: retryOn429 },
});
const { mutateAsync: deleteRole } = useDeleteServiceAccountRole({
mutation: { retry: retryOn429 },
});
const invalidateRoles = useCallback(
() =>
@@ -68,14 +72,21 @@ export function useServiceAccountRoleManager(
const addedRoles = availableRoles.filter(
(r) => r.id && desiredRoleIds.has(r.id) && !currentRoleIds.has(r.id),
);
const removedRoles = currentRoles.filter(
(r) => r.id && !desiredRoleIds.has(r.id),
);
// TODO: re-enable deletes once BE for this is streamlined
const allOperations = [
...addedRoles.map((role) => ({
role,
run: (): ReturnType<typeof createRole> =>
createRole({ pathParams: { id: accountId }, data: { id: role.id } }),
})),
...removedRoles.map((role) => ({
role,
run: (): ReturnType<typeof deleteRole> =>
deleteRole({ pathParams: { id: accountId, rid: role.id ?? '' } }),
})),
];
const results = await Promise.allSettled(
@@ -106,7 +117,7 @@ export function useServiceAccountRoleManager(
return failures;
},
[accountId, currentRoles, createRole, invalidateRoles],
[accountId, currentRoles, createRole, deleteRole, invalidateRoles],
);
return {

View File

@@ -6,12 +6,7 @@ import {
TabsRoot,
TabsTrigger,
} from '@signozhq/ui/tabs';
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from '@signozhq/ui/tooltip';
import { TooltipSimple } from '@signozhq/ui';
import {
Bookmark,
CalendarClock,
@@ -497,27 +492,22 @@ function SpanDetailsPanel({
actions.push({
key: 'dock-toggle',
component: (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
color="secondary"
onClick={(): void =>
onVariantChange(
isDocked ? SpanDetailVariant.DIALOG : SpanDetailVariant.DOCKED,
)
}
>
{isDocked ? <Dock size={14} /> : <PanelBottom size={14} />}
</Button>
</TooltipTrigger>
<TooltipContent className="dock-toggle-tooltip">
{isDocked ? 'Open as floating panel' : 'Dock at the bottom'}
</TooltipContent>
</Tooltip>
</TooltipProvider>
<TooltipSimple
title={isDocked ? 'Open as floating panel' : 'Dock at the bottom'}
>
<Button
variant="ghost"
size="icon"
color="secondary"
onClick={(): void =>
onVariantChange(
isDocked ? SpanDetailVariant.DIALOG : SpanDetailVariant.DOCKED,
)
}
>
{isDocked ? <Dock size={14} /> : <PanelBottom size={14} />}
</Button>
</TooltipSimple>
),
});
}

View File

@@ -25,7 +25,7 @@ describe('computeVisualLayout', () => {
it('should handle empty input', () => {
const layout = computeVisualLayout([]);
expect(layout.totalVisualRows).toBe(0);
expect(layout.visualRows).toEqual([]);
expect(layout.visualRows).toStrictEqual([]);
});
it('should handle single root, no children — 1 visual row', () => {
@@ -36,7 +36,7 @@ describe('computeVisualLayout', () => {
});
const layout = computeVisualLayout([[root]]);
expect(layout.totalVisualRows).toBe(1);
expect(layout.visualRows[0]).toEqual([root]);
expect(layout.visualRows[0]).toStrictEqual([root]);
expect(layout.spanToVisualRow['root']).toBe(0);
});

View File

@@ -1,10 +1,5 @@
import { Button } from '@signozhq/ui/button';
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from '@signozhq/ui/tooltip';
import { TooltipSimple } from '@signozhq/ui';
import { useCopySpanLink } from 'hooks/trace/useCopySpanLink';
import { Link } from 'lucide-react';
import { Span } from 'types/api/trace/getTraceV2';
@@ -21,24 +16,17 @@ export default function SpanLineActionButtons({
return (
<div className="span-line-action-buttons">
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
color="secondary"
onClick={onSpanCopy}
className="copy-span-btn"
>
<Link size={14} />
</Button>
</TooltipTrigger>
<TooltipContent className="span-line-action-tooltip">
Copy Span Link
</TooltipContent>
</Tooltip>
</TooltipProvider>
<TooltipSimple title="Copy Span Link">
<Button
variant="ghost"
size="icon"
color="secondary"
onClick={onSpanCopy}
className="copy-span-btn"
>
<Link size={14} />
</Button>
</TooltipSimple>
</div>
);
}

View File

@@ -11,12 +11,7 @@ import {
} from 'react';
import { Badge } from '@signozhq/ui/badge';
import { Button } from '@signozhq/ui/button';
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from '@signozhq/ui/tooltip';
import { TooltipSimple } from '@signozhq/ui';
import {
createColumnHelper,
flexRender,
@@ -109,26 +104,24 @@ const LazyEventDotPopover = memo(function LazyEventDotPopover({
const eventTimeMs = event.timeUnixNano / 1e6;
return (
<TooltipProvider>
<Tooltip
open
onOpenChange={(open): void => {
if (!open) {
setShowPopover(false);
}
}}
>
<TooltipTrigger asChild>{dot}</TooltipTrigger>
<TooltipContent className="span-hover-card-popover">
<EventTooltipContent
eventName={event.name}
timeOffsetMs={eventTimeMs - spanTimestamp}
isError={isError}
attributeMap={event.attributeMap || {}}
/>
</TooltipContent>
</Tooltip>
</TooltipProvider>
<TooltipSimple
open
onOpenChange={(open: boolean): void => {
if (!open) {
setShowPopover(false);
}
}}
title={
<EventTooltipContent
eventName={event.name}
timeOffsetMs={eventTimeMs - spanTimestamp}
isError={isError}
attributeMap={event.attributeMap || {}}
/>
}
>
{dot}
</TooltipSimple>
);
});
@@ -323,40 +316,28 @@ const SpanOverview = memo(function SpanOverview({
{/* Action buttons — shown on hover via CSS, right-aligned */}
<span className="span-row-actions">
<TooltipProvider delayDuration={200}>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
color="secondary"
className="span-action-btn"
onClick={onSpanCopy}
>
<Link size={12} />
</Button>
</TooltipTrigger>
<TooltipContent className="span-action-tooltip">
Copy Span Link
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
color="secondary"
className="span-action-btn"
onClick={handleFunnelClick}
>
<ListPlus size={12} />
</Button>
</TooltipTrigger>
<TooltipContent className="span-action-tooltip">
Add to Trace Funnel
</TooltipContent>
</Tooltip>
</TooltipProvider>
<TooltipSimple title="Copy Span Link">
<Button
variant="ghost"
size="icon"
color="secondary"
className="span-action-btn"
onClick={onSpanCopy}
>
<Link size={12} />
</Button>
</TooltipSimple>
<TooltipSimple title="Add to Trace Funnel">
<Button
variant="ghost"
size="icon"
color="secondary"
className="span-action-btn"
onClick={handleFunnelClick}
>
<ListPlus size={12} />
</Button>
</TooltipSimple>
</span>
</div>
</SpanHoverCard>

View File

@@ -44,12 +44,12 @@ function makeSpan(
describe('getVisibleSpans', () => {
it('returns empty array for empty input', () => {
expect(getVisibleSpans([], new Set())).toEqual([]);
expect(getVisibleSpans([], new Set())).toStrictEqual([]);
});
it('returns single root span with no children', () => {
const spans = [makeSpan({ span_id: 'root', level: 0 })];
expect(getVisibleSpans(spans, new Set())).toEqual(spans);
expect(getVisibleSpans(spans, new Set())).toStrictEqual(spans);
});
it('returns all spans for flat tree (all level 0, no children)', () => {
@@ -58,7 +58,7 @@ describe('getVisibleSpans', () => {
makeSpan({ span_id: 'b', level: 0 }),
makeSpan({ span_id: 'c', level: 0 }),
];
expect(getVisibleSpans(spans, new Set())).toEqual(spans);
expect(getVisibleSpans(spans, new Set())).toStrictEqual(spans);
});
it('returns all spans when all parents are expanded', () => {
@@ -68,7 +68,7 @@ describe('getVisibleSpans', () => {
makeSpan({ span_id: 'b', level: 1 }),
];
const uncollapsed = new Set(['root']);
expect(getVisibleSpans(spans, uncollapsed)).toEqual(spans);
expect(getVisibleSpans(spans, uncollapsed)).toStrictEqual(spans);
});
it('hides children when root is collapsed', () => {
@@ -99,7 +99,7 @@ describe('getVisibleSpans', () => {
// root and A expanded, but A is collapsed
const uncollapsed = new Set(['root']); // A not in set → collapsed
const result = getVisibleSpans(spans, uncollapsed);
expect(result.map((s) => s.span_id)).toEqual(['root', 'A']);
expect(result.map((s) => s.span_id)).toStrictEqual(['root', 'A']);
});
it('collapses one subtree while sibling subtree stays visible', () => {
@@ -116,7 +116,7 @@ describe('getVisibleSpans', () => {
// root and childB expanded, childA collapsed
const uncollapsed = new Set(['root', 'childB']);
const result = getVisibleSpans(spans, uncollapsed);
expect(result.map((s) => s.span_id)).toEqual([
expect(result.map((s) => s.span_id)).toStrictEqual([
'root',
'childA', // visible but collapsed
'childB', // visible and expanded
@@ -134,11 +134,11 @@ describe('getVisibleSpans', () => {
// Collapsed
const collapsed = getVisibleSpans(spans, new Set());
expect(collapsed.map((s) => s.span_id)).toEqual(['root']);
expect(collapsed.map((s) => s.span_id)).toStrictEqual(['root']);
// Uncollapsed
const uncollapsed = getVisibleSpans(spans, new Set(['root']));
expect(uncollapsed.map((s) => s.span_id)).toEqual(['root', 'a', 'b']);
expect(uncollapsed.map((s) => s.span_id)).toStrictEqual(['root', 'a', 'b']);
});
it('hides all levels below collapsed span in deeply nested tree', () => {
@@ -154,7 +154,7 @@ describe('getVisibleSpans', () => {
// Expand L0 and L1, collapse L2
const uncollapsed = new Set(['L0', 'L1']);
const result = getVisibleSpans(spans, uncollapsed);
expect(result.map((s) => s.span_id)).toEqual(['L0', 'L1', 'L2']);
expect(result.map((s) => s.span_id)).toStrictEqual(['L0', 'L1', 'L2']);
});
it('shows only root-level spans when all parents are collapsed', () => {
@@ -166,7 +166,7 @@ describe('getVisibleSpans', () => {
];
const uncollapsed = new Set<string>(); // nothing expanded
const result = getVisibleSpans(spans, uncollapsed);
expect(result.map((s) => s.span_id)).toEqual(['root1', 'root2']);
expect(result.map((s) => s.span_id)).toStrictEqual(['root1', 'root2']);
});
it('leaf span in uncollapsed set has no effect', () => {
@@ -177,7 +177,7 @@ describe('getVisibleSpans', () => {
// leaf is in uncollapsed set but has_children=false, should make no difference
const uncollapsed = new Set(['root', 'leaf']);
const result = getVisibleSpans(spans, uncollapsed);
expect(result).toEqual(spans);
expect(result).toStrictEqual(spans);
});
it('handles 10k spans within 50ms', () => {
@@ -218,7 +218,13 @@ describe('getVisibleSpans', () => {
// root and C expanded; A and B collapsed
const uncollapsed = new Set(['root', 'C']);
const result = getVisibleSpans(spans, uncollapsed);
expect(result.map((s) => s.span_id)).toEqual(['root', 'A', 'B', 'C', 'C1']);
expect(result.map((s) => s.span_id)).toStrictEqual([
'root',
'A',
'B',
'C',
'C1',
]);
});
});
@@ -234,7 +240,7 @@ describe('getAncestorSpanIds', () => {
makeSpan({ span_id: 'child', level: 1, parent_span_id: 'root' }),
];
const ancestors = getAncestorSpanIds(spans, 'child');
expect(ancestors).toEqual(new Set(['root']));
expect(ancestors).toStrictEqual(new Set(['root']));
});
it('returns all ancestors for deeply nested span', () => {
@@ -255,7 +261,7 @@ describe('getAncestorSpanIds', () => {
makeSpan({ span_id: 'L3', level: 3, parent_span_id: 'L2' }),
];
const ancestors = getAncestorSpanIds(spans, 'L3');
expect(ancestors).toEqual(new Set(['L2', 'L1', 'L0']));
expect(ancestors).toStrictEqual(new Set(['L2', 'L1', 'L0']));
});
it('returns empty set for unknown span', () => {

View File

@@ -1,5 +1,5 @@
import { useCallback, useMemo, useState } from 'react';
import { LoadingOutlined } from '@ant-design/icons';
import { Loader } from '@signozhq/icons';
import { Empty, Spin } from 'antd';
import {
BarController,
@@ -97,7 +97,7 @@ function FunnelGraph(): JSX.Element {
}
return (
<Spin spinning={isFetching} indicator={<LoadingOutlined spin />}>
<Spin spinning={isFetching} indicator={<Loader className="animate-spin" />}>
<div className={cx('funnel-graph', `funnel-graph--${totalSteps}-columns`)}>
<div className="funnel-graph__chart-container">
<canvas ref={canvasRef} />

View File

@@ -54,9 +54,23 @@ describe('globalTimeStore', () => {
expect(result.current.lastRefreshTimestamp).toBe(0);
});
it('should have lastComputedMinMax with default values', () => {
it('should have lastComputedMinMax computed from initial selectedTime', () => {
const { result } = renderHook(() => useGlobalTimeStore());
expect(result.current.lastComputedMinMax).toStrictEqual({
// Now computes min/max on store creation, no longer starts with 0
expect(result.current.lastComputedMinMax.minTime).toBeGreaterThan(0);
expect(result.current.lastComputedMinMax.maxTime).toBeGreaterThan(0);
});
it('should not crash with invalid relative time format', () => {
// Invalid time formats should not crash store creation
const store = createGlobalTimeStore({
selectedTime: 'invalid_time' as GlobalTimeSelectedTime,
});
// Store should be created successfully
expect(store.getState().selectedTime).toBe('invalid_time');
// Should fallback to {0, 0} when parsing fails
expect(store.getState().lastComputedMinMax).toStrictEqual({
minTime: 0,
maxTime: 0,
});
@@ -724,8 +738,8 @@ describe('globalTimeStore', () => {
const wrapper = createIsolatedWrapper();
const { result } = renderHook(() => useGlobalTime(), { wrapper });
// Initial state has 0 values
expect(result.current.lastComputedMinMax.maxTime).toBe(0);
// Initial state now has computed values (no longer 0)
expect(result.current.lastComputedMinMax.maxTime).toBeGreaterThan(0);
act(() => {
result.current.setSelectedTime('15m');

View File

@@ -134,17 +134,17 @@ describe('useComputedMinMaxSync', () => {
jest.useRealTimers();
});
it('should compute min/max on mount when store has zero values', () => {
it('should have computed min/max on store creation (no longer needs mount sync)', () => {
const contextStore = createGlobalTimeStore({ selectedTime: '15m' });
expect(contextStore.getState().lastComputedMinMax).toStrictEqual({
minTime: 0,
maxTime: 0,
});
// Store now computes min/max on creation, not on mount
expect(contextStore.getState().lastComputedMinMax.minTime).toBeGreaterThan(0);
expect(contextStore.getState().lastComputedMinMax.maxTime).toBeGreaterThan(0);
// Hook still works but is a no-op when values already exist
renderHook(() => useComputedMinMaxSync(contextStore));
// Should have computed values now
// Values remain computed
expect(contextStore.getState().lastComputedMinMax.maxTime).toBeGreaterThan(0);
expect(contextStore.getState().lastComputedMinMax.minTime).toBeGreaterThan(0);
});

View File

@@ -12,6 +12,7 @@ import {
computeRounded5sMinMax,
isCustomTimeRange,
parseSelectedTime,
safeParseSelectedTime,
} from './utils';
export type GlobalTimeStoreApi = StoreApi<GlobalTimeStore>;
@@ -40,7 +41,7 @@ export function createGlobalTimeStore(
refreshInterval,
isRefreshEnabled: computeIsRefreshEnabled(selectedTime, refreshInterval),
lastRefreshTimestamp: 0,
lastComputedMinMax: { minTime: 0, maxTime: 0 },
lastComputedMinMax: safeParseSelectedTime(selectedTime),
setSelectedTime: (
time: GlobalTimeSelectedTime,

View File

@@ -61,6 +61,8 @@ const fallbackDurationInNanoSeconds = 30 * 1000 * NANO_SECOND_MULTIPLIER; // 30s
* Parse the selectedTime string to get min/max time values.
* For relative times, computes fresh values based on Date.now().
* For custom times, extracts the stored min/max values.
*
* @throws Error - When selectedTime is relativeTime and it's invalid
*/
export function parseSelectedTime(selectedTime: string): ParsedTimeRange {
if (isCustomTimeRange(selectedTime)) {
@@ -78,6 +80,18 @@ export function parseSelectedTime(selectedTime: string): ParsedTimeRange {
return getMinMaxForSelectedTime(selectedTime as Time, 0, 0);
}
/**
* The {@ref parseSelectedTime} can throw errors, this handles and fallbacks to 0,0 if invalid selected time is provided
*/
export function safeParseSelectedTime(selectedTime: string): ParsedTimeRange {
try {
return parseSelectedTime(selectedTime);
} catch (e) {
console.error('Error parsing selected time:', e);
return { minTime: 0, maxTime: 0 };
}
}
/**
* @deprecated Use store.getAutoRefreshQueryKey() instead.
* Access via: const getAutoRefreshQueryKey = useGlobalTime((s) => s.getAutoRefreshQueryKey);

View File

@@ -5614,10 +5614,10 @@
tailwind-merge "^2.5.2"
tailwindcss-animate "^1.0.7"
"@signozhq/ui@0.0.18":
version "0.0.18"
resolved "https://registry.yarnpkg.com/@signozhq/ui/-/ui-0.0.18.tgz#a96f843aea87d2a435ed0efc68d0a94eaae98baa"
integrity sha512-1p3ALh76kafiz5yX7ReNKVcHDt2od7CcZD/Vx9i2adTwTeynkLJcEfVoXoJD3oh1kKTleooOiOjRyxlA7VzmSA==
"@signozhq/ui@0.0.19":
version "0.0.19"
resolved "https://registry.yarnpkg.com/@signozhq/ui/-/ui-0.0.19.tgz#125cbfb9c6bc39ace7f9a99b2b3fdd291a6bf76e"
integrity sha512-2q6aRxN/PR4PlR2xJZAREEuvLPiDFggfFKzCW2Z5vHVVbrgnvZHWD1jPUuwszfEg0ceH3UvkwqceO7wN4uRJAA==
dependencies:
"@chenglou/pretext" "^0.0.5"
"@radix-ui/react-checkbox" "^1.2.3"

View File

@@ -86,5 +86,24 @@ func (provider *provider) addInfraMonitoringRoutes(router *mux.Router) error {
return err
}
if err := router.Handle("/api/v2/infra_monitoring/clusters", handler.New(
provider.authzMiddleware.ViewAccess(provider.infraMonitoringHandler.ListClusters),
handler.OpenAPIDef{
ID: "ListClusters",
Tags: []string{"inframonitoring"},
Summary: "List Clusters for Infra Monitoring",
Description: "Returns a paginated list of Kubernetes clusters with key aggregated metrics derived by summing per-node values within the group: CPU usage, CPU allocatable, memory working set, memory allocatable. Each row also reports per-group nodeCountsByReadiness ({ ready, notReady } from each node's latest k8s.node.condition_ready value) and per-group podCountsByPhase ({ pending, running, succeeded, failed, unknown } from each pod's latest k8s.pod.phase value). Each cluster includes metadata attributes (k8s.cluster.name). The response type is 'list' for the default k8s.cluster.name grouping or 'grouped_list' for custom groupBy keys; in both modes every row aggregates nodes and pods in the group. Supports filtering via a filter expression, custom groupBy, ordering by cpu / cpu_allocatable / memory / memory_allocatable, and pagination via offset/limit. Also reports missing required metrics and whether the requested time range falls before the data retention boundary. Numeric metric fields (clusterCPU, clusterCPUAllocatable, clusterMemory, clusterMemoryAllocatable) return -1 as a sentinel when no data is available for that field.",
Request: new(inframonitoringtypes.PostableClusters),
RequestContentType: "application/json",
Response: new(inframonitoringtypes.Clusters),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusUnauthorized},
Deprecated: false,
SecuritySchemes: newSecuritySchemes(types.RoleViewer),
})).Methods(http.MethodPost).GetError(); err != nil {
return err
}
return nil
}

View File

@@ -0,0 +1,140 @@
package implinframonitoring
import (
"context"
"slices"
"github.com/SigNoz/signoz/pkg/types/inframonitoringtypes"
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
"github.com/SigNoz/signoz/pkg/valuer"
)
// buildClusterRecords assembles the page records. Node condition counts and
// pod phase counts come from the respective per-group maps in both modes;
// every row is a group of nodes+pods, so there's no per-row "current state"
// concept (analogous to namespaces).
func buildClusterRecords(
resp *qbtypes.QueryRangeResponse,
pageGroups []map[string]string,
groupBy []qbtypes.GroupByKey,
metadataMap map[string]map[string]string,
nodeConditionCountsMap map[string]nodeConditionCounts,
podPhaseCountsMap map[string]podPhaseCounts,
) []inframonitoringtypes.ClusterRecord {
metricsMap := parseFullQueryResponse(resp, groupBy)
records := make([]inframonitoringtypes.ClusterRecord, 0, len(pageGroups))
for _, labels := range pageGroups {
compositeKey := compositeKeyFromLabels(labels, groupBy)
clusterName := labels[clusterNameAttrKey]
record := inframonitoringtypes.ClusterRecord{ // initialize with default values
ClusterName: clusterName,
ClusterCPU: -1,
ClusterCPUAllocatable: -1,
ClusterMemory: -1,
ClusterMemoryAllocatable: -1,
Meta: map[string]string{},
}
if metrics, ok := metricsMap[compositeKey]; ok {
if v, exists := metrics["A"]; exists {
record.ClusterCPU = v
}
if v, exists := metrics["B"]; exists {
record.ClusterCPUAllocatable = v
}
if v, exists := metrics["C"]; exists {
record.ClusterMemory = v
}
if v, exists := metrics["D"]; exists {
record.ClusterMemoryAllocatable = v
}
}
if conditionCountsForGroup, ok := nodeConditionCountsMap[compositeKey]; ok {
record.NodeCountsByReadiness = inframonitoringtypes.NodeCountsByReadiness{
Ready: conditionCountsForGroup.Ready,
NotReady: conditionCountsForGroup.NotReady,
}
}
if phaseCountsForGroup, ok := podPhaseCountsMap[compositeKey]; ok {
record.PodCountsByPhase = inframonitoringtypes.PodCountsByPhase{
Pending: phaseCountsForGroup.Pending,
Running: phaseCountsForGroup.Running,
Succeeded: phaseCountsForGroup.Succeeded,
Failed: phaseCountsForGroup.Failed,
Unknown: phaseCountsForGroup.Unknown,
}
}
if attrs, ok := metadataMap[compositeKey]; ok {
for k, v := range attrs {
record.Meta[k] = v
}
}
records = append(records, record)
}
return records
}
func (m *module) getTopClusterGroups(
ctx context.Context,
orgID valuer.UUID,
req *inframonitoringtypes.PostableClusters,
metadataMap map[string]map[string]string,
) ([]map[string]string, error) {
orderByKey := req.OrderBy.Key.Name
queryNamesForOrderBy := orderByToClustersQueryNames[orderByKey]
rankingQueryName := queryNamesForOrderBy[len(queryNamesForOrderBy)-1]
topReq := &qbtypes.QueryRangeRequest{
Start: uint64(req.Start),
End: uint64(req.End),
RequestType: qbtypes.RequestTypeScalar,
CompositeQuery: qbtypes.CompositeQuery{
Queries: make([]qbtypes.QueryEnvelope, 0, len(queryNamesForOrderBy)),
},
}
for _, envelope := range m.newClustersTableListQuery().CompositeQuery.Queries {
if !slices.Contains(queryNamesForOrderBy, envelope.GetQueryName()) {
continue
}
copied := envelope
if copied.Type == qbtypes.QueryTypeBuilder {
existingExpr := ""
if f := copied.GetFilter(); f != nil {
existingExpr = f.Expression
}
reqFilterExpr := ""
if req.Filter != nil {
reqFilterExpr = req.Filter.Expression
}
merged := mergeFilterExpressions(existingExpr, reqFilterExpr)
copied.SetFilter(&qbtypes.Filter{Expression: merged})
copied.SetGroupBy(req.GroupBy)
}
topReq.CompositeQuery.Queries = append(topReq.CompositeQuery.Queries, copied)
}
resp, err := m.querier.QueryRange(ctx, orgID, topReq)
if err != nil {
return nil, err
}
allMetricGroups := parseAndSortGroups(resp, rankingQueryName, req.GroupBy, req.OrderBy.Direction)
return paginateWithBackfill(allMetricGroups, metadataMap, req.GroupBy, req.Offset, req.Limit), nil
}
func (m *module) getClustersTableMetadata(ctx context.Context, req *inframonitoringtypes.PostableClusters) (map[string]map[string]string, error) {
var nonGroupByAttrs []string
for _, key := range clusterAttrKeysForMetadata {
if !isKeyInGroupByAttrs(req.GroupBy, key) {
nonGroupByAttrs = append(nonGroupByAttrs, key)
}
}
return m.getMetadata(ctx, clustersTableMetricNamesList, req.GroupBy, nonGroupByAttrs, req.Filter, req.Start, req.End)
}

View File

@@ -0,0 +1,140 @@
package implinframonitoring
import (
"github.com/SigNoz/signoz/pkg/types/inframonitoringtypes"
"github.com/SigNoz/signoz/pkg/types/metrictypes"
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
)
// TODO(nikhilmantri0902): change to k8s.cluster.uid after showing the missing
// data banner. Carried forward from v1 (see k8sClusterUIDAttrKey in
// pkg/query-service/app/inframetrics/clusters.go).
const clusterNameAttrKey = "k8s.cluster.name"
var clusterNameGroupByKey = qbtypes.GroupByKey{
TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{
Name: clusterNameAttrKey,
FieldContext: telemetrytypes.FieldContextResource,
FieldDataType: telemetrytypes.FieldDataTypeString,
},
}
// clustersTableMetricNamesList drives the existence/retention check.
// Includes k8s.node.condition_ready and k8s.pod.phase so the response
// short-circuits cleanly when a cluster doesn't ship those metrics — even
// though they aren't part of the QB composite query (they're queried separately
// via getPerGroupNodeConditionCounts and getPerGroupPodPhaseCounts).
var clustersTableMetricNamesList = []string{
"k8s.node.cpu.usage",
"k8s.node.allocatable_cpu",
"k8s.node.memory.working_set",
"k8s.node.allocatable_memory",
"k8s.node.condition_ready", //TODO(nikhilmantri0902): should these metrics be used to count groups k8s.node.condition_ready and k8s.pod.phase
"k8s.pod.phase",
}
var clusterAttrKeysForMetadata = []string{
"k8s.cluster.name",
}
var orderByToClustersQueryNames = map[string][]string{
inframonitoringtypes.ClustersOrderByCPU: {"A"},
inframonitoringtypes.ClustersOrderByCPUAllocatable: {"B"},
inframonitoringtypes.ClustersOrderByMemory: {"C"},
inframonitoringtypes.ClustersOrderByMemoryAllocatable: {"D"},
}
// newClustersTableListQuery builds the composite QB v5 request for the clusters list.
// Cluster-scope metrics are derived by summing per-node metrics within the
// group (default group: k8s.cluster.name). Node condition counts and pod phase
// counts are derived separately via getPerGroupNodeConditionCounts and
// getPerGroupPodPhaseCounts respectively (works for both list and grouped_list
// modes), so neither is included here. Query letters A/B/C/D mirror the v1
// implementation and the v2 nodes list.
func (m *module) newClustersTableListQuery() *qbtypes.QueryRangeRequest {
queries := []qbtypes.QueryEnvelope{
// Query A: CPU usage — sum of node CPU within the group.
{
Type: qbtypes.QueryTypeBuilder,
Spec: qbtypes.QueryBuilderQuery[qbtypes.MetricAggregation]{
Name: "A",
Signal: telemetrytypes.SignalMetrics,
Aggregations: []qbtypes.MetricAggregation{
{
MetricName: "k8s.node.cpu.usage",
TimeAggregation: metrictypes.TimeAggregationAvg,
SpaceAggregation: metrictypes.SpaceAggregationSum,
ReduceTo: qbtypes.ReduceToAvg,
},
},
GroupBy: []qbtypes.GroupByKey{clusterNameGroupByKey},
Disabled: false,
},
},
// Query B: CPU allocatable — sum of node allocatable CPU within the group.
// TimeAggregationLatest is the closest v5 equivalent of v1's AnyLast;
// allocatable values change rarely so divergence in practice is negligible.
{
Type: qbtypes.QueryTypeBuilder,
Spec: qbtypes.QueryBuilderQuery[qbtypes.MetricAggregation]{
Name: "B",
Signal: telemetrytypes.SignalMetrics,
Aggregations: []qbtypes.MetricAggregation{
{
MetricName: "k8s.node.allocatable_cpu",
TimeAggregation: metrictypes.TimeAggregationLatest,
SpaceAggregation: metrictypes.SpaceAggregationSum,
ReduceTo: qbtypes.ReduceToAvg,
},
},
GroupBy: []qbtypes.GroupByKey{clusterNameGroupByKey},
Disabled: false,
},
},
// Query C: Memory working set — sum of node memory within the group.
{
Type: qbtypes.QueryTypeBuilder,
Spec: qbtypes.QueryBuilderQuery[qbtypes.MetricAggregation]{
Name: "C",
Signal: telemetrytypes.SignalMetrics,
Aggregations: []qbtypes.MetricAggregation{
{
MetricName: "k8s.node.memory.working_set",
TimeAggregation: metrictypes.TimeAggregationAvg,
SpaceAggregation: metrictypes.SpaceAggregationSum,
ReduceTo: qbtypes.ReduceToAvg,
},
},
GroupBy: []qbtypes.GroupByKey{clusterNameGroupByKey},
Disabled: false,
},
},
// Query D: Memory allocatable — sum of node allocatable memory within the group.
// Same Latest caveat as Query B.
{
Type: qbtypes.QueryTypeBuilder,
Spec: qbtypes.QueryBuilderQuery[qbtypes.MetricAggregation]{
Name: "D",
Signal: telemetrytypes.SignalMetrics,
Aggregations: []qbtypes.MetricAggregation{
{
MetricName: "k8s.node.allocatable_memory",
TimeAggregation: metrictypes.TimeAggregationLatest,
SpaceAggregation: metrictypes.SpaceAggregationSum,
ReduceTo: qbtypes.ReduceToAvg,
},
},
GroupBy: []qbtypes.GroupByKey{clusterNameGroupByKey},
Disabled: false,
},
},
}
return &qbtypes.QueryRangeRequest{
RequestType: qbtypes.RequestTypeScalar,
CompositeQuery: qbtypes.CompositeQuery{
Queries: queries,
},
}
}

View File

@@ -117,3 +117,27 @@ func (h *handler) ListNamespaces(rw http.ResponseWriter, req *http.Request) {
render.Success(rw, http.StatusOK, result)
}
func (h *handler) ListClusters(rw http.ResponseWriter, req *http.Request) {
claims, err := authtypes.ClaimsFromContext(req.Context())
if err != nil {
render.Error(rw, err)
return
}
orgID := valuer.MustNewUUID(claims.OrgID)
var parsedReq inframonitoringtypes.PostableClusters
if err := binding.JSON.BindBody(req.Body, &parsedReq); err != nil {
render.Error(rw, err)
return
}
result, err := h.module.ListClusters(req.Context(), orgID, &parsedReq)
if err != nil {
render.Error(rw, err)
return
}
render.Success(rw, http.StatusOK, result)
}

View File

@@ -426,3 +426,105 @@ func (m *module) ListNamespaces(ctx context.Context, orgID valuer.UUID, req *inf
return resp, nil
}
func (m *module) ListClusters(ctx context.Context, orgID valuer.UUID, req *inframonitoringtypes.PostableClusters) (*inframonitoringtypes.Clusters, error) {
if err := req.Validate(); err != nil {
return nil, err
}
resp := &inframonitoringtypes.Clusters{}
if req.OrderBy == nil {
req.OrderBy = &qbtypes.OrderBy{
Key: qbtypes.OrderByKey{
TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{
Name: inframonitoringtypes.ClustersOrderByCPU,
},
},
Direction: qbtypes.OrderDirectionDesc,
}
}
if len(req.GroupBy) == 0 {
req.GroupBy = []qbtypes.GroupByKey{clusterNameGroupByKey}
resp.Type = inframonitoringtypes.ResponseTypeList
} else {
resp.Type = inframonitoringtypes.ResponseTypeGroupedList
}
missingMetrics, minFirstReportedUnixMilli, err := m.getMetricsExistenceAndEarliestTime(ctx, clustersTableMetricNamesList)
if err != nil {
return nil, err
}
if len(missingMetrics) > 0 {
resp.RequiredMetricsCheck = inframonitoringtypes.RequiredMetricsCheck{MissingMetrics: missingMetrics}
resp.Records = []inframonitoringtypes.ClusterRecord{}
resp.Total = 0
return resp, nil
}
if req.End < int64(minFirstReportedUnixMilli) {
resp.EndTimeBeforeRetention = true
resp.Records = []inframonitoringtypes.ClusterRecord{}
resp.Total = 0
return resp, nil
}
resp.RequiredMetricsCheck = inframonitoringtypes.RequiredMetricsCheck{MissingMetrics: []string{}}
metadataMap, err := m.getClustersTableMetadata(ctx, req)
if err != nil {
return nil, err
}
resp.Total = len(metadataMap)
pageGroups, err := m.getTopClusterGroups(ctx, orgID, req, metadataMap)
if err != nil {
return nil, err
}
if len(pageGroups) == 0 {
resp.Records = []inframonitoringtypes.ClusterRecord{}
return resp, nil
}
filterExpr := ""
if req.Filter != nil {
filterExpr = req.Filter.Expression
}
fullQueryReq := buildFullQueryRequest(req.Start, req.End, filterExpr, req.GroupBy, pageGroups, m.newClustersTableListQuery())
queryResp, err := m.querier.QueryRange(ctx, orgID, fullQueryReq)
if err != nil {
return nil, err
}
// Reuse the nodes condition-counts CTE function via a temp struct — it reads only
// Start/End/Filter/GroupBy from PostableNodes. With default groupBy
// [k8s.cluster.name], counts are bucketed per cluster; with a custom groupBy,
// they aggregate across clusters in that group.
nodeConditionCountsMap, err := m.getPerGroupNodeConditionCounts(ctx, &inframonitoringtypes.PostableNodes{
Start: req.Start,
End: req.End,
Filter: req.Filter,
GroupBy: req.GroupBy,
}, pageGroups)
if err != nil {
return nil, err
}
// Same pattern for pod phase counts via PostablePods shim.
podPhaseCountsMap, err := m.getPerGroupPodPhaseCounts(ctx, &inframonitoringtypes.PostablePods{
Start: req.Start,
End: req.End,
Filter: req.Filter,
GroupBy: req.GroupBy,
}, pageGroups)
if err != nil {
return nil, err
}
resp.Records = buildClusterRecords(queryResp, pageGroups, req.GroupBy, metadataMap, nodeConditionCountsMap, podPhaseCountsMap)
resp.Warning = queryResp.Warning
return resp, nil
}

View File

@@ -13,6 +13,7 @@ type Handler interface {
ListPods(http.ResponseWriter, *http.Request)
ListNodes(http.ResponseWriter, *http.Request)
ListNamespaces(http.ResponseWriter, *http.Request)
ListClusters(http.ResponseWriter, *http.Request)
}
type Module interface {
@@ -20,4 +21,5 @@ type Module interface {
ListPods(ctx context.Context, orgID valuer.UUID, req *inframonitoringtypes.PostablePods) (*inframonitoringtypes.Pods, error)
ListNodes(ctx context.Context, orgID valuer.UUID, req *inframonitoringtypes.PostableNodes) (*inframonitoringtypes.Nodes, error)
ListNamespaces(ctx context.Context, orgID valuer.UUID, req *inframonitoringtypes.PostableNamespaces) (*inframonitoringtypes.Namespaces, error)
ListClusters(ctx context.Context, orgID valuer.UUID, req *inframonitoringtypes.PostableClusters) (*inframonitoringtypes.Clusters, error)
}

View File

@@ -377,7 +377,7 @@ func (module *module) getOrGetSetIdentity(ctx context.Context, serviceAccountID
}
func (module *module) setRole(ctx context.Context, orgID valuer.UUID, id valuer.UUID, role *authtypes.Role) error {
serviceAccount, err := module.GetWithRoles(ctx, orgID, id)
serviceAccount, err := module.Get(ctx, orgID, id)
if err != nil {
return err
}
@@ -387,24 +387,12 @@ func (module *module) setRole(ctx context.Context, orgID valuer.UUID, id valuer.
return err
}
err = module.authz.ModifyGrant(ctx, orgID, serviceAccount.RoleNames(), []string{role.Name}, authtypes.MustNewSubject(coretypes.NewResourceServiceAccount(), id.String(), orgID, nil))
err = module.authz.Grant(ctx, orgID, []string{role.Name}, authtypes.MustNewSubject(coretypes.NewResourceServiceAccount(), id.String(), orgID, nil))
if err != nil {
return err
}
err = module.store.RunInTx(ctx, func(ctx context.Context) error {
err = module.store.DeleteServiceAccountRoles(ctx, serviceAccount.ID)
if err != nil {
return err
}
err = module.store.CreateServiceAccountRole(ctx, serviceAccountRole)
if err != nil {
return err
}
return nil
})
err = module.store.CreateServiceAccountRole(ctx, serviceAccountRole)
if err != nil {
return err
}

View File

@@ -207,21 +207,6 @@ func (store *store) CreateServiceAccountRole(ctx context.Context, serviceAccount
return nil
}
func (store *store) DeleteServiceAccountRoles(ctx context.Context, serviceAccountID valuer.UUID) error {
_, err := store.
sqlstore.
BunDBCtx(ctx).
NewDelete().
Model(new(serviceaccounttypes.ServiceAccountRole)).
Where("service_account_id = ?", serviceAccountID).
Exec(ctx)
if err != nil {
return err
}
return nil
}
func (store *store) DeleteServiceAccountRole(ctx context.Context, serviceAccountID valuer.UUID, roleID valuer.UUID) error {
_, err := store.
sqlstore.

View File

@@ -0,0 +1,105 @@
package inframonitoringtypes
import (
"encoding/json"
"slices"
"github.com/SigNoz/signoz/pkg/errors"
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
)
type Clusters struct {
Type ResponseType `json:"type" required:"true"`
Records []ClusterRecord `json:"records" required:"true"`
Total int `json:"total" required:"true"`
RequiredMetricsCheck RequiredMetricsCheck `json:"requiredMetricsCheck" required:"true"`
EndTimeBeforeRetention bool `json:"endTimeBeforeRetention" required:"true"`
Warning *qbtypes.QueryWarnData `json:"warning,omitempty"`
}
type ClusterRecord struct {
// TODO(nikhilmantri0902): once the underlying attr key is migrated to
// k8s.cluster.uid (see clusterNameAttrKey TODO in implinframonitoring),
// surface ClusterUID alongside (or replace) ClusterName.
ClusterName string `json:"clusterName" required:"true"`
ClusterCPU float64 `json:"clusterCPU" required:"true"`
ClusterCPUAllocatable float64 `json:"clusterCPUAllocatable" required:"true"`
ClusterMemory float64 `json:"clusterMemory" required:"true"`
ClusterMemoryAllocatable float64 `json:"clusterMemoryAllocatable" required:"true"`
NodeCountsByReadiness NodeCountsByReadiness `json:"nodeCountsByReadiness" required:"true"`
PodCountsByPhase PodCountsByPhase `json:"podCountsByPhase" required:"true"`
Meta map[string]string `json:"meta" required:"true"`
}
// PostableClusters is the request body for the v2 clusters list API.
type PostableClusters struct {
Start int64 `json:"start" required:"true"`
End int64 `json:"end" required:"true"`
Filter *qbtypes.Filter `json:"filter"`
GroupBy []qbtypes.GroupByKey `json:"groupBy"`
OrderBy *qbtypes.OrderBy `json:"orderBy"`
Offset int `json:"offset"`
Limit int `json:"limit" required:"true"`
}
// Validate ensures PostableClusters contains acceptable values.
func (req *PostableClusters) Validate() error {
if req == nil {
return errors.NewInvalidInputf(errors.CodeInvalidInput, "request is nil")
}
if req.Start <= 0 {
return errors.NewInvalidInputf(
errors.CodeInvalidInput,
"invalid start time %d: start must be greater than 0",
req.Start,
)
}
if req.End <= 0 {
return errors.NewInvalidInputf(
errors.CodeInvalidInput,
"invalid end time %d: end must be greater than 0",
req.End,
)
}
if req.Start >= req.End {
return errors.NewInvalidInputf(
errors.CodeInvalidInput,
"invalid time range: start (%d) must be less than end (%d)",
req.Start,
req.End,
)
}
if req.Limit < 1 || req.Limit > 5000 {
return errors.NewInvalidInputf(errors.CodeInvalidInput, "limit must be between 1 and 5000")
}
if req.Offset < 0 {
return errors.NewInvalidInputf(errors.CodeInvalidInput, "offset cannot be negative")
}
if req.OrderBy != nil {
if !slices.Contains(ClustersValidOrderByKeys, req.OrderBy.Key.Name) {
return errors.NewInvalidInputf(errors.CodeInvalidInput, "invalid order by key: %s", req.OrderBy.Key.Name)
}
if req.OrderBy.Direction != qbtypes.OrderDirectionAsc && req.OrderBy.Direction != qbtypes.OrderDirectionDesc {
return errors.NewInvalidInputf(errors.CodeInvalidInput, "invalid order by direction: %s", req.OrderBy.Direction)
}
}
return nil
}
// UnmarshalJSON validates input immediately after decoding.
func (req *PostableClusters) UnmarshalJSON(data []byte) error {
type raw PostableClusters
var decoded raw
if err := json.Unmarshal(data, &decoded); err != nil {
return err
}
*req = PostableClusters(decoded)
return req.Validate()
}

View File

@@ -0,0 +1,15 @@
package inframonitoringtypes
const (
ClustersOrderByCPU = "cpu"
ClustersOrderByCPUAllocatable = "cpu_allocatable"
ClustersOrderByMemory = "memory"
ClustersOrderByMemoryAllocatable = "memory_allocatable"
)
var ClustersValidOrderByKeys = []string{
ClustersOrderByCPU,
ClustersOrderByCPUAllocatable,
ClustersOrderByMemory,
ClustersOrderByMemoryAllocatable,
}

View File

@@ -0,0 +1,291 @@
package inframonitoringtypes
import (
"testing"
"github.com/SigNoz/signoz/pkg/errors"
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
"github.com/SigNoz/signoz/pkg/valuer"
"github.com/stretchr/testify/require"
)
func TestPostableClusters_Validate(t *testing.T) {
tests := []struct {
name string
req *PostableClusters
wantErr bool
}{
{
name: "valid request",
req: &PostableClusters{
Start: 1000,
End: 2000,
Limit: 100,
Offset: 0,
},
wantErr: false,
},
{
name: "nil request",
req: nil,
wantErr: true,
},
{
name: "start time zero",
req: &PostableClusters{
Start: 0,
End: 2000,
Limit: 100,
Offset: 0,
},
wantErr: true,
},
{
name: "start time negative",
req: &PostableClusters{
Start: -1000,
End: 2000,
Limit: 100,
Offset: 0,
},
wantErr: true,
},
{
name: "end time zero",
req: &PostableClusters{
Start: 1000,
End: 0,
Limit: 100,
Offset: 0,
},
wantErr: true,
},
{
name: "start time greater than end time",
req: &PostableClusters{
Start: 2000,
End: 1000,
Limit: 100,
Offset: 0,
},
wantErr: true,
},
{
name: "start time equal to end time",
req: &PostableClusters{
Start: 1000,
End: 1000,
Limit: 100,
Offset: 0,
},
wantErr: true,
},
{
name: "limit zero",
req: &PostableClusters{
Start: 1000,
End: 2000,
Limit: 0,
Offset: 0,
},
wantErr: true,
},
{
name: "limit negative",
req: &PostableClusters{
Start: 1000,
End: 2000,
Limit: -10,
Offset: 0,
},
wantErr: true,
},
{
name: "limit exceeds max",
req: &PostableClusters{
Start: 1000,
End: 2000,
Limit: 5001,
Offset: 0,
},
wantErr: true,
},
{
name: "offset negative",
req: &PostableClusters{
Start: 1000,
End: 2000,
Limit: 100,
Offset: -5,
},
wantErr: true,
},
{
name: "orderBy nil is valid",
req: &PostableClusters{
Start: 1000,
End: 2000,
Limit: 100,
Offset: 0,
},
wantErr: false,
},
{
name: "orderBy with valid key cpu and direction asc",
req: &PostableClusters{
Start: 1000,
End: 2000,
Limit: 100,
Offset: 0,
OrderBy: &qbtypes.OrderBy{
Key: qbtypes.OrderByKey{
TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{
Name: ClustersOrderByCPU,
},
},
Direction: qbtypes.OrderDirectionAsc,
},
},
wantErr: false,
},
{
name: "orderBy with valid key cpu_allocatable and direction desc",
req: &PostableClusters{
Start: 1000,
End: 2000,
Limit: 100,
Offset: 0,
OrderBy: &qbtypes.OrderBy{
Key: qbtypes.OrderByKey{
TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{
Name: ClustersOrderByCPUAllocatable,
},
},
Direction: qbtypes.OrderDirectionDesc,
},
},
wantErr: false,
},
{
name: "orderBy with valid key memory and direction desc",
req: &PostableClusters{
Start: 1000,
End: 2000,
Limit: 100,
Offset: 0,
OrderBy: &qbtypes.OrderBy{
Key: qbtypes.OrderByKey{
TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{
Name: ClustersOrderByMemory,
},
},
Direction: qbtypes.OrderDirectionDesc,
},
},
wantErr: false,
},
{
name: "orderBy with valid key memory_allocatable and direction asc",
req: &PostableClusters{
Start: 1000,
End: 2000,
Limit: 100,
Offset: 0,
OrderBy: &qbtypes.OrderBy{
Key: qbtypes.OrderByKey{
TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{
Name: ClustersOrderByMemoryAllocatable,
},
},
Direction: qbtypes.OrderDirectionAsc,
},
},
wantErr: false,
},
{
name: "orderBy with condition key is rejected",
req: &PostableClusters{
Start: 1000,
End: 2000,
Limit: 100,
Offset: 0,
OrderBy: &qbtypes.OrderBy{
Key: qbtypes.OrderByKey{
TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{
Name: "condition",
},
},
Direction: qbtypes.OrderDirectionDesc,
},
},
wantErr: true,
},
{
name: "orderBy with pod_phase key is rejected",
req: &PostableClusters{
Start: 1000,
End: 2000,
Limit: 100,
Offset: 0,
OrderBy: &qbtypes.OrderBy{
Key: qbtypes.OrderByKey{
TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{
Name: "pod_phase",
},
},
Direction: qbtypes.OrderDirectionDesc,
},
},
wantErr: true,
},
{
name: "orderBy with invalid key",
req: &PostableClusters{
Start: 1000,
End: 2000,
Limit: 100,
Offset: 0,
OrderBy: &qbtypes.OrderBy{
Key: qbtypes.OrderByKey{
TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{
Name: "unknown",
},
},
Direction: qbtypes.OrderDirectionDesc,
},
},
wantErr: true,
},
{
name: "orderBy with valid key but invalid direction",
req: &PostableClusters{
Start: 1000,
End: 2000,
Limit: 100,
Offset: 0,
OrderBy: &qbtypes.OrderBy{
Key: qbtypes.OrderByKey{
TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{
Name: ClustersOrderByMemory,
},
},
Direction: qbtypes.OrderDirection{String: valuer.NewString("invalid")},
},
},
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := tt.req.Validate()
if tt.wantErr {
require.Error(t, err)
require.True(t, errors.Ast(err, errors.TypeInvalidInput), "expected error to be of type InvalidInput")
} else {
require.NoError(t, err)
}
})
}
}

View File

@@ -245,7 +245,6 @@ type Store interface {
// Service Account Role
CreateServiceAccountRole(context.Context, *ServiceAccountRole) error
DeleteServiceAccountRoles(context.Context, valuer.UUID) error
DeleteServiceAccountRole(context.Context, valuer.UUID, valuer.UUID) error
// Service Account Factor API Key

View File

@@ -73,6 +73,30 @@ def get_first_key_id(signoz: types.SigNoz, token: str, service_account_id: str)
return resp.json()["data"][0]["id"]
def create_service_account_with_roles(signoz: types.SigNoz, token: str, name: str, roles: list[str]) -> str:
"""Create a service account and assign multiple roles."""
resp = requests.post(
signoz.self.host_configs["8080"].get(SERVICE_ACCOUNT_BASE),
json={"name": name},
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)
assert resp.status_code == HTTPStatus.CREATED, resp.text
service_account_id = resp.json()["data"]["id"]
for role in roles:
role_id = find_role_by_name(signoz, token, role)
role_resp = requests.post(
signoz.self.host_configs["8080"].get(f"{SERVICE_ACCOUNT_BASE}/{service_account_id}/roles"),
json={"id": role_id},
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)
assert role_resp.status_code == HTTPStatus.NO_CONTENT, role_resp.text
return service_account_id
def find_service_account_by_name(signoz: types.SigNoz, token: str, name: str) -> dict:
"""Find a service account by name from the list endpoint."""
list_resp = requests.get(

View File

@@ -44,13 +44,13 @@ def test_assign_role_to_service_account(
create_user_admin: types.Operation, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
):
"""POST /{id}/roles replaces existing role, verify via GET."""
"""POST /{id}/roles adds a role alongside existing ones."""
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
# create service account with viewer role
service_account_id = create_service_account(signoz, token, "sa-assign-role", role="signoz-viewer")
# assign editor role (replaces viewer)
# assign editor role (additive — viewer stays)
editor_role_id = find_role_by_name(signoz, token, "signoz-editor")
assign_resp = requests.post(
signoz.self.host_configs["8080"].get(f"{SERVICE_ACCOUNT_BASE}/{service_account_id}/roles"),
@@ -60,7 +60,7 @@ def test_assign_role_to_service_account(
)
assert assign_resp.status_code == HTTPStatus.NO_CONTENT, assign_resp.text
# verify only editor role is present (viewer was replaced)
# verify both viewer and editor roles are present
roles_resp = requests.get(
signoz.self.host_configs["8080"].get(f"{SERVICE_ACCOUNT_BASE}/{service_account_id}/roles"),
headers={"Authorization": f"Bearer {token}"},
@@ -68,9 +68,31 @@ def test_assign_role_to_service_account(
)
assert roles_resp.status_code == HTTPStatus.OK, roles_resp.text
role_names = [r["name"] for r in roles_resp.json()["data"]]
assert len(role_names) == 1
assert len(role_names) == 2
assert "signoz-viewer" in role_names
assert "signoz-editor" in role_names
assert "signoz-viewer" not in role_names
# assign admin role — all three should be present
admin_role_id = find_role_by_name(signoz, token, "signoz-admin")
assign_resp = requests.post(
signoz.self.host_configs["8080"].get(f"{SERVICE_ACCOUNT_BASE}/{service_account_id}/roles"),
json={"id": admin_role_id},
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)
assert assign_resp.status_code == HTTPStatus.NO_CONTENT, assign_resp.text
roles_resp = requests.get(
signoz.self.host_configs["8080"].get(f"{SERVICE_ACCOUNT_BASE}/{service_account_id}/roles"),
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)
assert roles_resp.status_code == HTTPStatus.OK, roles_resp.text
role_names = [r["name"] for r in roles_resp.json()["data"]]
assert len(role_names) == 3
assert "signoz-viewer" in role_names
assert "signoz-editor" in role_names
assert "signoz-admin" in role_names
def test_assign_role_idempotent(
@@ -103,16 +125,16 @@ def test_assign_role_idempotent(
assert role_names.count("signoz-viewer") == 1
def test_assign_role_replaces_access(
def test_assign_role_expands_access(
signoz: types.SigNoz,
create_user_admin: types.Operation, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
):
"""After role replacement, SA loses old permissions and gains new ones."""
"""Adding a higher-privilege role expands the SA's access."""
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
# create SA with viewer role and an API key
service_account_id, api_key = create_service_account_with_key(signoz, token, "sa-role-replace-access", role="signoz-viewer")
service_account_id, api_key = create_service_account_with_key(signoz, token, "sa-role-expand-access", role="signoz-viewer")
# viewer should get 403 on admin-only endpoint
resp = requests.get(
@@ -122,7 +144,7 @@ def test_assign_role_replaces_access(
)
assert resp.status_code == HTTPStatus.FORBIDDEN, f"Expected 403 for viewer on admin endpoint, got {resp.status_code}: {resp.text}"
# assign admin role (replaces viewer)
# assign admin role (additive — viewer stays)
admin_role_id = find_role_by_name(signoz, token, "signoz-admin")
assign_resp = requests.post(
signoz.self.host_configs["8080"].get(f"{SERVICE_ACCOUNT_BASE}/{service_account_id}/roles"),
@@ -138,9 +160,9 @@ def test_assign_role_replaces_access(
headers={"SIGNOZ-API-KEY": api_key},
timeout=5,
)
assert resp.status_code == HTTPStatus.OK, f"Expected 200 for admin on admin endpoint, got {resp.status_code}: {resp.text}"
assert resp.status_code == HTTPStatus.OK, f"Expected 200 after adding admin role, got {resp.status_code}: {resp.text}"
# verify only admin role is present
# verify both roles are present
roles_resp = requests.get(
signoz.self.host_configs["8080"].get(f"{SERVICE_ACCOUNT_BASE}/{service_account_id}/roles"),
headers={"Authorization": f"Bearer {token}"},
@@ -148,9 +170,9 @@ def test_assign_role_replaces_access(
)
assert roles_resp.status_code == HTTPStatus.OK, roles_resp.text
role_names = [r["name"] for r in roles_resp.json()["data"]]
assert len(role_names) == 1
assert len(role_names) == 2
assert "signoz-admin" in role_names
assert "signoz-viewer" not in role_names
assert "signoz-viewer" in role_names
def test_remove_role_from_service_account(
@@ -158,13 +180,22 @@ def test_remove_role_from_service_account(
create_user_admin: types.Operation, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
):
"""DELETE /{id}/roles/{rid} revokes a role."""
"""DELETE /{id}/roles/{rid} revokes one role while keeping others."""
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
service_account_id = create_service_account(signoz, token, "sa-remove-role", role="signoz-editor")
editor_role_id = find_role_by_name(signoz, token, "signoz-editor")
# add admin role (now has editor + admin)
admin_role_id = find_role_by_name(signoz, token, "signoz-admin")
assign_resp = requests.post(
signoz.self.host_configs["8080"].get(f"{SERVICE_ACCOUNT_BASE}/{service_account_id}/roles"),
json={"id": admin_role_id},
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)
assert assign_resp.status_code == HTTPStatus.NO_CONTENT, assign_resp.text
# remove the role
# remove editor role
editor_role_id = find_role_by_name(signoz, token, "signoz-editor")
resp = requests.delete(
signoz.self.host_configs["8080"].get(f"{SERVICE_ACCOUNT_BASE}/{service_account_id}/roles/{editor_role_id}"),
headers={"Authorization": f"Bearer {token}"},
@@ -172,7 +203,7 @@ def test_remove_role_from_service_account(
)
assert resp.status_code == HTTPStatus.NO_CONTENT, resp.text
# verify role is gone
# verify editor is gone but admin remains
roles_resp = requests.get(
signoz.self.host_configs["8080"].get(f"{SERVICE_ACCOUNT_BASE}/{service_account_id}/roles"),
headers={"Authorization": f"Bearer {token}"},
@@ -181,6 +212,7 @@ def test_remove_role_from_service_account(
assert roles_resp.status_code == HTTPStatus.OK, roles_resp.text
role_names = [r["name"] for r in roles_resp.json()["data"]]
assert "signoz-editor" not in role_names
assert "signoz-admin" in role_names
def test_remove_role_verify_access_lost(