Compare commits

..

16 Commits

Author SHA1 Message Date
Vinicius Lourenço
c729ed2637 fix(host-list): not showing refresh status & refresh interval queries overlaps (#10812)
Some checks are pending
build-staging / prepare (push) Waiting to run
build-staging / js-build (push) Blocked by required conditions
build-staging / go-build (push) Blocked by required conditions
build-staging / staging (push) Blocked by required conditions
Release Drafter / update_release_draft (push) Waiting to run
* fix(host-list): not showing refresh status & refresh interval queries overlaps

* fix(hosts-list): standardize the name of the store

* fix(hosts-list): use nano second multiplier

* fix(host-list): not showing a good error message

* fix(host-list): ensure states do not conflict with each other
2026-04-06 18:14:05 +00:00
Vinicius Lourenço
43f7363e84 fix(date-time-selection): reset to the correct value under modal (#10813) 2026-04-06 13:07:23 +00:00
Ashwin Bhatkal
6fe69b94c9 fix: show alert type in classic experience edit (#10847) 2026-04-06 11:00:41 +00:00
Srikanth Chekuri
1eb27d0bc4 chore: add schema version specific validations (#10808)
Some checks failed
build-staging / prepare (push) Has been cancelled
build-staging / js-build (push) Has been cancelled
build-staging / go-build (push) Has been cancelled
build-staging / staging (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
* chore: add schema version specific validations

* chore: address reivew comments

* chore: additional checks

* chore: adrress lint

* chore: more lint
2026-04-06 10:45:41 +00:00
Vikrant Gupta
55cce3c708 feat(authz): accept singular roles for user and service accounts (#10827)
* feat(authz): accept singular roles for user and service accounts

* feat(authz): update integration tests

* feat(authz): update integration tests

* feat: move role management to a single select flow on members and service account pages(temporarily)

* feat(authz): enable stats reporter for service accounts

* feat(authz): identity call for activating/deleting user

---------

Co-authored-by: SagarRajput-7 <sagar@signoz.io>
2026-04-06 10:41:56 +00:00
Vikrant Gupta
d677973d56 test(integration): add test cases for new user APIs (#10837)
Some checks failed
build-staging / prepare (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
build-staging / js-build (push) Has been cancelled
build-staging / go-build (push) Has been cancelled
build-staging / staging (push) Has been cancelled
* test(integration): add user_v2 tests

* test(integration): fix fmt

* test(integration): disable delete mode from tests

* test(integration): add response.text when the assertion fails

* test(integration): some renaming
2026-04-04 14:43:55 +00:00
Srikanth Chekuri
b5b89bb678 fix: several panic errors (#10817)
* fix: several panic errors

* fix: add recover for dashboard sql
2026-04-04 10:38:42 +00:00
Pandey
b5eab118dd refactor: move and internalize resource filter statement builder (#10824)
* refactor: move resourcefilter to pkg/telemetryresourcefilter

Move pkg/querybuilder/resourcefilter to pkg/telemetryresourcefilter
to align with the existing telemetry package naming convention
(telemetrylogs, telemetrytraces, telemetrymetrics, telemetrymeter).
The resource filter is a statement builder, not a query builder utility.

* refactor: internalize resource filter construction in statement builders

Each telemetry statement builder (logs, traces) now creates its own
resource filter internally instead of receiving it as an injected
dependency. This makes it impossible to wire the wrong resource table
and simplifies the provider.

Delete telemetryresourcefilter/tables.go — each telemetry package now
owns its resource table constant (LogsResourceV2TableName in
telemetrylogs, TracesResourceV3TableName in telemetrytraces).

* refactor: create field mapper and condition builder inside resource filter New

Remove fieldMapper and conditionBuilder params from
telemetryresourcefilter.New — they are always the same
(NewFieldMapper + NewConditionBuilder) so create them internally.
2026-04-04 10:27:46 +00:00
Vishal Sharma
98d0bdfe49 chore(frontend): sync pylon chat widget theme with app theme (#10830)
Some checks failed
build-staging / prepare (push) Has been cancelled
build-staging / js-build (push) Has been cancelled
build-staging / go-build (push) Has been cancelled
build-staging / staging (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
2026-04-04 09:29:08 +00:00
Vikrant Gupta
a71006b662 chore(user): add partial index for email,org_id (#10828)
Some checks failed
build-staging / prepare (push) Has been cancelled
build-staging / js-build (push) Has been cancelled
build-staging / go-build (push) Has been cancelled
build-staging / staging (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
* chore(user): add partial index for email,org_id

* chore(user): fix integration test fmt

* chore(user): fix integration test lint
2026-04-03 20:27:04 +00:00
Vishal Sharma
769e36ec84 feat(frontend): add new onboarding datasource entries (#10829) 2026-04-03 20:13:08 +00:00
SagarRajput-7
363734054f feat: updated user api to v2 and accordingly update members page and role management (#10799)
* feat: updated user api to v2 and accordingly update members page and role management

* feat: updated members page to use new role management and v2 user api

* feat: updated test cases

* feat: code refactor

* feat: refactored code and addressed feedbacks

* feat: refactored code and addressed feedbacks

* feat: refactored code and addressed feedbacks

* fix(user): fix openapi spec

* feat: handle isRoot user and self user cases and added test cases

---------

Co-authored-by: vikrantgupta25 <vikrant@signoz.io>
2026-04-03 18:41:27 +00:00
Pandey
726948db9e feat(audittypes): align types with revised schema doc (#10826)
* feat(audittypes): align types with revised schema doc

Rename resource.name → resource.kind to match Typeable.Kind() rename.
Move resource attributes (kind, id) from event attributes to OTel
Resource, grouping events by target resource in NewPLogsFromAuditEvents.
Add network.protocol.name, network.protocol.version, url.scheme to
transport attributes for complete OTel semconv coverage.

* refactor(audittypes): inline resourceKey struct into function scope

* test(audittypes): add tests for NewPLogsFromAuditEvents

Cover resource grouping: empty input, single event, same resource
batched into one ResourceLogs, different resources split, same kind
with different IDs split, and interleaved events grouped correctly.
Verify resource attrs live on Resource (not event attributes).
2026-04-03 18:18:08 +00:00
Vikrant Gupta
13249b5e69 feat(sqlstore): enable wal mode by default (#10822)
Some checks failed
build-staging / prepare (push) Has been cancelled
build-staging / js-build (push) Has been cancelled
build-staging / go-build (push) Has been cancelled
build-staging / staging (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
* chore(sqlstore): run integration tests on wal and immediate

* chore(sqlstore): remove the txlock changes

* chore(sqlstore): make wal the default mode
2026-04-03 15:48:37 +00:00
Vikrant Gupta
fec24c2cc3 fix(authz): better retry loop for roles (#10821) 2026-04-03 10:07:38 +00:00
Ishan
0d3226f363 feat: table migration (#10625)
* feat: table migration

* feat: ux and smoothness improved

* feat: testcases and ux improve

* feat: ux improve

* feat: fixed settings

* feat: fixed scroll issue on drag, ux issue on cols and made scrollbar same

* feat: row merging header fix

* feat: remove column and ux fixes

* feat: scss prettier fix

* feat: css and cols persistant issue

* feat: cols persistant issue

* feat: pr ready, styling folder and unwanted code removal

* Feat/table enhancement (#10773)

* feat: add width for remaining space for col

* feat: style fixes

* feat: do not move state indicator in table

* feat: minor changes

* Feat/table enhancement (#10795)

* feat: add width for remaining space for col

* feat: style fixes

* feat: do not move state indicator in table

* feat: minor changes

* feat: memoise custom table row to reduce re-renders

* feat: remove typography and add hover state to show actions

* feat: add typography styles to p tag

* feat: action btn style fix

* feat: minor change

---------

Co-authored-by: Aditya Singh <adityasinghssj1@gmail.com>
Co-authored-by: Aditya Singh <aditya@signoz.io>
2026-04-03 10:07:35 +00:00
218 changed files with 12366 additions and 6028 deletions

View File

@@ -55,6 +55,8 @@ jobs:
sqlstore-provider:
- postgres
- sqlite
sqlite-mode:
- wal
clickhouse-version:
- 25.5.6
- 25.12.5
@@ -102,6 +104,7 @@ jobs:
--basetemp=./tmp/ \
src/${{matrix.src}} \
--sqlstore-provider ${{matrix.sqlstore-provider}} \
--sqlite-mode ${{matrix.sqlite-mode}} \
--postgres-version ${{matrix.postgres-version}} \
--clickhouse-version ${{matrix.clickhouse-version}} \
--schema-migrator-version ${{matrix.schema-migrator-version}}

View File

@@ -86,7 +86,7 @@ sqlstore:
# The path to the SQLite database file.
path: /var/lib/signoz/signoz.db
# The journal mode for the sqlite database. Supported values: delete, wal.
mode: delete
mode: wal
# The timeout for the sqlite database to wait for a lock.
busy_timeout: 10s
# The default transaction locking behavior. Supported values: deferred, immediate, exclusive.

View File

@@ -327,27 +327,6 @@ components:
nullable: true
type: array
type: object
AuthtypesStorableRole:
properties:
createdAt:
format: date-time
type: string
description:
type: string
id:
type: string
name:
type: string
orgId:
type: string
type:
type: string
updatedAt:
format: date-time
type: string
required:
- id
type: object
AuthtypesTransaction:
properties:
object:
@@ -371,7 +350,7 @@ components:
id:
type: string
role:
$ref: '#/components/schemas/AuthtypesStorableRole'
$ref: '#/components/schemas/AuthtypesRole'
roleId:
type: string
updatedAt:
@@ -381,6 +360,11 @@ components:
type: string
required:
- id
- userId
- roleId
- createdAt
- updatedAt
- role
type: object
AuthtypesUserWithRoles:
properties:

View File

@@ -193,6 +193,7 @@ uv run pytest --basetemp=./tmp/ -vv --reuse src/passwordauthn/01_register.py::te
Tests can be configured using pytest options:
- `--sqlstore-provider` - Choose database provider (default: postgres)
- `--sqlite-mode` - SQLite journal mode: `delete` or `wal` (default: delete). Only relevant when `--sqlstore-provider=sqlite`.
- `--postgres-version` - PostgreSQL version (default: 15)
- `--clickhouse-version` - ClickHouse version (default: 25.5.6)
- `--zookeeper-version` - Zookeeper version (default: 3.7.1)
@@ -202,7 +203,6 @@ Example:
uv run pytest --basetemp=./tmp/ -vv --reuse --sqlstore-provider=postgres --postgres-version=14 src/auth/
```
## What should I remember?
- **Always use the `--reuse` flag** when setting up the environment to keep containers running
@@ -213,3 +213,4 @@ uv run pytest --basetemp=./tmp/ -vv --reuse --sqlstore-provider=postgres --postg
- **Use descriptive test names** that clearly indicate what is being tested
- **Leverage fixtures** for common setup and authentication
- **Test both success and failure scenarios** to ensure robust functionality
- **`--sqlite-mode=wal` does not work on macOS.** The integration test environment runs SigNoz inside a Linux container with the SQLite database file mounted from the macOS host. WAL mode requires shared memory between connections, and connections crossing the VM boundary (macOS host ↔ Linux container) cannot share the WAL index, resulting in `SQLITE_IOERR_SHORT_READ`. WAL mode is tested in CI on Linux only.

View File

@@ -5,7 +5,6 @@ import (
"encoding/json"
"fmt"
"log/slog"
"strings"
"sync"
"time"
@@ -64,12 +63,12 @@ func NewAnomalyRule(
BaseRule: baseRule,
}
switch strings.ToLower(p.RuleCondition.Seasonality) {
case "hourly":
switch p.RuleCondition.Seasonality {
case ruletypes.SeasonalityHourly:
t.seasonality = anomaly.SeasonalityHourly
case "daily":
case ruletypes.SeasonalityDaily:
t.seasonality = anomaly.SeasonalityDaily
case "weekly":
case ruletypes.SeasonalityWeekly:
t.seasonality = anomaly.SeasonalityWeekly
default:
t.seasonality = anomaly.SeasonalityDaily

View File

@@ -67,7 +67,7 @@ func TestAnomalyRule_NoData_AlertOnAbsent(t *testing.T) {
}},
},
SelectedQuery: "A",
Seasonality: "daily",
Seasonality: ruletypes.SeasonalityDaily,
Thresholds: &ruletypes.RuleThresholdData{
Kind: ruletypes.BasicThresholdKind,
Spec: ruletypes.BasicRuleThresholds{{
@@ -170,7 +170,7 @@ func TestAnomalyRule_NoData_AbsentFor(t *testing.T) {
}},
},
SelectedQuery: "A",
Seasonality: "daily",
Seasonality: ruletypes.SeasonalityDaily,
Thresholds: &ruletypes.RuleThresholdData{
Kind: ruletypes.BasicThresholdKind,
Spec: ruletypes.BasicRuleThresholds{{

View File

@@ -54,7 +54,7 @@
"@signozhq/checkbox": "0.0.2",
"@signozhq/combobox": "0.0.2",
"@signozhq/command": "0.0.0",
"@signozhq/design-tokens": "2.1.4",
"@signozhq/design-tokens": "2.1.1",
"@signozhq/dialog": "^0.0.2",
"@signozhq/drawer": "0.0.4",
"@signozhq/icons": "0.1.0",
@@ -68,8 +68,8 @@
"@signozhq/toggle-group": "0.0.1",
"@signozhq/tooltip": "0.0.2",
"@signozhq/ui": "0.0.5",
"@tanstack/react-table": "8.20.6",
"@tanstack/react-virtual": "3.11.2",
"@tanstack/react-table": "8.21.3",
"@tanstack/react-virtual": "3.13.22",
"@uiw/codemirror-theme-copilot": "4.23.11",
"@uiw/codemirror-theme-github": "4.24.1",
"@uiw/react-codemirror": "4.23.10",

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="#1eb4d4" viewBox="0 0 24 24"><title>Hasura</title><path d="M23.558 8.172c.707-2.152.282-6.447-1.09-8.032a.42.42 0 0 0-.664.051l-1.69 2.59a1.32 1.32 0 0 1-1.737.276C16.544 1.885 14.354 1.204 12 1.204s-4.544.68-6.378 1.853a1.326 1.326 0 0 1-1.736-.276L2.196.191A.42.42 0 0 0 1.532.14C.16 1.728-.265 6.023.442 8.172c.236.716.3 1.472.16 2.207-.137.73-.276 1.61-.276 2.223C.326 18.898 5.553 24 11.997 24c6.447 0 11.671-5.105 11.671-11.398 0-.613-.138-1.494-.276-2.223a4.47 4.47 0 0 1 .166-2.207m-11.56 13.284c-4.984 0-9.036-3.96-9.036-8.827q0-.239.014-.473c.18-3.316 2.243-6.15 5.16-7.5 1.17-.546 2.481-.848 3.864-.848s2.69.302 3.864.85c2.917 1.351 4.98 4.187 5.16 7.501q.013.236.014.473c-.003 4.864-4.057 8.824-9.04 8.824m3.915-5.43-2.31-3.91-1.98-3.26a.26.26 0 0 0-.223-.125H9.508a.26.26 0 0 0-.227.13.25.25 0 0 0 .003.254l1.895 3.109-2.542 3.787a.25.25 0 0 0-.011.259.26.26 0 0 0 .23.132h1.905a.26.26 0 0 0 .218-.116l1.375-2.096 1.233 2.088a.26.26 0 0 0 .224.127h1.878c.094 0 .18-.049.224-.127a.24.24 0 0 0 0-.251z"/></svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="#ea4b71" viewBox="0 0 24 24"><title>n8n</title><path d="M21.474 5.684a2.53 2.53 0 0 0-2.447 1.895H16.13a2.526 2.526 0 0 0-2.492 2.11l-.103.624a1.26 1.26 0 0 1-1.246 1.055h-1.001a2.527 2.527 0 0 0-4.893 0H4.973a2.527 2.527 0 1 0 0 1.264h1.422a2.527 2.527 0 0 0 4.894 0h1a1.26 1.26 0 0 1 1.247 1.055l.103.623a2.526 2.526 0 0 0 2.492 2.111h.37a2.527 2.527 0 1 0 0-1.263h-.37a1.26 1.26 0 0 1-1.246-1.056l-.103-.623A2.52 2.52 0 0 0 13.96 12a2.52 2.52 0 0 0 .82-1.48l.104-.622a1.26 1.26 0 0 1 1.246-1.056h2.896a2.527 2.527 0 1 0 2.447-3.158m0 1.263a1.263 1.263 0 0 1 1.263 1.263 1.263 1.263 0 0 1-1.263 1.264A1.263 1.263 0 0 1 20.21 8.21a1.263 1.263 0 0 1 1.264-1.263m-18.948 3.79A1.263 1.263 0 0 1 3.79 12a1.263 1.263 0 0 1-1.264 1.263A1.263 1.263 0 0 1 1.263 12a1.263 1.263 0 0 1 1.263-1.263m6.316 0A1.263 1.263 0 0 1 10.105 12a1.263 1.263 0 0 1-1.263 1.263A1.263 1.263 0 0 1 7.58 12a1.263 1.263 0 0 1 1.263-1.263m10.106 3.79a1.263 1.263 0 0 1 1.263 1.263 1.263 1.263 0 0 1-1.263 1.263 1.263 1.263 0 0 1-1.264-1.263 1.263 1.263 0 0 1 1.263-1.264"/></svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 5.8 KiB

View File

@@ -1,9 +1,8 @@
import { ReactChild, useCallback, useEffect, useMemo, useState } from 'react';
import { useQuery } from 'react-query';
import { matchPath, Redirect, useLocation } from 'react-router-dom';
import getLocalStorageApi from 'api/browser/localstorage/get';
import setLocalStorageApi from 'api/browser/localstorage/set';
import getAll from 'api/v1/user/get';
import { useListUsers } from 'api/generated/services/users';
import { FeatureKeys } from 'constants/features';
import { LOCALSTORAGE } from 'constants/localStorage';
import { ORG_PREFERENCES } from 'constants/orgPreferences';
@@ -12,12 +11,9 @@ import { useGetTenantLicense } from 'hooks/useGetTenantLicense';
import history from 'lib/history';
import { isEmpty } from 'lodash-es';
import { useAppContext } from 'providers/App/App';
import { SuccessResponseV2 } from 'types/api';
import APIError from 'types/api/error';
import { LicensePlatform, LicenseState } from 'types/api/licensesV3/getActive';
import { OrgPreference } from 'types/api/preferences/preference';
import { Organization } from 'types/api/user/getOrganization';
import { UserResponse } from 'types/api/user/getUser';
import { USER_ROLES } from 'types/roles';
import { routePermission } from 'utils/permission';
@@ -63,18 +59,10 @@ function PrivateRoute({ children }: PrivateRouteProps): JSX.Element {
const [orgData, setOrgData] = useState<Organization | undefined>(undefined);
const { data: usersData, isFetching: isFetchingUsers } = useQuery<
SuccessResponseV2<UserResponse[]> | undefined,
APIError
>({
queryFn: () => {
if (orgData && orgData.id !== undefined) {
return getAll();
}
return undefined;
const { data: usersData, isFetching: isFetchingUsers } = useListUsers({
query: {
enabled: !isEmpty(orgData) && user.role === 'ADMIN',
},
queryKey: ['getOrgUser'],
enabled: !isEmpty(orgData) && user.role === 'ADMIN',
});
const checkFirstTimeUser = useCallback((): boolean => {

View File

@@ -67,9 +67,12 @@ jest.mock('hooks/useGetTenantLicense', () => ({
// Mock react-query for users fetch
let mockUsersData: { email: string }[] = [];
jest.mock('api/v1/user/get', () => ({
__esModule: true,
default: jest.fn(() => Promise.resolve({ data: mockUsersData })),
jest.mock('api/generated/services/users', () => ({
...jest.requireActual('api/generated/services/users'),
useListUsers: jest.fn(() => ({
data: { data: mockUsersData },
isFetching: false,
})),
}));
const queryClient = new QueryClient({

View File

@@ -18,7 +18,7 @@ import AppLayout from 'container/AppLayout';
import Hex from 'crypto-js/enc-hex';
import HmacSHA256 from 'crypto-js/hmac-sha256';
import { KeyboardHotkeysProvider } from 'hooks/hotkeys/useKeyboardHotkeys';
import { useThemeConfig } from 'hooks/useDarkMode';
import { useIsDarkMode, useThemeConfig } from 'hooks/useDarkMode';
import { useGetTenantLicense } from 'hooks/useGetTenantLicense';
import { NotificationProvider } from 'hooks/useNotifications';
import { ResourceProvider } from 'hooks/useResourceAttribute';
@@ -212,6 +212,12 @@ function App(): JSX.Element {
activeLicenseFetchError,
]);
const isDarkMode = useIsDarkMode();
useEffect(() => {
window.Pylon?.('setTheme', isDarkMode ? 'dark' : 'light');
}, [isDarkMode]);
useEffect(() => {
if (
pathname === ROUTES.ONBOARDING ||

View File

@@ -425,39 +425,6 @@ export interface AuthtypesSessionContextDTO {
orgs?: AuthtypesOrgSessionContextDTO[] | null;
}
export interface AuthtypesStorableRoleDTO {
/**
* @type string
* @format date-time
*/
createdAt?: Date;
/**
* @type string
*/
description?: string;
/**
* @type string
*/
id: string;
/**
* @type string
*/
name?: string;
/**
* @type string
*/
orgId?: string;
/**
* @type string
*/
type?: string;
/**
* @type string
* @format date-time
*/
updatedAt?: Date;
}
export interface AuthtypesTransactionDTO {
object: AuthtypesObjectDTO;
/**
@@ -475,25 +442,25 @@ export interface AuthtypesUserRoleDTO {
* @type string
* @format date-time
*/
createdAt?: Date;
createdAt: Date;
/**
* @type string
*/
id: string;
role?: AuthtypesStorableRoleDTO;
role: AuthtypesRoleDTO;
/**
* @type string
*/
roleId?: string;
roleId: string;
/**
* @type string
* @format date-time
*/
updatedAt?: Date;
updatedAt: Date;
/**
* @type string
*/
userId?: string;
userId: string;
}
export interface AuthtypesUserWithRolesDTO {

View File

@@ -13,7 +13,9 @@ export interface HostListPayload {
orderBy?: {
columnName: string;
order: 'asc' | 'desc';
};
} | null;
start?: number;
end?: number;
}
export interface TimeSeriesValue {

View File

@@ -1,10 +1,22 @@
import axios from 'api';
import { ErrorResponseHandler } from 'api/ErrorResponseHandler';
import { UnderscoreToDotMap } from 'api/utils';
import { AxiosError } from 'axios';
import { ErrorResponse, SuccessResponse } from 'types/api';
import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
import { TagFilter } from 'types/api/queryBuilder/queryBuilderData';
import { K8sBaseFilters } from '../Base/K8sBaseList';
import { UnderscoreToDotMap } from '../utils';
export interface K8sDaemonSetsListPayload {
filters: TagFilter;
groupBy?: BaseAutocompleteData[];
offset?: number;
limit?: number;
orderBy?: {
columnName: string;
order: 'asc' | 'desc';
};
}
export interface K8sDaemonSetsData {
daemonSetName: string;
@@ -56,19 +68,20 @@ export function mapDaemonSetsMeta(
}
export const getK8sDaemonSetsList = async (
props: K8sBaseFilters,
props: K8sDaemonSetsListPayload,
signal?: AbortSignal,
headers?: Record<string, string>,
dotMetricsEnabled = false,
): Promise<SuccessResponse<K8sDaemonSetsListResponse> | ErrorResponse> => {
try {
// filter prep (unchanged)…
const requestProps =
dotMetricsEnabled && Array.isArray(props.filters?.items)
? {
...props,
filters: {
...props.filters,
items: props.filters?.items.reduce<typeof props.filters.items>(
items: props.filters.items.reduce<typeof props.filters.items>(
(acc, item) => {
if (item.value === undefined) {
return acc;
@@ -101,6 +114,7 @@ export const getK8sDaemonSetsList = async (
});
const payload: K8sDaemonSetsListResponse = response.data;
// single-line meta mapping
payload.data.records = payload.data.records.map((record) => ({
...record,
meta: mapDaemonSetsMeta(record.meta as Record<string, unknown>),

View File

@@ -1,35 +1,21 @@
import axios from 'api';
import { ErrorResponseHandler } from 'api/ErrorResponseHandler';
import { UnderscoreToDotMap } from 'api/utils';
import { AxiosError } from 'axios';
import { ErrorResponse, SuccessResponse } from 'types/api';
import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
import { TagFilter } from 'types/api/queryBuilder/queryBuilderData';
import { K8sBaseFilters } from '../Base/K8sBaseList';
import { UnderscoreToDotMap } from '../utils';
export const podsMetaMap = [
{ dot: 'k8s.cronjob.name', under: 'k8s_cronjob_name' },
{ dot: 'k8s.daemonset.name', under: 'k8s_daemonset_name' },
{ dot: 'k8s.deployment.name', under: 'k8s_deployment_name' },
{ dot: 'k8s.job.name', under: 'k8s_job_name' },
{ dot: 'k8s.namespace.name', under: 'k8s_namespace_name' },
{ dot: 'k8s.node.name', under: 'k8s_node_name' },
{ dot: 'k8s.pod.name', under: 'k8s_pod_name' },
{ dot: 'k8s.pod.uid', under: 'k8s_pod_uid' },
{ dot: 'k8s.statefulset.name', under: 'k8s_statefulset_name' },
{ dot: 'k8s.cluster.name', under: 'k8s_cluster_name' },
] as const;
export function mapPodsMeta(raw: Record<string, unknown>): K8sPodsData['meta'] {
// clone everything
const out: Record<string, unknown> = { ...raw };
// overlay only the dot→under mappings
podsMetaMap.forEach(({ dot, under }) => {
if (dot in raw) {
const v = raw[dot];
out[under] = typeof v === 'string' ? v : raw[under];
}
});
return out as K8sPodsData['meta'];
export interface K8sPodsListPayload {
filters: TagFilter;
groupBy?: BaseAutocompleteData[];
offset?: number;
limit?: number;
orderBy?: {
columnName: string;
order: 'asc' | 'desc';
};
}
export interface TimeSeriesValue {
@@ -85,9 +71,35 @@ export interface K8sPodsListResponse {
};
}
// TODO: Remove this method once we move this to OpenAPI
export const podsMetaMap = [
{ dot: 'k8s.cronjob.name', under: 'k8s_cronjob_name' },
{ dot: 'k8s.daemonset.name', under: 'k8s_daemonset_name' },
{ dot: 'k8s.deployment.name', under: 'k8s_deployment_name' },
{ dot: 'k8s.job.name', under: 'k8s_job_name' },
{ dot: 'k8s.namespace.name', under: 'k8s_namespace_name' },
{ dot: 'k8s.node.name', under: 'k8s_node_name' },
{ dot: 'k8s.pod.name', under: 'k8s_pod_name' },
{ dot: 'k8s.pod.uid', under: 'k8s_pod_uid' },
{ dot: 'k8s.statefulset.name', under: 'k8s_statefulset_name' },
{ dot: 'k8s.cluster.name', under: 'k8s_cluster_name' },
] as const;
export function mapPodsMeta(raw: Record<string, unknown>): K8sPodsData['meta'] {
// clone everything
const out: Record<string, unknown> = { ...raw };
// overlay only the dot→under mappings
podsMetaMap.forEach(({ dot, under }) => {
if (dot in raw) {
const v = raw[dot];
out[under] = typeof v === 'string' ? v : raw[under];
}
});
return out as K8sPodsData['meta'];
}
// getK8sPodsList
export const getK8sPodsList = async (
props: K8sBaseFilters,
props: K8sPodsListPayload,
signal?: AbortSignal,
headers?: Record<string, string>,
dotMetricsEnabled = false,
@@ -99,7 +111,7 @@ export const getK8sPodsList = async (
...props,
filters: {
...props.filters,
items: props.filters?.items.reduce<typeof props.filters.items>(
items: props.filters.items.reduce<typeof props.filters.items>(
(acc, item) => {
if (item.value === undefined) {
return acc;

View File

@@ -1,26 +0,0 @@
import { ApiV2Instance as axios } from 'api';
import { ErrorResponseHandler } from 'api/ErrorResponseHandler';
import { AxiosError } from 'axios';
import { ErrorResponse, SuccessResponse } from 'types/api';
import { PayloadProps, Props } from 'types/api/user/editOrg';
const editOrg = async (
props: Props,
): Promise<SuccessResponse<PayloadProps> | ErrorResponse> => {
try {
const response = await axios.put(`/orgs/me`, {
displayName: props.displayName,
});
return {
statusCode: 204,
error: null,
message: response.data.status,
payload: response.data,
};
} catch (error) {
return ErrorResponseHandler(error as AxiosError);
}
};
export default editOrg;

View File

@@ -1,28 +0,0 @@
import axios from 'api';
import { ErrorResponseHandler } from 'api/ErrorResponseHandler';
import { AxiosError } from 'axios';
import { ErrorResponse, SuccessResponse } from 'types/api';
import { PayloadProps } from 'types/api/user/getOrganization';
const getOrganization = async (
token?: string,
): Promise<SuccessResponse<PayloadProps> | ErrorResponse> => {
try {
const response = await axios.get(`/org`, {
headers: {
Authorization: `bearer ${token}`,
},
});
return {
statusCode: 200,
error: null,
message: response.data.status,
payload: response.data,
};
} catch (error) {
return ErrorResponseHandler(error as AxiosError);
}
};
export default getOrganization;

View File

@@ -1,21 +0,0 @@
import axios from 'api';
import { ErrorResponseHandlerV2 } from 'api/ErrorResponseHandlerV2';
import { AxiosError } from 'axios';
import { ErrorV2Resp, SuccessResponseV2 } from 'types/api';
import { UserResponse } from 'types/api/user/getUser';
import { PayloadProps } from 'types/api/user/getUsers';
const getAll = async (): Promise<SuccessResponseV2<UserResponse[]>> => {
try {
const response = await axios.get<PayloadProps>(`/user`);
return {
httpStatusCode: response.status,
data: response.data.data,
};
} catch (error) {
ErrorResponseHandlerV2(error as AxiosError<ErrorV2Resp>);
}
};
export default getAll;

View File

@@ -1,22 +0,0 @@
import axios from 'api';
import { ErrorResponseHandlerV2 } from 'api/ErrorResponseHandlerV2';
import { AxiosError } from 'axios';
import { ErrorV2Resp, SuccessResponseV2 } from 'types/api';
import { PayloadProps, Props, UserResponse } from 'types/api/user/getUser';
const getUser = async (
props: Props,
): Promise<SuccessResponseV2<UserResponse>> => {
try {
const response = await axios.get<PayloadProps>(`/user/${props.userId}`);
return {
httpStatusCode: response.status,
data: response.data.data,
};
} catch (error) {
ErrorResponseHandlerV2(error as AxiosError<ErrorV2Resp>);
}
};
export default getUser;

View File

@@ -1,23 +0,0 @@
import axios from 'api';
import { ErrorResponseHandlerV2 } from 'api/ErrorResponseHandlerV2';
import { AxiosError } from 'axios';
import { ErrorV2Resp, SuccessResponseV2 } from 'types/api';
import { Props } from 'types/api/user/editUser';
const update = async (props: Props): Promise<SuccessResponseV2<null>> => {
try {
const response = await axios.put(`/user/${props.userId}`, {
displayName: props.displayName,
role: props.role,
});
return {
httpStatusCode: response.status,
data: null,
};
} catch (error) {
ErrorResponseHandlerV2(error as AxiosError<ErrorV2Resp>);
}
};
export default update;

View File

@@ -1,20 +0,0 @@
import axios from 'api';
import { ErrorResponseHandlerV2 } from 'api/ErrorResponseHandlerV2';
import { AxiosError } from 'axios';
import { ErrorV2Resp, SuccessResponseV2 } from 'types/api';
import { PayloadProps, UserResponse } from 'types/api/user/getUser';
const get = async (): Promise<SuccessResponseV2<UserResponse>> => {
try {
const response = await axios.get<PayloadProps>(`/user/me`);
return {
httpStatusCode: response.status,
data: response.data.data,
};
} catch (error) {
ErrorResponseHandlerV2(error as AxiosError<ErrorV2Resp>);
}
};
export default get;

View File

@@ -0,0 +1,75 @@
import { Button } from '@signozhq/button';
import { DialogFooter, DialogWrapper } from '@signozhq/dialog';
import { Trash2, X } from '@signozhq/icons';
import { MemberRow } from 'components/MembersTable/MembersTable';
interface DeleteMemberDialogProps {
open: boolean;
isInvited: boolean;
member: MemberRow | null;
isDeleting: boolean;
onClose: () => void;
onConfirm: () => void;
}
function DeleteMemberDialog({
open,
isInvited,
member,
isDeleting,
onClose,
onConfirm,
}: DeleteMemberDialogProps): JSX.Element {
const title = isInvited ? 'Revoke Invite' : 'Delete Member';
const body = isInvited ? (
<>
Are you sure you want to revoke the invite for{' '}
<strong>{member?.email}</strong>? They will no longer be able to join the
workspace using this invite.
</>
) : (
<>
Are you sure you want to delete{' '}
<strong>{member?.name || member?.email}</strong>? This will remove their
access to the workspace.
</>
);
return (
<DialogWrapper
open={open}
onOpenChange={(isOpen): void => {
if (!isOpen) {
onClose();
}
}}
title={title}
width="narrow"
className="alert-dialog delete-dialog"
showCloseButton={false}
disableOutsideClick={false}
>
<p className="delete-dialog__body">{body}</p>
<DialogFooter className="delete-dialog__footer">
<Button variant="solid" color="secondary" size="sm" onClick={onClose}>
<X size={12} />
Cancel
</Button>
<Button
variant="solid"
color="destructive"
size="sm"
disabled={isDeleting}
onClick={onConfirm}
>
<Trash2 size={12} />
{isDeleting ? 'Processing...' : title}
</Button>
</DialogFooter>
</DialogWrapper>
);
}
export default DeleteMemberDialog;

View File

@@ -45,8 +45,8 @@
display: flex;
align-items: center;
justify-content: space-between;
height: 32px;
padding: 0 var(--padding-2);
min-height: 32px;
padding: var(--padding-1) var(--padding-2);
border-radius: 2px;
background: var(--l2-background);
border: 1px solid var(--border);
@@ -57,6 +57,13 @@
}
}
&__disabled-roles {
display: flex;
flex-wrap: wrap;
gap: var(--spacing-2);
flex: 1;
}
&__email-text {
font-size: var(--font-size-sm);
font-weight: var(--font-weight-normal);
@@ -76,35 +83,6 @@
opacity: 0.6;
}
&__role-select {
width: 100%;
height: 32px;
.ant-select-selector {
background-color: var(--l2-background) !important;
border-color: var(--border) !important;
border-radius: 2px;
padding: 0 var(--padding-2) !important;
display: flex;
align-items: center;
}
.ant-select-selection-item {
font-size: var(--font-size-sm);
color: var(--l1-foreground);
line-height: 32px;
letter-spacing: -0.07px;
}
.ant-select-arrow {
color: var(--foreground);
}
&:not(.ant-select-disabled):hover .ant-select-selector {
border-color: var(--foreground);
}
}
&__meta {
display: flex;
flex-direction: column;
@@ -168,6 +146,10 @@
flex-shrink: 0;
}
&__tooltip-wrapper {
display: inline-flex;
}
&__footer-btn {
display: inline-flex;
align-items: center;

View File

@@ -2,38 +2,65 @@ import { useCallback, useEffect, useState } from 'react';
import { useCopyToClipboard } from 'react-use';
import { Badge } from '@signozhq/badge';
import { Button } from '@signozhq/button';
import { DialogFooter, DialogWrapper } from '@signozhq/dialog';
import { DrawerWrapper } from '@signozhq/drawer';
import {
Check,
ChevronDown,
Copy,
LockKeyhole,
RefreshCw,
Trash2,
X,
} from '@signozhq/icons';
import { LockKeyhole, RefreshCw, Trash2, X } from '@signozhq/icons';
import { Input } from '@signozhq/input';
import { toast } from '@signozhq/sonner';
import { Select } from 'antd';
import { Skeleton, Tooltip } from 'antd';
import { convertToApiError } from 'api/ErrorResponseHandlerForGeneratedAPIs';
import { RenderErrorResponseDTO } from 'api/generated/services/sigNoz.schemas';
import type { RenderErrorResponseDTO } from 'api/generated/services/sigNoz.schemas';
import {
getResetPasswordToken,
useDeleteUser,
useUpdateUserDeprecated,
useGetUser,
useUpdateMyUserV2,
useUpdateUser,
} from 'api/generated/services/users';
import { AxiosError } from 'axios';
import { MemberRow } from 'components/MembersTable/MembersTable';
import RolesSelect, { useRoles } from 'components/RolesSelect';
import SaveErrorItem from 'components/ServiceAccountDrawer/SaveErrorItem';
import type { SaveError } from 'components/ServiceAccountDrawer/utils';
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
import { MemberStatus } from 'container/MembersSettings/utils';
import { capitalize } from 'lodash-es';
import {
MemberRoleUpdateFailure,
useMemberRoleManager,
} from 'hooks/member/useMemberRoleManager';
import { useAppContext } from 'providers/App/App';
import { useTimezone } from 'providers/Timezone';
import { ROLES } from 'types/roles';
import { popupContainer } from 'utils/selectPopupContainer';
import APIError from 'types/api/error';
import { toAPIError } from 'utils/errorUtils';
import DeleteMemberDialog from './DeleteMemberDialog';
import ResetLinkDialog from './ResetLinkDialog';
import './EditMemberDrawer.styles.scss';
const ROOT_USER_TOOLTIP = 'This operation is not supported for the root user';
const SELF_DELETE_TOOLTIP =
'You cannot perform this action on your own account';
function getDeleteTooltip(
isRootUser: boolean,
isSelf: boolean,
): string | undefined {
if (isRootUser) {
return ROOT_USER_TOOLTIP;
}
if (isSelf) {
return SELF_DELETE_TOOLTIP;
}
return undefined;
}
function toSaveApiError(err: unknown): APIError {
return (
convertToApiError(err as AxiosError<RenderErrorResponseDTO>) ??
toAPIError(err as AxiosError<RenderErrorResponseDTO>)
);
}
export interface EditMemberDrawerProps {
member: MemberRow | null;
open: boolean;
@@ -49,9 +76,12 @@ function EditMemberDrawer({
onComplete,
}: EditMemberDrawerProps): JSX.Element {
const { formatTimezoneAdjustedTimestamp } = useTimezone();
const { user: currentUser } = useAppContext();
const [displayName, setDisplayName] = useState('');
const [selectedRole, setSelectedRole] = useState<ROLES>('VIEWER');
const [localDisplayName, setLocalDisplayName] = useState('');
const [localRole, setLocalRole] = useState('');
const [isSaving, setIsSaving] = useState(false);
const [saveErrors, setSaveErrors] = useState<SaveError[]>([]);
const [isGeneratingLink, setIsGeneratingLink] = useState(false);
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
const [resetLink, setResetLink] = useState<string | null>(null);
@@ -60,32 +90,63 @@ function EditMemberDrawer({
const [linkType, setLinkType] = useState<'invite' | 'reset' | null>(null);
const isInvited = member?.status === MemberStatus.Invited;
const isSelf = !!member?.id && member.id === currentUser?.id;
const { mutate: updateUser, isLoading: isSaving } = useUpdateUserDeprecated({
mutation: {
onSuccess: (): void => {
toast.success('Member details updated successfully', { richColors: true });
onComplete();
onClose();
},
onError: (err): void => {
const errMessage =
convertToApiError(
err as AxiosError<RenderErrorResponseDTO, unknown> | null,
)?.getErrorMessage() || 'An error occurred';
toast.error(`Failed to update member details: ${errMessage}`, {
richColors: true,
});
},
},
});
const {
data: fetchedUser,
isLoading: isFetchingUser,
refetch: refetchUser,
} = useGetUser(
{ id: member?.id ?? '' },
{ query: { enabled: open && !!member?.id } },
);
const isRootUser = !!fetchedUser?.data?.isRoot;
const {
roles: availableRoles,
isLoading: rolesLoading,
isError: rolesError,
error: rolesErrorObj,
refetch: refetchRoles,
} = useRoles();
const { fetchedRoleIds, applyDiff } = useMemberRoleManager(
member?.id ?? '',
open && !!member?.id,
);
const fetchedDisplayName =
fetchedUser?.data?.displayName ?? member?.name ?? '';
const fetchedUserId = fetchedUser?.data?.id;
const fetchedUserDisplayName = fetchedUser?.data?.displayName;
useEffect(() => {
if (fetchedUserId) {
setLocalDisplayName(fetchedUserDisplayName ?? member?.name ?? '');
}
setSaveErrors([]);
}, [fetchedUserId, fetchedUserDisplayName, member?.name]);
useEffect(() => {
setLocalRole(fetchedRoleIds[0] ?? '');
}, [fetchedRoleIds]);
const isDirty =
member !== null &&
fetchedUser != null &&
(localDisplayName !== fetchedDisplayName ||
localRole !== (fetchedRoleIds[0] ?? ''));
const { mutateAsync: updateMyUser } = useUpdateMyUserV2();
const { mutateAsync: updateUser } = useUpdateUser();
const { mutate: deleteUser, isLoading: isDeleting } = useDeleteUser({
mutation: {
onSuccess: (): void => {
toast.success(
isInvited ? 'Invite revoked successfully' : 'Member deleted successfully',
{ richColors: true },
{ richColors: true, position: 'top-right' },
);
setShowDeleteConfirm(false);
onComplete();
@@ -99,53 +160,168 @@ function EditMemberDrawer({
const prefix = isInvited
? 'Failed to revoke invite'
: 'Failed to delete member';
toast.error(`${prefix}: ${errMessage}`, { richColors: true });
toast.error(`${prefix}: ${errMessage}`, {
richColors: true,
position: 'top-right',
});
},
},
});
useEffect(() => {
if (member) {
setDisplayName(member.name ?? '');
setSelectedRole(member.role);
}
}, [member]);
const isDirty =
member !== null &&
(displayName !== (member.name ?? '') || selectedRole !== member.role);
const formatTimestamp = useCallback(
(ts: string | null | undefined): string => {
if (!ts) {
return '—';
const makeRoleRetry = useCallback(
(
context: string,
rawRetry: () => Promise<void>,
) => async (): Promise<void> => {
try {
await rawRetry();
setSaveErrors((prev) => prev.filter((e) => e.context !== context));
refetchUser();
} catch (err) {
setSaveErrors((prev) =>
prev.map((e) =>
e.context === context ? { ...e, apiError: toSaveApiError(err) } : e,
),
);
}
const d = new Date(ts);
if (Number.isNaN(d.getTime())) {
return '—';
}
return formatTimezoneAdjustedTimestamp(ts, DATE_TIME_FORMATS.DASH_DATETIME);
},
[formatTimezoneAdjustedTimestamp],
[refetchUser],
);
const handleSave = useCallback((): void => {
const retryNameUpdate = useCallback(async (): Promise<void> => {
if (!member) {
return;
}
try {
if (isSelf) {
await updateMyUser({ data: { displayName: localDisplayName } });
} else {
await updateUser({
pathParams: { id: member.id },
data: { displayName: localDisplayName },
});
}
setSaveErrors((prev) => prev.filter((e) => e.context !== 'Name update'));
refetchUser();
} catch (err) {
setSaveErrors((prev) =>
prev.map((e) =>
e.context === 'Name update' ? { ...e, apiError: toSaveApiError(err) } : e,
),
);
}
}, [member, isSelf, localDisplayName, updateMyUser, updateUser, refetchUser]);
const handleSave = useCallback(async (): Promise<void> => {
if (!member || !isDirty) {
return;
}
updateUser({
pathParams: { id: member.id },
data: { id: member.id, displayName, role: selectedRole },
});
}, [member, isDirty, displayName, selectedRole, updateUser]);
setSaveErrors([]);
setIsSaving(true);
try {
const nameChanged = localDisplayName !== fetchedDisplayName;
const rolesChanged = localRole !== (fetchedRoleIds[0] ?? '');
const namePromise = nameChanged
? isSelf
? updateMyUser({ data: { displayName: localDisplayName } })
: updateUser({
pathParams: { id: member.id },
data: { displayName: localDisplayName },
})
: Promise.resolve();
const [nameResult, rolesResult] = await Promise.allSettled([
namePromise,
rolesChanged
? applyDiff([localRole].filter(Boolean), availableRoles)
: Promise.resolve([]),
]);
const errors: SaveError[] = [];
if (nameResult.status === 'rejected') {
errors.push({
context: 'Name update',
apiError: toSaveApiError(nameResult.reason),
onRetry: retryNameUpdate,
});
}
if (rolesResult.status === 'rejected') {
errors.push({
context: 'Roles update',
apiError: toSaveApiError(rolesResult.reason),
onRetry: async (): Promise<void> => {
const failures = await applyDiff(
[localRole].filter(Boolean),
availableRoles,
);
setSaveErrors((prev) => {
const rest = prev.filter((e) => e.context !== 'Roles update');
return [
...rest,
...failures.map((f: MemberRoleUpdateFailure) => {
const ctx = `Role '${f.roleName}'`;
return {
context: ctx,
apiError: toSaveApiError(f.error),
onRetry: makeRoleRetry(ctx, f.onRetry),
};
}),
];
});
refetchUser();
},
});
} else {
for (const failure of rolesResult.value ?? []) {
const context = `Role '${failure.roleName}'`;
errors.push({
context,
apiError: toSaveApiError(failure.error),
onRetry: makeRoleRetry(context, failure.onRetry),
});
}
}
if (errors.length > 0) {
setSaveErrors(errors);
} else {
toast.success('Member details updated successfully', {
richColors: true,
position: 'top-right',
});
onComplete();
}
refetchUser();
} finally {
setIsSaving(false);
}
}, [
member,
isDirty,
isSelf,
localDisplayName,
localRole,
fetchedDisplayName,
fetchedRoleIds,
updateMyUser,
updateUser,
applyDiff,
availableRoles,
refetchUser,
retryNameUpdate,
makeRoleRetry,
onComplete,
]);
const handleDelete = useCallback((): void => {
if (!member) {
return;
}
deleteUser({
pathParams: { id: member.id },
});
deleteUser({ pathParams: { id: member.id } });
}, [member, deleteUser]);
const handleGenerateResetLink = useCallback(async (): Promise<void> => {
@@ -176,29 +352,28 @@ function EditMemberDrawer({
} finally {
setIsGeneratingLink(false);
}
}, [member, isInvited, setLinkType, onClose]);
}, [member, isInvited, onClose]);
const [copyState, copyToClipboard] = useCopyToClipboard();
const handleCopyResetLink = useCallback(async (): Promise<void> => {
const handleCopyResetLink = useCallback((): void => {
if (!resetLink) {
return;
}
copyToClipboard(resetLink);
setHasCopiedResetLink(true);
setTimeout(() => setHasCopiedResetLink(false), 2000);
toast.success(
const message =
linkType === 'invite'
? 'Invite link copied to clipboard'
: 'Reset link copied to clipboard',
{ richColors: true },
);
: 'Reset link copied to clipboard';
toast.success(message, { richColors: true, position: 'top-right' });
}, [resetLink, copyToClipboard, linkType]);
useEffect(() => {
if (copyState.error) {
toast.error('Failed to copy link', {
richColors: true,
position: 'top-right',
});
}
}, [copyState.error]);
@@ -210,102 +385,176 @@ function EditMemberDrawer({
const joinedOnLabel = isInvited ? 'Invited On' : 'Joined On';
const drawerContent = (
<div className="edit-member-drawer__layout">
<div className="edit-member-drawer__body">
<div className="edit-member-drawer__field">
<label className="edit-member-drawer__label" htmlFor="member-name">
Name
</label>
const formatTimestamp = useCallback(
(ts: string | null | undefined): string => {
if (!ts) {
return '—';
}
const d = new Date(ts);
if (Number.isNaN(d.getTime())) {
return '—';
}
return formatTimezoneAdjustedTimestamp(ts, DATE_TIME_FORMATS.DASH_DATETIME);
},
[formatTimezoneAdjustedTimestamp],
);
const drawerBody = isFetchingUser ? (
<Skeleton active paragraph={{ rows: 6 }} />
) : (
<>
<div className="edit-member-drawer__field">
<label className="edit-member-drawer__label" htmlFor="member-name">
Name
</label>
<Tooltip title={isRootUser ? ROOT_USER_TOOLTIP : undefined}>
<Input
id="member-name"
value={displayName}
onChange={(e): void => setDisplayName(e.target.value)}
value={localDisplayName}
onChange={(e): void => {
setLocalDisplayName(e.target.value);
setSaveErrors((prev) =>
prev.filter((err) => err.context !== 'Name update'),
);
}}
className="edit-member-drawer__input"
placeholder="Enter name"
disabled={isRootUser}
/>
</div>
</Tooltip>
</div>
<div className="edit-member-drawer__field">
<label className="edit-member-drawer__label" htmlFor="member-email">
Email Address
</label>
<div className="edit-member-drawer__input-wrapper edit-member-drawer__input-wrapper--disabled">
<span className="edit-member-drawer__email-text">
{member?.email || '—'}
</span>
<LockKeyhole size={16} className="edit-member-drawer__lock-icon" />
</div>
</div>
<div className="edit-member-drawer__field">
<label className="edit-member-drawer__label" htmlFor="member-role">
Roles
</label>
<Select
id="member-role"
value={selectedRole}
onChange={(role): void => setSelectedRole(role as ROLES)}
className="edit-member-drawer__role-select"
suffixIcon={<ChevronDown size={14} />}
getPopupContainer={popupContainer}
>
<Select.Option value="ADMIN">{capitalize('ADMIN')}</Select.Option>
<Select.Option value="EDITOR">{capitalize('EDITOR')}</Select.Option>
<Select.Option value="VIEWER">{capitalize('VIEWER')}</Select.Option>
</Select>
</div>
<div className="edit-member-drawer__meta">
<div className="edit-member-drawer__meta-item">
<span className="edit-member-drawer__meta-label">Status</span>
{member?.status === MemberStatus.Active ? (
<Badge color="forest" variant="outline">
ACTIVE
</Badge>
) : (
<Badge color="amber" variant="outline">
INVITED
</Badge>
)}
</div>
<div className="edit-member-drawer__meta-item">
<span className="edit-member-drawer__meta-label">{joinedOnLabel}</span>
<Badge color="vanilla">{formatTimestamp(member?.joinedOn)}</Badge>
</div>
{!isInvited && (
<div className="edit-member-drawer__meta-item">
<span className="edit-member-drawer__meta-label">Last Modified</span>
<Badge color="vanilla">{formatTimestamp(member?.updatedAt)}</Badge>
</div>
)}
<div className="edit-member-drawer__field">
<label className="edit-member-drawer__label" htmlFor="member-email">
Email Address
</label>
<div className="edit-member-drawer__input-wrapper edit-member-drawer__input-wrapper--disabled">
<span className="edit-member-drawer__email-text">
{member?.email || '—'}
</span>
<LockKeyhole size={16} className="edit-member-drawer__lock-icon" />
</div>
</div>
<div className="edit-member-drawer__field">
<label className="edit-member-drawer__label" htmlFor="member-role">
Roles
</label>
{isSelf || isRootUser ? (
<Tooltip
title={isRootUser ? ROOT_USER_TOOLTIP : 'You cannot modify your own role'}
>
<div className="edit-member-drawer__input-wrapper edit-member-drawer__input-wrapper--disabled">
<div className="edit-member-drawer__disabled-roles">
{localRole ? (
<Badge color="vanilla">
{availableRoles.find((r) => r.id === localRole)?.name ?? localRole}
</Badge>
) : (
<span className="edit-member-drawer__email-text"></span>
)}
</div>
<LockKeyhole size={16} className="edit-member-drawer__lock-icon" />
</div>
</Tooltip>
) : (
<RolesSelect
id="member-role"
roles={availableRoles}
loading={rolesLoading}
isError={rolesError}
error={rolesErrorObj}
onRefetch={refetchRoles}
value={localRole}
onChange={(role): void => {
setLocalRole(role);
setSaveErrors((prev) =>
prev.filter(
(err) =>
err.context !== 'Roles update' && !err.context.startsWith("Role '"),
),
);
}}
placeholder="Select role"
/>
)}
</div>
<div className="edit-member-drawer__meta">
<div className="edit-member-drawer__meta-item">
<span className="edit-member-drawer__meta-label">Status</span>
{member?.status === MemberStatus.Active ? (
<Badge color="forest" variant="outline">
ACTIVE
</Badge>
) : (
<Badge color="amber" variant="outline">
INVITED
</Badge>
)}
</div>
<div className="edit-member-drawer__meta-item">
<span className="edit-member-drawer__meta-label">{joinedOnLabel}</span>
<Badge color="vanilla">{formatTimestamp(member?.joinedOn)}</Badge>
</div>
{!isInvited && (
<div className="edit-member-drawer__meta-item">
<span className="edit-member-drawer__meta-label">Last Modified</span>
<Badge color="vanilla">{formatTimestamp(member?.updatedAt)}</Badge>
</div>
)}
</div>
{saveErrors.length > 0 && (
<div className="edit-member-drawer__save-errors">
{saveErrors.map((e) => (
<SaveErrorItem
key={e.context}
context={e.context}
apiError={e.apiError}
onRetry={e.onRetry}
/>
))}
</div>
)}
</>
);
const drawerContent = (
<div className="edit-member-drawer__layout">
<div className="edit-member-drawer__body">{drawerBody}</div>
<div className="edit-member-drawer__footer">
<div className="edit-member-drawer__footer-left">
<Button
className="edit-member-drawer__footer-btn edit-member-drawer__footer-btn--danger"
onClick={(): void => setShowDeleteConfirm(true)}
>
<Trash2 size={12} />
{isInvited ? 'Revoke Invite' : 'Delete Member'}
</Button>
<Tooltip title={getDeleteTooltip(isRootUser, isSelf)}>
<span className="edit-member-drawer__tooltip-wrapper">
<Button
className="edit-member-drawer__footer-btn edit-member-drawer__footer-btn--danger"
onClick={(): void => setShowDeleteConfirm(true)}
disabled={isRootUser || isSelf}
>
<Trash2 size={12} />
{isInvited ? 'Revoke Invite' : 'Delete Member'}
</Button>
</span>
</Tooltip>
<div className="edit-member-drawer__footer-divider" />
<Button
className="edit-member-drawer__footer-btn edit-member-drawer__footer-btn--warning"
onClick={handleGenerateResetLink}
disabled={isGeneratingLink}
>
<RefreshCw size={12} />
{isGeneratingLink
? 'Generating...'
: isInvited
? 'Copy Invite Link'
: 'Generate Password Reset Link'}
</Button>
<Tooltip title={isRootUser ? ROOT_USER_TOOLTIP : undefined}>
<span className="edit-member-drawer__tooltip-wrapper">
<Button
className="edit-member-drawer__footer-btn edit-member-drawer__footer-btn--warning"
onClick={handleGenerateResetLink}
disabled={isGeneratingLink || isRootUser}
>
<RefreshCw size={12} />
{isGeneratingLink && 'Generating...'}
{!isGeneratingLink && isInvited && 'Copy Invite Link'}
{!isGeneratingLink && !isInvited && 'Generate Password Reset Link'}
</Button>
</span>
</Tooltip>
</div>
<div className="edit-member-drawer__footer-right">
@@ -318,7 +567,7 @@ function EditMemberDrawer({
variant="solid"
color="primary"
size="sm"
disabled={!isDirty || isSaving}
disabled={!isDirty || isSaving || isRootUser}
onClick={handleSave}
>
{isSaving ? 'Saving...' : 'Save Member Details'}
@@ -328,22 +577,6 @@ function EditMemberDrawer({
</div>
);
const deleteDialogTitle = isInvited ? 'Revoke Invite' : 'Delete Member';
const deleteDialogBody = isInvited ? (
<>
Are you sure you want to revoke the invite for{' '}
<strong>{member?.email}</strong>? They will no longer be able to join the
workspace using this invite.
</>
) : (
<>
Are you sure you want to delete{' '}
<strong>{member?.name || member?.email}</strong>? This will remove their
access to the workspace.
</>
);
const deleteConfirmLabel = isInvited ? 'Revoke Invite' : 'Delete Member';
return (
<>
<DrawerWrapper
@@ -363,82 +596,25 @@ function EditMemberDrawer({
className="edit-member-drawer"
/>
<DialogWrapper
<ResetLinkDialog
open={showResetLinkDialog}
onOpenChange={(isOpen): void => {
if (!isOpen) {
setShowResetLinkDialog(false);
setLinkType(null);
}
linkType={linkType}
resetLink={resetLink}
hasCopied={hasCopiedResetLink}
onClose={(): void => {
setShowResetLinkDialog(false);
}}
title={linkType === 'invite' ? 'Invite Link' : 'Password Reset Link'}
showCloseButton
width="base"
className="reset-link-dialog"
>
<div className="reset-link-dialog__content">
<p className="reset-link-dialog__description">
{linkType === 'invite'
? 'Share this one-time link with the team member to complete their account setup.'
: 'This creates a one-time link the team member can use to set a new password for their SigNoz account.'}
</p>
<div className="reset-link-dialog__link-row">
<div className="reset-link-dialog__link-text-wrap">
<span className="reset-link-dialog__link-text">{resetLink}</span>
</div>
<Button
variant="outlined"
color="secondary"
size="sm"
onClick={handleCopyResetLink}
prefixIcon={
hasCopiedResetLink ? <Check size={12} /> : <Copy size={12} />
}
className="reset-link-dialog__copy-btn"
>
{hasCopiedResetLink ? 'Copied!' : 'Copy'}
</Button>
</div>
</div>
</DialogWrapper>
onCopy={handleCopyResetLink}
/>
<DialogWrapper
<DeleteMemberDialog
open={showDeleteConfirm}
onOpenChange={(isOpen): void => {
if (!isOpen) {
setShowDeleteConfirm(false);
}
}}
title={deleteDialogTitle}
width="narrow"
className="alert-dialog delete-dialog"
showCloseButton={false}
disableOutsideClick={false}
>
<p className="delete-dialog__body">{deleteDialogBody}</p>
<DialogFooter className="delete-dialog__footer">
<Button
variant="solid"
color="secondary"
size="sm"
onClick={(): void => setShowDeleteConfirm(false)}
>
<X size={12} />
Cancel
</Button>
<Button
variant="solid"
color="destructive"
size="sm"
disabled={isDeleting}
onClick={handleDelete}
>
<Trash2 size={12} />
{isDeleting ? 'Processing...' : deleteConfirmLabel}
</Button>
</DialogFooter>
</DialogWrapper>
isInvited={isInvited}
member={member}
isDeleting={isDeleting}
onClose={(): void => setShowDeleteConfirm(false)}
onConfirm={handleDelete}
/>
</>
);
}

View File

@@ -0,0 +1,61 @@
import { Button } from '@signozhq/button';
import { DialogWrapper } from '@signozhq/dialog';
import { Check, Copy } from '@signozhq/icons';
interface ResetLinkDialogProps {
open: boolean;
linkType: 'invite' | 'reset' | null;
resetLink: string | null;
hasCopied: boolean;
onClose: () => void;
onCopy: () => void;
}
function ResetLinkDialog({
open,
linkType,
resetLink,
hasCopied,
onClose,
onCopy,
}: ResetLinkDialogProps): JSX.Element {
return (
<DialogWrapper
open={open}
onOpenChange={(isOpen): void => {
if (!isOpen) {
onClose();
}
}}
title={linkType === 'invite' ? 'Invite Link' : 'Password Reset Link'}
showCloseButton
width="base"
className="reset-link-dialog"
>
<div className="reset-link-dialog__content">
<p className="reset-link-dialog__description">
{linkType === 'invite'
? 'Share this one-time link with the team member to complete their account setup.'
: 'This creates a one-time link the team member can use to set a new password for their SigNoz account.'}
</p>
<div className="reset-link-dialog__link-row">
<div className="reset-link-dialog__link-text-wrap">
<span className="reset-link-dialog__link-text">{resetLink}</span>
</div>
<Button
variant="outlined"
color="secondary"
size="sm"
onClick={onCopy}
prefixIcon={hasCopied ? <Check size={12} /> : <Copy size={12} />}
className="reset-link-dialog__copy-btn"
>
{hasCopied ? 'Copied!' : 'Copy'}
</Button>
</div>
</div>
</DialogWrapper>
);
}
export default ResetLinkDialog;

View File

@@ -4,11 +4,18 @@ import { convertToApiError } from 'api/ErrorResponseHandlerForGeneratedAPIs';
import {
getResetPasswordToken,
useDeleteUser,
useUpdateUserDeprecated,
useGetUser,
useSetRoleByUserID,
useUpdateMyUserV2,
useUpdateUser,
} from 'api/generated/services/users';
import { MemberStatus } from 'container/MembersSettings/utils';
import {
listRolesSuccessResponse,
managedRoles,
} from 'mocks-server/__mockdata__/roles';
import { rest, server } from 'mocks-server/server';
import { render, screen, userEvent, waitFor } from 'tests/test-utils';
import { ROLES } from 'types/roles';
import EditMemberDrawer, { EditMemberDrawerProps } from '../EditMemberDrawer';
@@ -44,7 +51,10 @@ jest.mock('@signozhq/dialog', () => ({
jest.mock('api/generated/services/users', () => ({
useDeleteUser: jest.fn(),
useUpdateUserDeprecated: jest.fn(),
useGetUser: jest.fn(),
useUpdateUser: jest.fn(),
useUpdateMyUserV2: jest.fn(),
useSetRoleByUserID: jest.fn(),
getResetPasswordToken: jest.fn(),
}));
@@ -69,25 +79,53 @@ jest.mock('react-use', () => ({
],
}));
const mockUpdateMutate = jest.fn();
const ROLES_ENDPOINT = '*/api/v1/roles';
const mockDeleteMutate = jest.fn();
const mockGetResetPasswordToken = jest.mocked(getResetPasswordToken);
const mockFetchedUser = {
data: {
id: 'user-1',
displayName: 'Alice Smith',
email: 'alice@signoz.io',
status: 'active',
userRoles: [
{
id: 'ur-1',
roleId: managedRoles[0].id,
role: managedRoles[0], // signoz-admin
},
],
},
};
const activeMember = {
id: 'user-1',
name: 'Alice Smith',
email: 'alice@signoz.io',
role: 'ADMIN' as ROLES,
status: MemberStatus.Active,
joinedOn: '1700000000000',
updatedAt: '1710000000000',
};
const selfMember = {
...activeMember,
id: 'some-user-id',
};
const rootMockFetchedUser = {
data: {
...mockFetchedUser.data,
id: 'root-user-1',
isRoot: true,
},
};
const invitedMember = {
id: 'abc123',
name: '',
email: 'bob@signoz.io',
role: 'VIEWER' as ROLES,
status: MemberStatus.Invited,
joinedOn: '1700000000000',
};
@@ -109,8 +147,26 @@ function renderDrawer(
describe('EditMemberDrawer', () => {
beforeEach(() => {
jest.clearAllMocks();
(useUpdateUserDeprecated as jest.Mock).mockReturnValue({
mutate: mockUpdateMutate,
server.use(
rest.get(ROLES_ENDPOINT, (_, res, ctx) =>
res(ctx.status(200), ctx.json(listRolesSuccessResponse)),
),
);
(useGetUser as jest.Mock).mockReturnValue({
data: mockFetchedUser,
isLoading: false,
refetch: jest.fn(),
});
(useUpdateUser as jest.Mock).mockReturnValue({
mutateAsync: jest.fn().mockResolvedValue({}),
isLoading: false,
});
(useUpdateMyUserV2 as jest.Mock).mockReturnValue({
mutateAsync: jest.fn().mockResolvedValue({}),
isLoading: false,
});
(useSetRoleByUserID as jest.Mock).mockReturnValue({
mutateAsync: jest.fn().mockResolvedValue({}),
isLoading: false,
});
(useDeleteUser as jest.Mock).mockReturnValue({
@@ -119,6 +175,10 @@ describe('EditMemberDrawer', () => {
});
});
afterEach(() => {
server.resetHandlers();
});
it('renders active member details and disables Save when form is not dirty', () => {
renderDrawer();
@@ -130,16 +190,15 @@ describe('EditMemberDrawer', () => {
).toBeDisabled();
});
it('enables Save after editing name and calls update API on confirm', async () => {
it('enables Save after editing name and calls updateUser on confirm', async () => {
const onComplete = jest.fn();
const user = userEvent.setup({ pointerEventsCheck: 0 });
const mockMutateAsync = jest.fn().mockResolvedValue({});
(useUpdateUserDeprecated as jest.Mock).mockImplementation((options) => ({
mutate: mockUpdateMutate.mockImplementation(() => {
options?.mutation?.onSuccess?.();
}),
(useUpdateUser as jest.Mock).mockReturnValue({
mutateAsync: mockMutateAsync,
isLoading: false,
}));
});
renderDrawer({ onComplete });
@@ -153,12 +212,90 @@ describe('EditMemberDrawer', () => {
await user.click(saveBtn);
await waitFor(() => {
expect(mockUpdateMutate).toHaveBeenCalledWith(
expect.objectContaining({
pathParams: { id: 'user-1' },
data: expect.objectContaining({ displayName: 'Alice Updated' }),
}),
);
expect(mockMutateAsync).toHaveBeenCalledWith({
pathParams: { id: 'user-1' },
data: { displayName: 'Alice Updated' },
});
expect(onComplete).toHaveBeenCalled();
});
});
it('does not close the drawer after a successful save', async () => {
const onClose = jest.fn();
const user = userEvent.setup({ pointerEventsCheck: 0 });
renderDrawer({ onClose });
const nameInput = screen.getByDisplayValue('Alice Smith');
await user.clear(nameInput);
await user.type(nameInput, 'Alice Updated');
const saveBtn = screen.getByRole('button', { name: /save member details/i });
await waitFor(() => expect(saveBtn).not.toBeDisabled());
await user.click(saveBtn);
await waitFor(() => {
expect(
screen.getByRole('button', { name: /save member details/i }),
).toBeInTheDocument();
});
expect(onClose).not.toHaveBeenCalled();
});
it('selecting a different role calls setRole with the new role name', async () => {
const onComplete = jest.fn();
const user = userEvent.setup({ pointerEventsCheck: 0 });
const mockSet = jest.fn().mockResolvedValue({});
(useSetRoleByUserID as jest.Mock).mockReturnValue({
mutateAsync: mockSet,
isLoading: false,
});
renderDrawer({ onComplete });
// Open the roles dropdown and select signoz-editor
await user.click(screen.getByLabelText('Roles'));
await user.click(await screen.findByTitle('signoz-editor'));
const saveBtn = screen.getByRole('button', { name: /save member details/i });
await waitFor(() => expect(saveBtn).not.toBeDisabled());
await user.click(saveBtn);
await waitFor(() => {
expect(mockSet).toHaveBeenCalledWith({
pathParams: { id: 'user-1' },
data: { name: 'signoz-editor' },
});
expect(onComplete).toHaveBeenCalled();
});
});
it('does not call removeRole when the role is changed', async () => {
const onComplete = jest.fn();
const user = userEvent.setup({ pointerEventsCheck: 0 });
const mockSet = jest.fn().mockResolvedValue({});
(useSetRoleByUserID as jest.Mock).mockReturnValue({
mutateAsync: mockSet,
isLoading: false,
});
renderDrawer({ onComplete });
// Switch from signoz-admin to signoz-viewer using single-select
await user.click(screen.getByLabelText('Roles'));
await user.click(await screen.findByTitle('signoz-viewer'));
const saveBtn = screen.getByRole('button', { name: /save member details/i });
await waitFor(() => expect(saveBtn).not.toBeDisabled());
await user.click(saveBtn);
await waitFor(() => {
expect(mockSet).toHaveBeenCalledWith({
pathParams: { id: 'user-1' },
data: { name: 'signoz-viewer' },
});
expect(onComplete).toHaveBeenCalled();
});
});
@@ -239,16 +376,33 @@ describe('EditMemberDrawer', () => {
});
});
it('calls update API when saving changes for an invited member', async () => {
it('calls updateUser when saving name change for an invited member', async () => {
const onComplete = jest.fn();
const user = userEvent.setup({ pointerEventsCheck: 0 });
const mockMutateAsync = jest.fn().mockResolvedValue({});
(useUpdateUserDeprecated as jest.Mock).mockImplementation((options) => ({
mutate: mockUpdateMutate.mockImplementation(() => {
options?.mutation?.onSuccess?.();
}),
(useGetUser as jest.Mock).mockReturnValue({
data: {
data: {
...mockFetchedUser.data,
id: 'abc123',
displayName: 'Bob',
userRoles: [
{
id: 'ur-2',
roleId: managedRoles[2].id,
role: managedRoles[2], // signoz-viewer
},
],
},
},
isLoading: false,
}));
refetch: jest.fn(),
});
(useUpdateUser as jest.Mock).mockReturnValue({
mutateAsync: mockMutateAsync,
isLoading: false,
});
renderDrawer({ member: { ...invitedMember, name: 'Bob' }, onComplete });
@@ -261,12 +415,10 @@ describe('EditMemberDrawer', () => {
await user.click(saveBtn);
await waitFor(() => {
expect(mockUpdateMutate).toHaveBeenCalledWith(
expect.objectContaining({
pathParams: { id: 'abc123' },
data: expect.objectContaining({ displayName: 'Bob Updated' }),
}),
);
expect(mockMutateAsync).toHaveBeenCalledWith({
pathParams: { id: 'abc123' },
data: { displayName: 'Bob Updated' },
});
expect(onComplete).toHaveBeenCalled();
});
});
@@ -280,16 +432,13 @@ describe('EditMemberDrawer', () => {
} as ReturnType<typeof convertToApiError>);
});
it('shows API error message when updateUser fails', async () => {
it('shows SaveErrorItem when updateUser fails for name change', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
const mockToast = jest.mocked(toast);
(useUpdateUserDeprecated as jest.Mock).mockImplementation((options) => ({
mutate: mockUpdateMutate.mockImplementation(() => {
options?.mutation?.onError?.({});
}),
(useUpdateUser as jest.Mock).mockReturnValue({
mutateAsync: jest.fn().mockRejectedValue(new Error('server error')),
isLoading: false,
}));
});
renderDrawer();
@@ -302,10 +451,9 @@ describe('EditMemberDrawer', () => {
await user.click(saveBtn);
await waitFor(() => {
expect(mockToast.error).toHaveBeenCalledWith(
'Failed to update member details: Something went wrong on server',
expect.anything(),
);
expect(
screen.getByText('Name update: Something went wrong on server'),
).toBeInTheDocument();
});
});
@@ -364,6 +512,96 @@ describe('EditMemberDrawer', () => {
});
});
describe('self user (isSelf)', () => {
it('disables Delete button when viewing own profile', () => {
renderDrawer({ member: selfMember });
expect(
screen.getByRole('button', { name: /delete member/i }),
).toBeDisabled();
});
it('does not open delete confirm dialog when Delete is clicked while disabled (isSelf)', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
renderDrawer({ member: selfMember });
await user.click(screen.getByRole('button', { name: /delete member/i }));
expect(
screen.queryByText(/are you sure you want to delete/i),
).not.toBeInTheDocument();
});
it('keeps name input enabled when viewing own profile', () => {
renderDrawer({ member: selfMember });
expect(screen.getByDisplayValue('Alice Smith')).not.toBeDisabled();
});
it('keeps Reset Link button enabled when viewing own profile', () => {
renderDrawer({ member: selfMember });
expect(
screen.getByRole('button', { name: /generate password reset link/i }),
).not.toBeDisabled();
});
});
describe('root user', () => {
beforeEach(() => {
(useGetUser as jest.Mock).mockReturnValue({
data: rootMockFetchedUser,
isLoading: false,
refetch: jest.fn(),
});
});
it('disables name input for root user', () => {
renderDrawer();
expect(screen.getByDisplayValue('Alice Smith')).toBeDisabled();
});
it('disables Delete button for root user', () => {
renderDrawer();
expect(
screen.getByRole('button', { name: /delete member/i }),
).toBeDisabled();
});
it('disables Reset Link button for root user', () => {
renderDrawer();
expect(
screen.getByRole('button', { name: /generate password reset link/i }),
).toBeDisabled();
});
it('disables Save button for root user', () => {
renderDrawer();
expect(
screen.getByRole('button', { name: /save member details/i }),
).toBeDisabled();
});
it('does not open delete confirm dialog when Delete is clicked while disabled (root)', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
renderDrawer();
await user.click(screen.getByRole('button', { name: /delete member/i }));
expect(
screen.queryByText(/are you sure you want to delete/i),
).not.toBeInTheDocument();
});
it('does not call getResetPasswordToken when Reset Link is clicked while disabled (root)', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
renderDrawer();
await user.click(
screen.getByRole('button', { name: /generate password reset link/i }),
);
expect(mockGetResetPasswordToken).not.toHaveBeenCalled();
});
});
describe('Generate Password Reset Link', () => {
beforeEach(() => {
mockCopyToClipboard.mockClear();

View File

@@ -10,7 +10,12 @@ import APIError from 'types/api/error';
import './ErrorContent.styles.scss';
interface ErrorContentProps {
error: APIError;
error:
| APIError
| {
code: number;
message: string;
};
icon?: ReactNode;
}
@@ -20,7 +25,15 @@ function ErrorContent({ error, icon }: ErrorContentProps): JSX.Element {
errors: errorMessages,
code: errorCode,
message: errorMessage,
} = error?.error?.error || {};
} =
error && 'error' in error
? error?.error?.error || {}
: {
url: undefined,
errors: [],
code: error.code || 500,
message: error.message || 'Something went wrong',
};
return (
<section className="error-content">
{/* Summary Header */}

View File

@@ -197,13 +197,16 @@ function InviteMembersModal({
})),
});
}
toast.success('Invites sent successfully', { richColors: true });
toast.success('Invites sent successfully', {
richColors: true,
position: 'top-right',
});
resetAndClose();
onComplete?.();
} catch (err) {
const apiErr = err as APIError;
const errorMessage = apiErr?.getErrorMessage?.() ?? 'An error occurred';
toast.error(errorMessage, { richColors: true });
toast.error(errorMessage, { richColors: true, position: 'top-right' });
} finally {
setIsSubmitting(false);
}

View File

@@ -28,14 +28,22 @@
}
}
// In table/column view, keep action buttons visible at the viewport's right edge
.log-line-action-buttons.table-view-log-actions {
position: absolute;
top: 50%;
right: 8px;
left: auto;
transform: translateY(-50%);
margin: 0;
z-index: 5;
}
.lightMode {
.log-line-action-buttons {
border: 1px solid var(--bg-vanilla-400);
background: var(--bg-vanilla-400);
.ant-btn-default {
}
.copy-log-btn {
border-left: 1px solid var(--bg-vanilla-400);
border-color: var(--bg-vanilla-400) !important;

View File

@@ -15,13 +15,12 @@ export function getDefaultCellStyle(isDarkMode?: boolean): CSSProperties {
letterSpacing: '-0.07px',
marginBottom: '0px',
minWidth: '10rem',
width: '10rem',
width: 'auto',
};
}
export const defaultTableStyle: CSSProperties = {
minWidth: '40rem',
maxWidth: '90rem',
};
export const defaultListViewPanelStyle: CSSProperties = {

View File

@@ -33,7 +33,7 @@
display: flex;
align-items: center;
.ant-typography {
p {
margin-bottom: 0;
}
}
@@ -45,6 +45,7 @@
}
.paragraph {
margin: 0;
padding: 0px !important;
&.small {
font-size: 11px !important;

View File

@@ -1,5 +1,5 @@
import { useMemo } from 'react';
import { TableColumnsType as ColumnsType, Typography } from 'antd';
import { TableColumnsType as ColumnsType } from 'antd';
import cx from 'classnames';
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
import { getSanitizedLogBody } from 'container/LogDetailedView/utils';
@@ -43,7 +43,7 @@ export const useTableView = (props: UseTableViewProps): UseTableViewResult => {
const bodyColumnStyle = useMemo(
() => ({
...defaultTableStyle,
...(fields.length > 2 ? { width: '50rem' } : {}),
...(fields.length > 2 ? { width: 'auto' } : {}),
}),
[fields.length],
);
@@ -59,18 +59,18 @@ export const useTableView = (props: UseTableViewProps): UseTableViewResult => {
key: name,
render: (field): ColumnTypeRender<Record<string, unknown>> => ({
props: {
style: isListViewPanel
? defaultListViewPanelStyle
: getDefaultCellStyle(isDarkMode),
style: {
...(isListViewPanel
? defaultListViewPanelStyle
: getDefaultCellStyle(isDarkMode)),
display: '-webkit-box',
WebkitLineClamp: linesPerRow,
WebkitBoxOrient: 'vertical',
overflow: 'hidden',
wordBreak: 'break-all',
},
},
children: (
<Typography.Paragraph
ellipsis={{ rows: linesPerRow }}
className={cx('paragraph', fontSize)}
>
{field}
</Typography.Paragraph>
),
children: <p className={cx('paragraph', fontSize)}>{field}</p>,
}),
}));
@@ -123,9 +123,7 @@ export const useTableView = (props: UseTableViewProps): UseTableViewResult => {
return {
children: (
<div className="table-timestamp">
<Typography.Paragraph ellipsis className={cx('text', fontSize)}>
{date}
</Typography.Paragraph>
<p className={cx('text', fontSize)}>{date}</p>
</div>
),
};

View File

@@ -4,9 +4,7 @@ import { Table, Tooltip } from 'antd';
import type { ColumnsType, SorterResult } from 'antd/es/table/interface';
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
import { MemberStatus } from 'container/MembersSettings/utils';
import { capitalize } from 'lodash-es';
import { useTimezone } from 'providers/Timezone';
import { ROLES } from 'types/roles';
import './MembersTable.styles.scss';
@@ -14,7 +12,6 @@ export interface MemberRow {
id: string;
name?: string;
email: string;
role: ROLES;
status: MemberStatus;
joinedOn: string | null;
updatedAt?: string | null;
@@ -141,17 +138,6 @@ function MembersTable({
<NameEmailCell name={record.name} email={record.email} />
),
},
{
title: 'Roles',
dataIndex: 'role',
key: 'role',
width: 180,
sorter: (a, b): number => a.role.localeCompare(b.role),
render: (role: ROLES): JSX.Element => (
<Badge color="vanilla">{capitalize(role)}</Badge>
),
},
{
title: 'Status',
dataIndex: 'status',

View File

@@ -1,6 +1,5 @@
import { MemberStatus } from 'container/MembersSettings/utils';
import { render, screen, userEvent } from 'tests/test-utils';
import { ROLES } from 'types/roles';
import MembersTable, { MemberRow } from '../MembersTable';
@@ -9,7 +8,6 @@ const mockActiveMembers: MemberRow[] = [
id: 'user-1',
name: 'Alice Smith',
email: 'alice@signoz.io',
role: 'ADMIN' as ROLES,
status: MemberStatus.Active,
joinedOn: '1700000000000',
},
@@ -17,7 +15,6 @@ const mockActiveMembers: MemberRow[] = [
id: 'user-2',
name: 'Bob Jones',
email: 'bob@signoz.io',
role: 'VIEWER' as ROLES,
status: MemberStatus.Active,
joinedOn: null,
},
@@ -27,7 +24,6 @@ const mockInvitedMember: MemberRow = {
id: 'inv-abc',
name: '',
email: 'charlie@signoz.io',
role: 'EDITOR' as ROLES,
status: MemberStatus.Invited,
joinedOn: null,
};
@@ -47,12 +43,11 @@ describe('MembersTable', () => {
jest.clearAllMocks();
});
it('renders member rows with name, email, role badge, and ACTIVE status', () => {
it('renders member rows with name, email, and ACTIVE status', () => {
render(<MembersTable {...defaultProps} data={mockActiveMembers} />);
expect(screen.getByText('Alice Smith')).toBeInTheDocument();
expect(screen.getByText('alice@signoz.io')).toBeInTheDocument();
expect(screen.getByText('Admin')).toBeInTheDocument();
expect(screen.getAllByText('ACTIVE')).toHaveLength(2);
});
@@ -67,7 +62,6 @@ describe('MembersTable', () => {
expect(screen.getByText('INVITED')).toBeInTheDocument();
expect(screen.getByText('charlie@signoz.io')).toBeInTheDocument();
expect(screen.getByText('Editor')).toBeInTheDocument();
});
it('calls onRowClick with the member data when a row is clicked', async () => {
@@ -99,7 +93,6 @@ describe('MembersTable', () => {
id: 'user-del',
name: 'Dave Deleted',
email: 'dave@signoz.io',
role: 'VIEWER' as ROLES,
status: MemberStatus.Deleted,
joinedOn: null,
};

View File

@@ -1,21 +1,35 @@
.quick-filters-container {
display: flex;
flex-direction: row;
height: 100%;
min-height: 0;
position: relative;
.quick-filters-settings-container {
flex: 0 0 0;
width: 0;
min-width: 0;
overflow: visible;
position: relative;
align-self: stretch;
}
}
.quick-filters {
display: flex;
flex-direction: column;
flex: 1 1 auto;
min-width: 0;
height: 100%;
width: 100%;
min-height: 0;
border-right: 1px solid var(--bg-slate-400);
color: var(--bg-vanilla-100);
.overlay-scrollbar {
flex: 1;
min-height: 0;
}
.header {
display: flex;
align-items: center;

View File

@@ -2,6 +2,8 @@
display: flex;
flex-direction: column;
position: absolute;
top: 0;
left: 0;
z-index: 999;
width: 342px;
background: var(--bg-slate-500);

View File

@@ -88,3 +88,13 @@
color: var(--destructive);
}
}
.roles-single-select {
.ant-select-selector {
min-height: 32px;
background-color: var(--l2-background) !important;
border: 1px solid var(--border) !important;
border-radius: 2px;
padding: 2px var(--padding-2) !important;
}
}

View File

@@ -158,10 +158,10 @@ function RolesSelect(props: RolesSelectProps): JSX.Element {
return (
<Select
id={id}
value={value}
value={value || undefined}
onChange={onChange}
placeholder={placeholder}
className={cx('roles-select', className)}
className={cx('roles-single-select', className)}
loading={loading}
notFoundContent={notFoundContent}
options={options}

View File

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

View File

@@ -80,7 +80,7 @@ function ServiceAccountDrawer({
parseAsBoolean.withDefault(false),
);
const [localName, setLocalName] = useState('');
const [localRoles, setLocalRoles] = useState<string[]>([]);
const [localRole, setLocalRole] = useState('');
const [isSaving, setIsSaving] = useState(false);
const [saveErrors, setSaveErrors] = useState<SaveError[]>([]);
@@ -116,7 +116,7 @@ function ServiceAccountDrawer({
}, [account?.id, account?.name, setKeysPage]);
useEffect(() => {
setLocalRoles(currentRoles.map((r) => r.id).filter(Boolean) as string[]);
setLocalRole(currentRoles[0]?.id ?? '');
}, [currentRoles]);
const isDeleted =
@@ -125,8 +125,7 @@ function ServiceAccountDrawer({
const isDirty =
account !== null &&
(localName !== (account.name ?? '') ||
JSON.stringify([...localRoles].sort()) !==
JSON.stringify([...currentRoles.map((r) => r.id).filter(Boolean)].sort()));
localRole !== (currentRoles[0]?.id ?? ''));
const {
roles: availableRoles,
@@ -216,7 +215,10 @@ function ServiceAccountDrawer({
const retryRolesUpdate = useCallback(async (): Promise<void> => {
try {
const failures = await applyDiff(localRoles, availableRoles);
const failures = await applyDiff(
[localRole].filter(Boolean),
availableRoles,
);
if (failures.length === 0) {
setSaveErrors((prev) => prev.filter((e) => e.context !== 'Roles update'));
} else {
@@ -240,7 +242,7 @@ function ServiceAccountDrawer({
),
);
}
}, [localRoles, availableRoles, applyDiff, toSaveApiError, makeRoleRetry]);
}, [localRole, availableRoles, applyDiff, toSaveApiError, makeRoleRetry]);
const handleSave = useCallback(async (): Promise<void> => {
if (!account || !isDirty) {
@@ -259,7 +261,7 @@ function ServiceAccountDrawer({
const [nameResult, rolesResult] = await Promise.allSettled([
namePromise,
applyDiff(localRoles, availableRoles),
applyDiff([localRole].filter(Boolean), availableRoles),
]);
const errors: SaveError[] = [];
@@ -294,6 +296,7 @@ function ServiceAccountDrawer({
} else {
toast.success('Service account updated successfully', {
richColors: true,
position: 'top-right',
});
onSuccess({ closeDrawer: false });
}
@@ -307,7 +310,7 @@ function ServiceAccountDrawer({
account,
isDirty,
localName,
localRoles,
localRole,
availableRoles,
updateMutateAsync,
applyDiff,
@@ -409,8 +412,8 @@ function ServiceAccountDrawer({
account={account}
localName={localName}
onNameChange={handleNameChange}
localRoles={localRoles}
onRolesChange={setLocalRoles}
localRole={localRole}
onRoleChange={setLocalRole}
isDisabled={isDeleted}
availableRoles={availableRoles}
rolesLoading={rolesLoading}

View File

@@ -139,20 +139,20 @@ describe('ServiceAccountDrawer', () => {
});
});
it('changing roles enables Save; clicking Save sends updated roles in payload', async () => {
const updateSpy = jest.fn();
it('changing roles enables Save; clicking Save sends role add request without delete', async () => {
const roleSpy = jest.fn();
const deleteSpy = jest.fn();
const user = userEvent.setup({ pointerEventsCheck: 0 });
server.use(
rest.put(SA_ENDPOINT, async (req, res, ctx) => {
updateSpy(await req.json());
return res(ctx.status(200), ctx.json({ status: 'success', data: {} }));
}),
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();
@@ -167,12 +167,12 @@ describe('ServiceAccountDrawer', () => {
await user.click(saveBtn);
await waitFor(() => {
expect(updateSpy).not.toHaveBeenCalled();
expect(roleSpy).toHaveBeenCalledWith(
expect.objectContaining({
id: '019c24aa-2248-7585-a129-4188b3473c27',
}),
);
expect(deleteSpy).not.toHaveBeenCalled();
});
});
@@ -350,7 +350,7 @@ describe('ServiceAccountDrawer save-error UX', () => {
).toBeInTheDocument();
});
it('role update failure shows SaveErrorItem with the role name context', async () => {
it('role add failure shows SaveErrorItem with the role name context', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
server.use(

View File

@@ -12,6 +12,7 @@ export enum LOCALSTORAGE {
GRAPH_VISIBILITY_STATES = 'GRAPH_VISIBILITY_STATES',
TRACES_LIST_COLUMNS = 'TRACES_LIST_COLUMNS',
LOGS_LIST_COLUMNS = 'LOGS_LIST_COLUMNS',
LOGS_LIST_COLUMN_SIZING = 'LOGS_LIST_COLUMN_SIZING',
LOGGED_IN_USER_NAME = 'LOGGED_IN_USER_NAME',
LOGGED_IN_USER_EMAIL = 'LOGGED_IN_USER_EMAIL',
CHAT_SUPPORT = 'CHAT_SUPPORT',

View File

@@ -838,22 +838,20 @@ function FormAlertRules({
>
<div className="overview-header">
<div className="alert-type-container">
{isNewRule && (
<Typography.Title level={5} className="alert-type-title">
<BellDot size={14} />
<Typography.Title level={5} className="alert-type-title">
<BellDot size={14} />
{alertDef.alertType === AlertTypes.ANOMALY_BASED_ALERT &&
'Anomaly Detection Alert'}
{alertDef.alertType === AlertTypes.METRICS_BASED_ALERT &&
'Metrics Based Alert'}
{alertDef.alertType === AlertTypes.LOGS_BASED_ALERT &&
'Logs Based Alert'}
{alertDef.alertType === AlertTypes.TRACES_BASED_ALERT &&
'Traces Based Alert'}
{alertDef.alertType === AlertTypes.EXCEPTIONS_BASED_ALERT &&
'Exceptions Based Alert'}
</Typography.Title>
)}
{alertDef.alertType === AlertTypes.ANOMALY_BASED_ALERT &&
'Anomaly Detection Alert'}
{alertDef.alertType === AlertTypes.METRICS_BASED_ALERT &&
'Metrics Based Alert'}
{alertDef.alertType === AlertTypes.LOGS_BASED_ALERT &&
'Logs Based Alert'}
{alertDef.alertType === AlertTypes.TRACES_BASED_ALERT &&
'Traces Based Alert'}
{alertDef.alertType === AlertTypes.EXCEPTIONS_BASED_ALERT &&
'Exceptions Based Alert'}
</Typography.Title>
</div>
<Button

View File

@@ -1,41 +1,52 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
// eslint-disable-next-line no-restricted-imports
import { useSelector } from 'react-redux';
import { useQuery } from 'react-query';
import { VerticalAlignTopOutlined } from '@ant-design/icons';
import { Button, Tooltip, Typography } from 'antd';
import logEvent from 'api/common/logEvent';
import { HostListPayload } from 'api/infraMonitoring/getHostLists';
import {
getHostLists,
HostListPayload,
HostListResponse,
} from 'api/infraMonitoring/getHostLists';
import HostMetricDetail from 'components/HostMetricsDetail';
import QuickFilters from 'components/QuickFilters/QuickFilters';
import { QuickFiltersSource } from 'components/QuickFilters/types';
import { InfraMonitoringEvents } from 'constants/events';
import { FeatureKeys } from 'constants/features';
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
import {
useInfraMonitoringCurrentPage,
useInfraMonitoringFiltersHosts,
useInfraMonitoringOrderByHosts,
} from 'container/InfraMonitoringK8s/hooks';
import { usePageSize } from 'container/InfraMonitoringK8s/utils';
import { useGetHostList } from 'hooks/infraMonitoring/useGetHostList';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { useQueryOperations } from 'hooks/queryBuilder/useQueryBuilderOperations';
import { Filter } from 'lucide-react';
import { parseAsString, useQueryState } from 'nuqs';
import { AppState } from 'store/reducers';
import { IBuilderQuery, Query } from 'types/api/queryBuilder/queryBuilderData';
import { GlobalReducer } from 'types/reducer/globalTime';
import { useAppContext } from 'providers/App/App';
import { useGlobalTimeStore } from 'store/globalTime';
import {
getAutoRefreshQueryKey,
NANO_SECOND_MULTIPLIER,
} from 'store/globalTime/utils';
import { ErrorResponse, SuccessResponse } from 'types/api';
import {
IBuilderQuery,
Query,
TagFilter,
} from 'types/api/queryBuilder/queryBuilderData';
import { FeatureKeys } from '../../constants/features';
import { useAppContext } from '../../providers/App/App';
import HostsListControls from './HostsListControls';
import HostsListTable from './HostsListTable';
import { getHostListsQuery, GetHostsQuickFiltersConfig } from './utils';
import './InfraMonitoring.styles.scss';
function HostsList(): JSX.Element {
const { maxTime, minTime } = useSelector<AppState, GlobalReducer>(
(state) => state.globalTime,
);
const defaultFilters: TagFilter = { items: [], op: 'and' };
const baseQuery = getHostListsQuery();
function HostsList(): JSX.Element {
const [currentPage, setCurrentPage] = useInfraMonitoringCurrentPage();
const [filters, setFilters] = useInfraMonitoringFiltersHosts();
const [orderBy, setOrderBy] = useInfraMonitoringOrderByHosts();
@@ -62,57 +73,49 @@ function HostsList(): JSX.Element {
const { pageSize, setPageSize } = usePageSize('hosts');
const query = useMemo(() => {
const baseQuery = getHostListsQuery();
return {
...baseQuery,
limit: pageSize,
offset: (currentPage - 1) * pageSize,
filters,
start: Math.floor(minTime / 1000000),
end: Math.floor(maxTime / 1000000),
orderBy,
};
}, [pageSize, currentPage, filters, minTime, maxTime, orderBy]);
const selectedTime = useGlobalTimeStore((s) => s.selectedTime);
const isRefreshEnabled = useGlobalTimeStore((s) => s.isRefreshEnabled);
const refreshInterval = useGlobalTimeStore((s) => s.refreshInterval);
const getMinMaxTime = useGlobalTimeStore((s) => s.getMinMaxTime);
const queryKey = useMemo(() => {
if (selectedHostName) {
return [
'hostList',
const queryKey = useMemo(
() =>
getAutoRefreshQueryKey(
selectedTime,
REACT_QUERY_KEY.GET_HOST_LIST,
String(pageSize),
String(currentPage),
JSON.stringify(filters),
JSON.stringify(orderBy),
];
}
return [
'hostList',
String(pageSize),
String(currentPage),
JSON.stringify(filters),
JSON.stringify(orderBy),
String(minTime),
String(maxTime),
];
}, [
pageSize,
currentPage,
filters,
orderBy,
selectedHostName,
minTime,
maxTime,
]);
const { data, isFetching, isLoading, isError } = useGetHostList(
query as HostListPayload,
{
queryKey,
enabled: !!query,
keepPreviousData: true,
},
),
[pageSize, currentPage, filters, orderBy, selectedTime],
);
const { data, isFetching, isLoading, isError } = useQuery<
SuccessResponse<HostListResponse> | ErrorResponse,
Error
>({
queryKey,
queryFn: ({ signal }) => {
const { minTime, maxTime } = getMinMaxTime();
const payload: HostListPayload = {
...baseQuery,
limit: pageSize,
offset: (currentPage - 1) * pageSize,
filters: filters ?? defaultFilters,
orderBy,
start: Math.floor(minTime / NANO_SECOND_MULTIPLIER),
end: Math.floor(maxTime / NANO_SECOND_MULTIPLIER),
};
return getHostLists(payload, signal);
},
enabled: true,
keepPreviousData: true,
refetchInterval: isRefreshEnabled ? refreshInterval : false,
});
const hostMetricsData = useMemo(() => data?.payload?.data?.records || [], [
data,
]);
@@ -227,7 +230,7 @@ function HostsList(): JSX.Element {
isError={isError}
tableData={data}
hostMetricsData={hostMetricsData}
filters={filters || { items: [], op: 'AND' }}
filters={filters ?? defaultFilters}
currentPage={currentPage}
setCurrentPage={setCurrentPage}
onHostClick={handleHostClick}

View File

@@ -10,6 +10,7 @@ import {
} from 'antd';
import type { SorterResult } from 'antd/es/table/interface';
import logEvent from 'api/common/logEvent';
import ErrorContent from 'components/ErrorModal/components/ErrorContent';
import { InfraMonitoringEvents } from 'constants/events';
import { isModifierKeyPressed } from 'utils/app';
import { openInNewTab } from 'utils/navigation';
@@ -26,9 +27,40 @@ import {
function EmptyOrLoadingView(
viewState: EmptyOrLoadingViewProps,
): React.ReactNode {
const { isError, errorMessage } = viewState;
if (isError) {
return <Typography>{errorMessage || 'Something went wrong'}</Typography>;
if (viewState.showTableLoadingState) {
return (
<div className="hosts-list-loading-state">
<Skeleton.Input
className="hosts-list-loading-state-item"
size="large"
block
active
/>
<Skeleton.Input
className="hosts-list-loading-state-item"
size="large"
block
active
/>
<Skeleton.Input
className="hosts-list-loading-state-item"
size="large"
block
active
/>
</div>
);
}
const { isError, data } = viewState;
if (isError || data?.error || (data?.statusCode || 0) >= 300) {
return (
<ErrorContent
error={{
code: data?.statusCode || 500,
message: data?.error || 'Something went wrong',
}}
/>
);
}
if (viewState.showHostsEmptyState) {
return (
@@ -76,30 +108,6 @@ function EmptyOrLoadingView(
</div>
);
}
if (viewState.showTableLoadingState) {
return (
<div className="hosts-list-loading-state">
<Skeleton.Input
className="hosts-list-loading-state-item"
size="large"
block
active
/>
<Skeleton.Input
className="hosts-list-loading-state-item"
size="large"
block
active
/>
<Skeleton.Input
className="hosts-list-loading-state-item"
size="large"
block
active
/>
</div>
);
}
return null;
}
@@ -190,7 +198,8 @@ export default function HostsListTable({
!isLoading &&
formattedHostMetricsData.length === 0 &&
(!sentAnyHostMetricsData || isSendingIncorrectK8SAgentMetrics) &&
!filters.items.length;
!filters.items.length &&
!endTimeBeforeRetention;
const showEndTimeBeforeRetentionMessage =
!isFetching &&
@@ -211,7 +220,7 @@ export default function HostsListTable({
const emptyOrLoadingView = EmptyOrLoadingView({
isError,
errorMessage: data?.error ?? '',
data,
showHostsEmptyState,
sentAnyHostMetricsData,
isSendingIncorrectK8SAgentMetrics,

View File

@@ -2,8 +2,8 @@ import { QueryClient, QueryClientProvider } from 'react-query';
// eslint-disable-next-line no-restricted-imports
import { Provider } from 'react-redux';
import { MemoryRouter } from 'react-router-dom';
import { render } from '@testing-library/react';
import * as useGetHostListHooks from 'hooks/infraMonitoring/useGetHostList';
import { render, waitFor } from '@testing-library/react';
import * as getHostListsApi from 'api/infraMonitoring/getHostLists';
import { withNuqsTestingAdapter } from 'nuqs/adapters/testing';
import * as appContextHooks from 'providers/App/App';
import * as timezoneHooks from 'providers/Timezone';
@@ -19,6 +19,10 @@ jest.mock('lib/getMinMax', () => ({
maxTime: 1713738000000,
isValidShortHandDateTimeFormat: jest.fn().mockReturnValue(true),
})),
getMinMaxForSelectedTime: jest.fn().mockReturnValue({
minTime: 1713734400000000000,
maxTime: 1713738000000000000,
}),
}));
jest.mock('container/TopNav/DateTimeSelectionV2', () => ({
__esModule: true,
@@ -41,7 +45,13 @@ jest.mock('components/CustomTimePicker/CustomTimePicker', () => ({
),
}));
const queryClient = new QueryClient();
const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: false,
},
},
});
jest.mock('react-redux', () => ({
...jest.requireActual('react-redux'),
@@ -80,27 +90,40 @@ jest.spyOn(timezoneHooks, 'useTimezone').mockReturnValue({
offset: 0,
},
} as any);
jest.spyOn(useGetHostListHooks, 'useGetHostList').mockReturnValue({
data: {
payload: {
data: {
records: [
{
hostName: 'test-host',
active: true,
cpu: 0.75,
memory: 0.65,
wait: 0.03,
},
],
isSendingK8SAgentMetrics: false,
sentAnyHostMetricsData: true,
},
jest.spyOn(getHostListsApi, 'getHostLists').mockResolvedValue({
statusCode: 200,
error: null,
message: 'Success',
payload: {
status: 'success',
data: {
type: 'list',
records: [
{
hostName: 'test-host',
active: true,
os: 'linux',
cpu: 0.75,
cpuTimeSeries: { labels: {}, labelsArray: [], values: [] },
memory: 0.65,
memoryTimeSeries: { labels: {}, labelsArray: [], values: [] },
wait: 0.03,
waitTimeSeries: { labels: {}, labelsArray: [], values: [] },
load15: 0.5,
load15TimeSeries: { labels: {}, labelsArray: [], values: [] },
},
],
groups: null,
total: 1,
sentAnyHostMetricsData: true,
isSendingK8SAgentMetrics: false,
endTimeBeforeRetention: false,
},
},
isLoading: false,
isError: false,
} as any);
params: {} as any,
});
jest.spyOn(appContextHooks, 'useAppContext').mockReturnValue({
user: {
role: 'admin',
@@ -128,22 +151,11 @@ jest.spyOn(appContextHooks, 'useAppContext').mockReturnValue({
const Wrapper = withNuqsTestingAdapter({ searchParams: {} });
describe('HostsList', () => {
it('renders hosts list table', () => {
const { container } = render(
<Wrapper>
<QueryClientProvider client={queryClient}>
<MemoryRouter>
<Provider store={store}>
<HostsList />
</Provider>
</MemoryRouter>
</QueryClientProvider>
</Wrapper>,
);
expect(container.querySelector('.hosts-list-table')).toBeInTheDocument();
beforeEach(() => {
queryClient.clear();
});
it('renders filters', () => {
it('renders hosts list table', async () => {
const { container } = render(
<Wrapper>
<QueryClientProvider client={queryClient}>
@@ -155,6 +167,25 @@ describe('HostsList', () => {
</QueryClientProvider>
</Wrapper>,
);
expect(container.querySelector('.filters')).toBeInTheDocument();
await waitFor(() => {
expect(container.querySelector('.hosts-list-table')).toBeInTheDocument();
});
});
it('renders filters', async () => {
const { container } = render(
<Wrapper>
<QueryClientProvider client={queryClient}>
<MemoryRouter>
<Provider store={store}>
<HostsList />
</Provider>
</MemoryRouter>
</QueryClientProvider>
</Wrapper>,
);
await waitFor(() => {
expect(container.querySelector('.filters')).toBeInTheDocument();
});
});
});

View File

@@ -1,8 +1,13 @@
import { Dispatch, SetStateAction } from 'react';
import { InfoCircleOutlined } from '@ant-design/icons';
import { Color } from '@signozhq/design-tokens';
import { Progress, TabsProps, Tag, Tooltip, Typography } from 'antd';
import { TableColumnType as ColumnType } from 'antd';
import {
Progress,
TableColumnType as ColumnType,
Tag,
Tooltip,
Typography,
} from 'antd';
import { SortOrder } from 'antd/lib/table/interface';
import {
HostData,
@@ -13,8 +18,6 @@ import {
FiltersType,
IQuickFiltersConfig,
} from 'components/QuickFilters/types';
import TabLabel from 'components/TabLabel';
import { PANEL_TYPES } from 'constants/queryBuilder';
import { TriangleAlert } from 'lucide-react';
import { ErrorResponse, SuccessResponse } from 'types/api';
import { DataTypes } from 'types/api/queryBuilder/queryAutocompleteResponse';
@@ -22,9 +25,6 @@ import { TagFilter } from 'types/api/queryBuilder/queryBuilderData';
import { DataSource } from 'types/common/queryBuilder';
import { OrderBySchemaType } from '../InfraMonitoringK8s/schemas';
import HostsList from './HostsList';
import './InfraMonitoring.styles.scss';
export interface HostRowData {
key?: string;
@@ -112,7 +112,10 @@ export interface HostsListTableProps {
export interface EmptyOrLoadingViewProps {
isError: boolean;
errorMessage: string;
data:
| ErrorResponse<string>
| SuccessResponse<HostListResponse, unknown>
| undefined;
showHostsEmptyState: boolean;
sentAnyHostMetricsData: boolean;
isSendingIncorrectK8SAgentMetrics: boolean;
@@ -141,14 +144,6 @@ function mapOrderByToSortOrder(
: undefined;
}
export const getTabsItems = (): TabsProps['items'] => [
{
label: <TabLabel label="List View" isDisabled={false} tooltipText="" />,
key: PANEL_TYPES.LIST,
children: <HostsList />,
},
];
export const getHostsListColumns = (
orderBy: OrderBySchemaType,
): ColumnType<HostRowData>[] => [

View File

@@ -1,171 +0,0 @@
.clickableRow {
cursor: pointer;
}
.expandedClickableRow {
cursor: pointer;
}
.expandedTableContainer {
border: 1px solid var(--l1-border);
overflow-x: auto;
padding-left: 48px;
:global(.ant-table-tbody > tr:hover > td) {
background: none !important;
}
}
.expandedTableFooter {
display: flex;
justify-content: flex-start;
gap: 8px;
padding: 8px;
padding-left: 42px;
margin-top: 8px;
}
.viewAllButton {
display: flex;
align-items: center;
gap: var(--spacing-2);
}
.k8sListTable {
:global(.ant-table) {
:global(.ant-table-thead > tr > th) {
padding: 12px;
font-weight: 500;
font-size: 12px;
line-height: 18px;
border-bottom: none;
color: var(--l2-foreground);
font-family: Inter;
font-size: 11px;
font-style: normal;
font-weight: 600;
line-height: 18px;
letter-spacing: 0.44px;
text-transform: uppercase;
background: var(--bg-ink-500) !important;
&::before {
background: transparent !important;
}
}
:global(.ant-table-cell) {
padding: 12px;
font-size: 13px;
line-height: 20px;
color: var(--l1-foreground);
background: var(--bg-ink-500);
border-bottom: none;
&:before,
&:after {
display: none;
}
}
:global(.hostname-column-value) {
color: var(--l1-foreground);
font-family: 'Geist Mono';
font-style: normal;
font-weight: 600;
line-height: 20px;
letter-spacing: -0.07px;
}
:global(.progress-container) {
:global(.ant-progress-bg) {
height: 8px !important;
border-radius: 4px;
}
}
:global(.ant-table-tbody > tr:hover > td) {
background: var(--bg-ink-500);
}
:global(.ant-table-tbody > tr:not(.ant-table-expanded-row):hover > td) {
background: var(--l2-background);
}
:global(.ant-table-tbody > tr > td) {
border-bottom: none;
}
:global(.ant-empty-normal) {
visibility: hidden;
}
:global(.ant-table-cell) {
min-width: 180px !important;
max-width: 180px !important;
}
:global(.ant-table-cell:first-of-type):not(:global(.ant-table-row-expand-icon-cell)) {
min-width: 250px !important;
max-width: 250px !important;
}
:global(.ant-table-row-expand-icon-cell) {
min-width: 40px !important;
max-width: 40px !important;
}
}
:global(.ant-pagination) {
width: 100%;
position: fixed;
bottom: 0;
right: 0;
padding: 16px;
background-color: var(--bg-ink-500);
margin: 0 !important;
padding-right: 72px;
:global(.ant-pagination-item) {
border-radius: 4px;
}
:global(.ant-pagination-item-active) {
background: var(--l2-background);
border-color: var(--l2-border);
a {
color: var(--l1-foreground) !important;
}
}
}
}
.noFilteredHostsMessageContainer {
height: 30vh;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
}
.noFilteredHostsMessageContent {
display: flex;
flex-direction: column;
align-items: flex-start;
justify-content: center;
width: fit-content;
padding: 24px;
}
.noFilteredHostsMessage {
margin-top: var(--spacing-4);
}
.emptyStateSvg {
width: 32px;
max-width: 100%;
}

View File

@@ -1,650 +0,0 @@
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { useQuery } from 'react-query';
// eslint-disable-next-line no-restricted-imports
import { LoadingOutlined } from '@ant-design/icons';
import {
Button,
Spin,
Table,
TableColumnType as ColumnType,
TablePaginationConfig,
TableProps,
Typography,
} from 'antd';
import type { SorterResult } from 'antd/es/table/interface';
import logEvent from 'api/common/logEvent';
import { InfraMonitoringEvents } from 'constants/events';
import { ChevronDown, ChevronRight, CornerDownRight } from 'lucide-react';
import { parseAsString, useQueryState } from 'nuqs';
import { useGlobalTimeStore } from 'store/globalTime';
import {
getAutoRefreshQueryKey,
NANO_SECOND_MULTIPLIER,
} from 'store/globalTime/utils';
import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
import {
IBuilderQuery,
TagFilter,
} from 'types/api/queryBuilder/queryBuilderData';
import { buildAbsolutePath, isModifierKeyPressed } from 'utils/app';
import { openInNewTab } from 'utils/navigation';
import { K8sCategory } from '../constants';
import {
useInfraMonitoringCurrentPage,
useInfraMonitoringFilters,
useInfraMonitoringGroupBy,
useInfraMonitoringOrderBy,
useInfraMonitoringQueryFilters,
useInfraMonitoringSelectedItem,
} from '../hooks';
import LoadingContainer from '../LoadingContainer';
import { OrderBySchemaType } from '../schemas';
import { usePageSize } from '../utils';
import K8sHeader from './K8sHeader';
import {
IEntityColumn,
useInfraMonitoringTableColumnsForPage,
useInfraMonitoringTableColumnsStore,
} from './useInfraMonitoringTableColumnsStore';
import styles from './K8sBaseList.module.scss';
export type K8sBaseFilters = {
filters?: TagFilter;
groupBy?: BaseAutocompleteData[];
offset?: number;
limit?: number;
start: number;
end: number;
orderBy?: OrderBySchemaType;
};
export type K8sRenderedRowData = {
/**
* The unique ID for the row
*/
key: string;
/**
* The ID to the selectedItem
*/
itemKey: string;
groupedByMeta: Record<string, string>;
[key: string]: unknown;
};
export type K8sBaseListProps<T = unknown> = {
controlListPrefix?: React.ReactNode;
entity: K8sCategory;
tableColumnsDefinitions: IEntityColumn[];
tableColumns: ColumnType<K8sRenderedRowData>[];
fetchListData: (
filters: K8sBaseFilters,
signal?: AbortSignal,
) => Promise<{
data: T[];
total: number;
error?: string | null;
}>;
renderRowData: (
record: T,
groupBy: BaseAutocompleteData[],
) => K8sRenderedRowData;
eventCategory: InfraMonitoringEvents;
};
export type K8sExpandedRowProps<T> = {
record: K8sRenderedRowData;
entity: K8sCategory;
tableColumns: ColumnType<K8sRenderedRowData>[];
fetchListData: K8sBaseListProps<T>['fetchListData'];
renderRowData: K8sBaseListProps<T>['renderRowData'];
};
function K8sExpandedRow<T>({
record,
entity,
tableColumns,
fetchListData,
renderRowData,
}: K8sExpandedRowProps<T>): JSX.Element {
const [groupBy, setGroupBy] = useInfraMonitoringGroupBy();
const [orderBy, setOrderBy] = useInfraMonitoringOrderBy();
const [, setCurrentPage] = useInfraMonitoringCurrentPage();
const [, setFilters] = useInfraMonitoringFilters();
const [, setSelectedItem] = useInfraMonitoringSelectedItem();
const queryFilters = useInfraMonitoringQueryFilters();
const [
columnsDefinitions,
columnsHidden,
] = useInfraMonitoringTableColumnsForPage(entity);
const hiddenColumnIdsForNested = useMemo(
() =>
columnsDefinitions
.filter((col) => col.behavior === 'hidden-on-collapse')
.map((col) => col.id),
[columnsDefinitions],
);
const nestedColumns = useMemo(
() =>
tableColumns.filter(
(c) =>
!columnsHidden.includes(c.key?.toString() || '') &&
!hiddenColumnIdsForNested.includes(c.key?.toString() || ''),
),
[tableColumns, columnsHidden, hiddenColumnIdsForNested],
);
const createFiltersForRecord = useCallback((): IBuilderQuery['filters'] => {
const baseFilters: IBuilderQuery['filters'] = {
items: [...queryFilters.items],
op: 'and',
};
const { groupedByMeta } = record;
for (const key of Object.keys(groupedByMeta)) {
baseFilters.items.push({
key: {
key,
type: null,
},
op: '=',
value: groupedByMeta[key],
id: key,
});
}
return baseFilters;
}, [queryFilters.items, record]);
const selectedTime = useGlobalTimeStore((s) => s.selectedTime);
const refreshInterval = useGlobalTimeStore((s) => s.refreshInterval);
const isRefreshEnabled = useGlobalTimeStore((s) => s.isRefreshEnabled);
const getMinMaxTime = useGlobalTimeStore((s) => s.getMinMaxTime);
const queryKey = useMemo(() => {
return getAutoRefreshQueryKey(selectedTime, [
'k8sExpandedRow',
record.key,
JSON.stringify(queryFilters),
JSON.stringify(orderBy),
]);
}, [selectedTime, record.key, queryFilters, orderBy]);
const { data, isFetching, isLoading, isError } = useQuery({
queryKey,
queryFn: ({ signal }) => {
const { minTime, maxTime } = getMinMaxTime();
return fetchListData(
{
limit: 10,
offset: 0,
filters: createFiltersForRecord(),
start: Math.floor(minTime / NANO_SECOND_MULTIPLIER),
end: Math.floor(maxTime / NANO_SECOND_MULTIPLIER),
orderBy: orderBy || undefined,
groupBy: undefined,
},
signal,
);
},
refetchInterval: isRefreshEnabled ? refreshInterval : false,
});
const formattedData = useMemo(() => {
if (!data?.data) {
return undefined;
}
const rows = data.data.map((item) => renderRowData(item, groupBy));
// Without handling duplicated keys, the table became unpredictable/unstable
const keyCount = new Map<string, number>();
return rows.map(
(row): K8sRenderedRowData => {
const count = keyCount.get(row.key) || 0;
keyCount.set(row.key, count + 1);
if (count > 0) {
return { ...row, key: `${row.key}-${count}` };
}
return row;
},
);
}, [data?.data, renderRowData, groupBy]);
const openRecordInNewTab = (rowRecord: K8sRenderedRowData): void => {
const newParams = new URLSearchParams(document.location.search);
newParams.set('selectedItem', rowRecord.itemKey);
openInNewTab(
buildAbsolutePath({
relativePath: '',
urlQueryString: newParams.toString(),
}),
);
};
const handleViewAllClick = (): void => {
const filters = createFiltersForRecord();
setFilters(JSON.stringify(filters));
setCurrentPage(1);
setGroupBy([]);
setOrderBy(null);
};
return (
<div
className={styles.expandedTableContainer}
data-testid="expanded-table-container"
>
{isError && (
<Typography>{data?.error?.toString() || 'Something went wrong'}</Typography>
)}
{isFetching || isLoading ? (
<LoadingContainer />
) : (
<div data-testid="expanded-table">
<Table
columns={nestedColumns}
dataSource={formattedData}
pagination={false}
scroll={{ x: true }}
tableLayout="fixed"
showHeader={false}
loading={{
spinning: isFetching || isLoading,
indicator: <Spin indicator={<LoadingOutlined size={14} spin />} />,
}}
onRow={(
rowRecord: K8sRenderedRowData,
): { onClick: (event: React.MouseEvent) => void; className: string } => ({
onClick: (event: React.MouseEvent): void => {
if (isModifierKeyPressed(event)) {
openRecordInNewTab(rowRecord);
return;
}
setSelectedItem(rowRecord.itemKey);
},
className: styles.expandedClickableRow,
})}
/>
{data?.total && data?.total > 10 && (
<div className={styles.expandedTableFooter}>
<Button
type="default"
size="small"
className={styles.viewAllButton}
onClick={handleViewAllClick}
>
<CornerDownRight size={14} />
View All
</Button>
</div>
)}
</div>
)}
</div>
);
}
export function K8sBaseList<T>({
controlListPrefix,
entity,
tableColumnsDefinitions,
tableColumns,
fetchListData,
renderRowData,
eventCategory,
}: K8sBaseListProps<T>): JSX.Element {
const queryFilters = useInfraMonitoringQueryFilters();
const [currentPage, setCurrentPage] = useInfraMonitoringCurrentPage();
const [groupBy] = useInfraMonitoringGroupBy();
const [orderBy, setOrderBy] = useInfraMonitoringOrderBy();
const [initialOrderBy] = useState(orderBy);
const [selectedItem, setSelectedItem] = useQueryState(
'selectedItem',
parseAsString,
);
const [expandedRowKeys, setExpandedRowKeys] = useState<string[]>([]);
useEffect(() => {
setExpandedRowKeys([]);
}, [groupBy, currentPage]);
const { pageSize, setPageSize } = usePageSize(entity);
const initializeTableColumns = useInfraMonitoringTableColumnsStore(
(state) => state.initializePageColumns,
);
useEffect(() => {
initializeTableColumns(entity, tableColumnsDefinitions);
}, [initializeTableColumns, entity, tableColumnsDefinitions]);
const selectedTime = useGlobalTimeStore((s) => s.selectedTime);
const refreshInterval = useGlobalTimeStore((s) => s.refreshInterval);
const isRefreshEnabled = useGlobalTimeStore((s) => s.isRefreshEnabled);
const getMinMaxTime = useGlobalTimeStore((s) => s.getMinMaxTime);
const queryKey = useMemo(() => {
return getAutoRefreshQueryKey(
selectedTime,
'k8sBaseList',
entity,
String(pageSize),
String(currentPage),
JSON.stringify(queryFilters),
JSON.stringify(orderBy),
JSON.stringify(groupBy),
);
}, [
selectedTime,
entity,
pageSize,
currentPage,
queryFilters,
orderBy,
groupBy,
]);
const { data, isLoading, isError } = useQuery({
queryKey,
queryFn: ({ signal }) => {
const { minTime, maxTime } = getMinMaxTime();
return fetchListData(
{
limit: pageSize,
offset: (currentPage - 1) * pageSize,
filters: queryFilters,
start: Math.floor(minTime / NANO_SECOND_MULTIPLIER),
end: Math.floor(maxTime / NANO_SECOND_MULTIPLIER),
orderBy: orderBy || undefined,
groupBy: groupBy?.length > 0 ? groupBy : undefined,
},
signal,
);
},
refetchInterval: isRefreshEnabled ? refreshInterval : false,
});
const pageData = data?.data;
const totalCount = data?.total || 0;
const formattedItemsData = useMemo(() => {
if (!pageData) {
return undefined;
}
const rows = pageData.map((item) => renderRowData(item, groupBy));
// Without handling duplicated keys, the table became unpredictable/unstable
const keyCount = new Map<string, number>();
return rows.map(
// eslint-disable-next-line sonarjs/no-identical-functions
(row): K8sRenderedRowData => {
const count = keyCount.get(row.key) || 0;
keyCount.set(row.key, count + 1);
if (count > 0) {
return { ...row, key: `${row.key}-${count}` };
}
return row;
},
);
}, [pageData, renderRowData, groupBy]);
const handleTableChange: TableProps<K8sRenderedRowData>['onChange'] = useCallback(
(
pagination: TablePaginationConfig,
_filters: Record<string, (string | number | boolean)[] | null>,
sorter:
| SorterResult<K8sRenderedRowData>
| SorterResult<K8sRenderedRowData>[],
): void => {
if (pagination.current) {
setCurrentPage(pagination.current);
logEvent(InfraMonitoringEvents.PageNumberChanged, {
entity: InfraMonitoringEvents.K8sEntity,
page: InfraMonitoringEvents.ListPage,
category: eventCategory,
});
}
if ('field' in sorter && sorter.order) {
setOrderBy({
columnName: sorter.field as string,
order: (sorter.order === 'ascend' ? 'asc' : 'desc') as 'asc' | 'desc',
});
} else {
setOrderBy(null);
}
},
[eventCategory, setCurrentPage, setOrderBy],
);
useEffect(() => {
logEvent(InfraMonitoringEvents.PageVisited, {
entity: InfraMonitoringEvents.K8sEntity,
page: InfraMonitoringEvents.ListPage,
category: eventCategory,
total: totalCount,
});
}, [eventCategory, totalCount]);
const handleGroupByRowClick = (record: K8sRenderedRowData): void => {
if (expandedRowKeys.includes(record.key)) {
setExpandedRowKeys(expandedRowKeys.filter((key) => key !== record.key));
} else {
setExpandedRowKeys([record.key]);
}
};
const openItemInNewTab = (record: K8sRenderedRowData): void => {
const newParams = new URLSearchParams(document.location.search);
newParams.set('selectedItem', record.itemKey);
openInNewTab(
buildAbsolutePath({
relativePath: '',
urlQueryString: newParams.toString(),
}),
);
};
const handleRowClick = (
record: K8sRenderedRowData,
event: React.MouseEvent,
): void => {
if (event && isModifierKeyPressed(event)) {
openItemInNewTab(record);
return;
}
if (groupBy.length === 0) {
setSelectedItem(record.itemKey);
} else {
handleGroupByRowClick(record);
}
logEvent(InfraMonitoringEvents.ItemClicked, {
entity: InfraMonitoringEvents.K8sEntity,
page: InfraMonitoringEvents.ListPage,
category: eventCategory,
});
};
const [
columnsDefinitions,
columnsHidden,
] = useInfraMonitoringTableColumnsForPage(entity);
const hiddenColumnIdsOnList = useMemo(
() =>
columnsDefinitions
.filter(
(col) =>
(groupBy?.length > 0 && col.behavior === 'hidden-on-expand') ||
(!groupBy?.length && col.behavior === 'hidden-on-collapse'),
)
.map((col) => col.id),
[columnsDefinitions, groupBy?.length],
);
const mapDefaultSort = useCallback(
(
tableColumn: ColumnType<K8sRenderedRowData>,
): ColumnType<K8sRenderedRowData> => {
if (tableColumn.key === initialOrderBy?.columnName) {
return {
...tableColumn,
defaultSortOrder: initialOrderBy?.order === 'asc' ? 'ascend' : 'descend',
};
}
return tableColumn;
},
[initialOrderBy?.columnName, initialOrderBy?.order],
);
const columns = useMemo(
() =>
tableColumns
.filter(
(c) =>
!hiddenColumnIdsOnList.includes(c.key?.toString() || '') &&
!columnsHidden.includes(c.key?.toString() || ''),
)
.map(mapDefaultSort),
[columnsHidden, hiddenColumnIdsOnList, mapDefaultSort, tableColumns],
);
const isGroupedByAttribute = groupBy.length > 0;
const expandedRowRender = (record: K8sRenderedRowData): JSX.Element => (
<K8sExpandedRow<T>
record={record}
entity={entity}
tableColumns={tableColumns}
fetchListData={fetchListData}
renderRowData={renderRowData}
/>
);
const expandRowIconRenderer = ({
expanded,
onExpand,
record,
}: {
expanded: boolean;
onExpand: (
record: K8sRenderedRowData,
e: React.MouseEvent<HTMLButtonElement>,
) => void;
record: K8sRenderedRowData;
}): JSX.Element | null => {
if (!isGroupedByAttribute) {
return null;
}
return expanded ? (
<Button
className="periscope-btn ghost"
onClick={(e: React.MouseEvent<HTMLButtonElement>): void =>
onExpand(record, e)
}
role="button"
>
<ChevronDown size={14} />
</Button>
) : (
<Button
className="periscope-btn ghost"
onClick={(e: React.MouseEvent<HTMLButtonElement>): void =>
onExpand(record, e)
}
role="button"
>
<ChevronRight size={14} />
</Button>
);
};
const onPaginationChange = (page: number, pageSize: number): void => {
setCurrentPage(page);
setPageSize(pageSize);
logEvent(InfraMonitoringEvents.PageNumberChanged, {
entity: InfraMonitoringEvents.K8sEntity,
page: InfraMonitoringEvents.ListPage,
category: eventCategory,
});
};
const showTableLoadingState = isLoading;
return (
<>
<K8sHeader
controlListPrefix={controlListPrefix}
entity={entity}
showAutoRefresh={!selectedItem}
/>
{isError && (
<Typography>{data?.error?.toString() || 'Something went wrong'}</Typography>
)}
<Table
className={styles.k8sListTable}
dataSource={showTableLoadingState ? [] : formattedItemsData}
columns={columns}
pagination={{
current: currentPage,
pageSize,
total: totalCount,
showSizeChanger: true,
hideOnSinglePage: false,
onChange: onPaginationChange,
}}
loading={{
spinning: showTableLoadingState,
indicator: <Spin indicator={<LoadingOutlined size={14} spin />} />,
}}
locale={{
emptyText: showTableLoadingState ? null : (
<div className={styles.noFilteredHostsMessageContainer}>
<div className={styles.noFilteredHostsMessageContent}>
<img
src="/Icons/emptyState.svg"
alt="thinking-emoji"
className={styles.emptyStateSvg}
/>
<Typography.Text className={styles.noFilteredHostsMessage}>
This query had no results. Edit your query and try again!
</Typography.Text>
</div>
</div>
),
}}
scroll={{ x: true }}
tableLayout="fixed"
onChange={handleTableChange}
onRow={(
record,
): { onClick: (event: React.MouseEvent) => void; className: string } => ({
onClick: (event: React.MouseEvent): void => handleRowClick(record, event),
className: styles.clickableRow,
})}
expandable={{
expandedRowRender: isGroupedByAttribute ? expandedRowRender : undefined,
expandIcon: expandRowIconRenderer,
expandedRowKeys,
}}
/>
</>
);
}

View File

@@ -1,36 +0,0 @@
.drawer {
--dialog-description-padding: 0px 0px var(--spacing-8) 0px;
}
.columnItem {
display: flex;
align-items: center;
}
.columnsTitle {
color: var(--l2-foreground);
font-size: var(--periscope-font-size-small, 11px);
font-weight: var(--periscope-font-weight-medium, 500);
text-transform: uppercase;
padding: var(--spacing-4) var(--spacing-8);
border-bottom: 1px solid var(--l2-border);
&:not(:first-of-type) {
border-top: 1px solid var(--l2-border);
}
}
.columnsList {
display: flex;
flex-direction: column;
}
.columnItem {
justify-content: flex-start !important;
width: 100%;
}
.horizontalDivider {
border-top: 1px solid var(--l1-border);
}

View File

@@ -1,102 +0,0 @@
import { Button, DrawerWrapper } from '@signozhq/ui';
import { K8sCategory } from '../constants';
import {
useInfraMonitoringTableColumnsForPage,
useInfraMonitoringTableColumnsStore,
} from './useInfraMonitoringTableColumnsStore';
import styles from './K8sFiltersSidePanel.module.scss';
function K8sFiltersSidePanel({
open,
onClose,
entity,
}: {
open: boolean;
onClose: () => void;
entity: K8sCategory;
}): JSX.Element {
const addColumn = useInfraMonitoringTableColumnsStore(
(state) => state.addColumn,
);
const removeColumn = useInfraMonitoringTableColumnsStore(
(state) => state.removeColumn,
);
const [columns, columnsHidden] = useInfraMonitoringTableColumnsForPage(entity);
const drawerContent = (
<>
<div className={styles.columnsTitle}>Added Columns (Click to remove)</div>
<div className={styles.columnsList}>
{columns
.filter(
(column) =>
!columnsHidden.includes(column.id) &&
column.behavior !== 'hidden-on-collapse',
)
.map((column) => (
<div className={styles.columnItem} key={column.value}>
{/*<GripVertical size={16} /> TODO: Add support back when update the table component */}
<Button
variant="ghost"
color="none"
className={styles.columnItem}
disabled={!column.canBeHidden}
data-testid={`remove-column-${column.id}`}
onClick={(): void => removeColumn(entity, column.id)}
>
{column.label}
</Button>
</div>
))}
</div>
<div className={styles.horizontalDivider} />
<div className={styles.columnsTitle}>Other Columns (Click to add)</div>
<div className={styles.columnsList}>
{columns
.filter((column) => columnsHidden.includes(column.id))
.map((column) => (
<div className={styles.columnItem} key={column.value}>
<Button
variant="ghost"
color="none"
className={styles.columnItem}
data-can-be-added="true"
data-testid={`add-column-${column.id}`}
onClick={(): void => addColumn(entity, column.id)}
tabIndex={0}
>
{column.label}
</Button>
</div>
))}
</div>
</>
);
return (
<DrawerWrapper
open={open}
onOpenChange={(isOpen): void => {
if (!isOpen) {
onClose();
}
}}
title="Columns"
direction="right"
showCloseButton
showOverlay={false}
className={styles.drawer}
>
{drawerContent}
</DrawerWrapper>
);
}
export default K8sFiltersSidePanel;

View File

@@ -1,80 +0,0 @@
.k8sListControls {
padding: var(--spacing-4);
display: flex;
justify-content: space-between;
align-items: center;
gap: var(--spacing-4);
:global(.ant-select-selector) {
border-radius: 2px;
border: 1px solid var(--l1-border) !important;
background-color: var(--l2-background) !important;
input {
font-size: 12px;
}
:global(.ant-tag .ant-typography) {
font-size: 12px;
}
}
}
.k8sListControlsLeft {
flex: 1;
display: flex;
align-items: center;
flex-wrap: wrap;
gap: var(--spacing-4);
.k8sQbSearchContainer {
flex: 1;
min-width: 240px;
max-width: 60%;
}
}
.k8sAttributeSearchContainer {
flex: 1;
min-width: 240px;
max-width: 40%;
display: flex;
align-items: center;
}
.groupByLabel {
min-width: max-content;
font-size: var(--periscope-font-size-base, 13px);
font-weight: var(--periscope-font-weight-regular, 400);
line-height: 18px;
letter-spacing: -0.07px;
border-radius: 2px 0px 0px 2px;
border: 1px solid var(--l1-border);
border-right: none;
border-top-right-radius: 0px;
border-bottom-right-radius: 0px;
display: flex;
height: 32px;
padding: var(--spacing-3) var(--spacing-3) var(--spacing-3) var(--spacing-4);
justify-content: center;
align-items: center;
gap: var(--spacing-2);
}
.groupBySelect {
:global(.ant-select-selector) {
border-left: none;
border-top-left-radius: 0px;
border-bottom-left-radius: 0px;
}
}
.k8sListControlsRight {
min-width: 240px;
display: flex;
align-items: center;
gap: var(--spacing-2);
}

View File

@@ -1,226 +0,0 @@
import React, { useCallback, useMemo, useState } from 'react';
import { Button } from '@signozhq/button';
import { Select } from 'antd';
import logEvent from 'api/common/logEvent';
import { InfraMonitoringEvents } from 'constants/events';
import { FeatureKeys } from 'constants/features';
import { initialQueriesMap } from 'constants/queryBuilder';
import QueryBuilderSearch from 'container/QueryBuilder/filters/QueryBuilderSearch';
import DateTimeSelectionV2 from 'container/TopNav/DateTimeSelectionV2';
import { useGetAggregateKeys } from 'hooks/queryBuilder/useGetAggregateKeys';
import { useQueryOperations } from 'hooks/queryBuilder/useQueryBuilderOperations';
import { SlidersHorizontal } from 'lucide-react';
import { useAppContext } from 'providers/App/App';
import { IBuilderQuery } from 'types/api/queryBuilder/queryBuilderData';
import { DataSource } from 'types/common/queryBuilder';
import { GetK8sEntityToAggregateAttribute, K8sCategory } from '../constants';
import {
useInfraMonitoringCurrentPage,
useInfraMonitoringFiltersK8s,
useInfraMonitoringGroupBy,
} from '../hooks';
import K8sFiltersSidePanel from './K8sFiltersSidePanel';
import styles from './K8sHeader.module.scss';
interface K8sHeaderProps {
controlListPrefix?: React.ReactNode;
entity: K8sCategory;
showAutoRefresh: boolean;
}
function K8sHeader({
controlListPrefix,
entity,
showAutoRefresh,
}: K8sHeaderProps): JSX.Element {
const [isFiltersSidePanelOpen, setIsFiltersSidePanelOpen] = useState(false);
const [urlFilters, setUrlFilters] = useInfraMonitoringFiltersK8s();
const currentQuery = initialQueriesMap[DataSource.METRICS];
const updatedCurrentQuery = useMemo(() => {
let { filters } = currentQuery.builder.queryData[0];
if (urlFilters) {
filters = urlFilters;
}
return {
...currentQuery,
builder: {
...currentQuery.builder,
queryData: [
{
...currentQuery.builder.queryData[0],
aggregateOperator: 'noop',
aggregateAttribute: {
...currentQuery.builder.queryData[0].aggregateAttribute,
},
filters,
},
],
},
};
}, [currentQuery, urlFilters]);
const query = useMemo(
() => updatedCurrentQuery?.builder?.queryData[0] || null,
[updatedCurrentQuery],
);
const { handleChangeQueryData } = useQueryOperations({
index: 0,
query: currentQuery.builder.queryData[0],
entityVersion: '',
});
const [, setCurrentPage] = useInfraMonitoringCurrentPage();
const handleChangeTagFilters = useCallback(
(value: IBuilderQuery['filters']) => {
setUrlFilters(value || null);
handleChangeQueryData('filters', value);
setCurrentPage(1);
if (value?.items && value?.items?.length > 0) {
logEvent(InfraMonitoringEvents.FilterApplied, {
entity: InfraMonitoringEvents.K8sEntity,
page: InfraMonitoringEvents.ListPage,
category: InfraMonitoringEvents.Pod,
});
}
},
[handleChangeQueryData, setCurrentPage, setUrlFilters],
);
const { featureFlags } = useAppContext();
const dotMetricsEnabled =
featureFlags?.find((flag) => flag.name === FeatureKeys.DOT_METRICS_ENABLED)
?.active || false;
const {
data: groupByFiltersData,
isLoading: isLoadingGroupByFilters,
} = useGetAggregateKeys(
{
dataSource: currentQuery.builder.queryData[0].dataSource,
aggregateAttribute: GetK8sEntityToAggregateAttribute(
entity,
dotMetricsEnabled,
),
aggregateOperator: 'noop',
searchText: '',
tagType: '',
},
{
queryKey: [currentQuery.builder.queryData[0].dataSource, 'noop'],
},
true,
entity,
);
const groupByOptions = useMemo(
() =>
groupByFiltersData?.payload?.attributeKeys?.map((filter) => ({
value: filter.key,
label: filter.key,
})) || [],
[groupByFiltersData],
);
const [groupBy, setGroupBy] = useInfraMonitoringGroupBy();
const handleGroupByChange = useCallback(
(value: IBuilderQuery['groupBy']) => {
const newGroupBy = [];
for (let index = 0; index < value.length; index++) {
const element = (value[index] as unknown) as string;
const key = groupByFiltersData?.payload?.attributeKeys?.find(
(k) => k.key === element,
);
if (key) {
newGroupBy.push(key);
}
}
// Reset pagination on switching to groupBy
setCurrentPage(1);
setGroupBy(newGroupBy);
logEvent(InfraMonitoringEvents.GroupByChanged, {
entity: InfraMonitoringEvents.K8sEntity,
page: InfraMonitoringEvents.ListPage,
category: InfraMonitoringEvents.Pod,
});
},
[groupByFiltersData, setCurrentPage, setGroupBy],
);
const onClickOutside = useCallback(() => {
setIsFiltersSidePanelOpen(false);
}, []);
return (
<div className={styles.k8sListControls}>
<div className={styles.k8sListControlsLeft}>
{controlListPrefix}
<div className={styles.k8sQbSearchContainer}>
<QueryBuilderSearch
query={query as IBuilderQuery}
onChange={handleChangeTagFilters}
isInfraMonitoring
disableNavigationShortcuts
entity={entity}
/>
</div>
<div className={styles.k8sAttributeSearchContainer}>
<div className={styles.groupByLabel}> Group by </div>
<Select
className={styles.groupBySelect}
loading={isLoadingGroupByFilters}
mode="multiple"
value={groupBy}
allowClear
maxTagCount="responsive"
placeholder="Search for attribute"
style={{ width: '100%' }}
options={groupByOptions}
onChange={handleGroupByChange}
/>
</div>
</div>
<div className={styles.k8sListControlsRight}>
<DateTimeSelectionV2
showAutoRefresh={showAutoRefresh}
showRefreshText={false}
hideShareModal
/>
<Button
type="button"
variant="ghost"
size="icon"
color="none"
disabled={groupBy?.length > 0}
data-testid="k8s-list-filters-button"
onClick={(): void => setIsFiltersSidePanelOpen(true)}
>
<SlidersHorizontal size={14} />
</Button>
</div>
<K8sFiltersSidePanel
open={isFiltersSidePanelOpen}
entity={entity}
onClose={onClickOutside}
/>
</div>
);
}
export default K8sHeader;

View File

@@ -1,113 +0,0 @@
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
export interface IEntityColumn {
label: string;
value: string;
id: string;
defaultVisibility: boolean;
canBeHidden: boolean;
behavior: 'hidden-on-expand' | 'hidden-on-collapse' | 'always-visible';
}
export interface IInfraMonitoringTableColumnsStore {
columns: Record<string, IEntityColumn[]>;
columnsHidden: Record<string, string[]>;
addColumn: (page: string, columnId: string) => void;
removeColumn: (page: string, columnId: string) => void;
initializePageColumns: (page: string, columns: IEntityColumn[]) => void;
}
export const useInfraMonitoringTableColumnsStore = create<IInfraMonitoringTableColumnsStore>()(
persist(
(set, get) => ({
columns: {},
columnsHidden: {},
addColumn: (page, columnId): void => {
const state = get();
const columnDefinition = state.columns[page]?.find(
(c) => c.id === columnId,
);
if (!columnDefinition) {
return;
}
if (!columnDefinition.canBeHidden) {
return;
}
const columnsHidden = state.columnsHidden[page];
if (columnsHidden.includes(columnId)) {
set({
columnsHidden: {
...state.columnsHidden,
[page]: columnsHidden.filter((id) => id !== columnId),
},
});
}
},
removeColumn: (page, columnId): void => {
const state = get();
const columnDefinition = state.columns[page]?.find(
(c) => c.id === columnId,
);
if (!columnDefinition) {
return;
}
if (!columnDefinition.canBeHidden) {
return;
}
const columnsHidden = state.columnsHidden[page];
if (!columnsHidden.includes(columnId)) {
set({
columnsHidden: {
...state.columnsHidden,
[page]: [...columnsHidden, columnId],
},
});
}
},
initializePageColumns: (page, columns): void => {
const state = get();
set({
columns: {
...state.columns,
[page]: columns,
},
});
if (state.columnsHidden[page] === undefined) {
set({
columnsHidden: {
...state.columnsHidden,
[page]: columns
.filter((c) => c.defaultVisibility === false)
.map((c) => c.id),
},
});
}
},
}),
{
name: '@signoz/infra-monitoring-columns',
},
),
);
export const useInfraMonitoringTableColumnsForPage = (
page: string,
): [columns: IEntityColumn[], columnsHidden: string[]] => {
const state = useInfraMonitoringTableColumnsStore((s) => s.columns);
const columnsHidden = useInfraMonitoringTableColumnsStore(
(s) => s.columnsHidden,
);
return [state[page] ?? [], columnsHidden[page] ?? []];
};

View File

@@ -1,18 +0,0 @@
.itemDataGroup {
display: flex;
align-items: center;
gap: 4px;
flex-wrap: wrap;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.itemDataGroupTagItem {
display: block !important;
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}

View File

@@ -1,92 +0,0 @@
import { Badge } from '@signozhq/ui';
import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
import { IBuilderQuery } from 'types/api/queryBuilder/queryBuilderData';
import styles from './utils.module.scss';
const dotToUnder: Record<string, string> = {
'k8s.node.name': 'k8s_node_name',
'k8s.cluster.name': 'k8s_cluster_name',
'k8s.node.uid': 'k8s_node_uid',
'k8s.cronjob.name': 'k8s_cronjob_name',
'k8s.daemonset.name': 'k8s_daemonset_name',
'k8s.deployment.name': 'k8s_deployment_name',
'k8s.job.name': 'k8s_job_name',
'k8s.namespace.name': 'k8s_namespace_name',
'k8s.pod.name': 'k8s_pod_name',
'k8s.pod.uid': 'k8s_pod_uid',
'k8s.statefulset.name': 'k8s_statefulset_name',
};
export function getGroupedByMeta<T extends { meta: Record<string, string> }>(
itemData: T,
groupBy: BaseAutocompleteData[],
): Record<string, string> {
const result: Record<string, string> = {};
groupBy.forEach((group) => {
const rawKey = group.key as string;
const metaKey = (dotToUnder[rawKey] ?? rawKey) as keyof typeof itemData.meta;
result[rawKey] = (itemData.meta[metaKey] || itemData.meta[rawKey]) ?? '';
});
return result;
}
export function getRowKey<T extends { meta: Record<string, string> }>(
itemData: T,
getItemIdentifier: () => string,
groupBy: BaseAutocompleteData[],
): string {
const nodeIdentifier = getItemIdentifier();
if (groupBy.length === 0) {
return nodeIdentifier || JSON.stringify(itemData.meta);
}
const groupedMeta = getGroupedByMeta(itemData, groupBy);
const groupKey = Object.values(groupedMeta).join('-');
if (groupKey && nodeIdentifier) {
return `${groupKey}-${nodeIdentifier}`;
}
if (groupKey) {
return groupKey;
}
if (nodeIdentifier) {
return nodeIdentifier;
}
return JSON.stringify(itemData.meta);
}
export function getGroupByEl<T extends { meta: Record<string, string> }>(
itemData: T,
groupBy: IBuilderQuery['groupBy'],
): React.ReactNode {
const groupByValues: string[] = [];
groupBy.forEach((group) => {
const rawKey = group.key as string;
// Choose mapped key if present, otherwise use rawKey
const metaKey = (dotToUnder[rawKey] ?? rawKey) as keyof typeof itemData.meta;
const value = itemData.meta[metaKey] || itemData.meta[rawKey] || '<no-value>';
groupByValues.push(value);
});
return (
<div className={styles.itemDataGroup}>
{groupByValues.map((value) => (
<Badge
key={value}
color="secondary"
className={styles.itemDataGroupTagItem}
>
{value === '' ? '<no-value>' : value}
</Badge>
))}
</div>
);
}

View File

@@ -0,0 +1,7 @@
import { K8sDaemonSetsData } from 'api/infraMonitoring/getK8sDaemonSetsList';
export type DaemonSetDetailsProps = {
daemonSet: K8sDaemonSetsData | null;
isModalTimeSelection: boolean;
onClose: () => void;
};

View File

@@ -0,0 +1,667 @@
/* eslint-disable sonarjs/no-identical-functions */
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
// eslint-disable-next-line no-restricted-imports
import { useSelector } from 'react-redux';
import { Color, Spacing } from '@signozhq/design-tokens';
import { Button, Divider, Drawer, Radio, Tooltip, Typography } from 'antd';
import type { RadioChangeEvent } from 'antd/lib';
import logEvent from 'api/common/logEvent';
import { VIEW_TYPES, VIEWS } from 'components/HostMetricsDetail/constants';
import { InfraMonitoringEvents } from 'constants/events';
import { QueryParams } from 'constants/query';
import {
initialQueryBuilderFormValuesMap,
initialQueryState,
} from 'constants/queryBuilder';
import ROUTES from 'constants/routes';
import { filterDuplicateFilters } from 'container/InfraMonitoringK8s/commonUtils';
import { K8sCategory } from 'container/InfraMonitoringK8s/constants';
import {
useInfraMonitoringEventsFilters,
useInfraMonitoringLogFilters,
useInfraMonitoringTracesFilters,
useInfraMonitoringView,
} from 'container/InfraMonitoringK8s/hooks';
import {
CustomTimeType,
Time,
} from 'container/TopNav/DateTimeSelectionV2/types';
import { useIsDarkMode } from 'hooks/useDarkMode';
import useUrlQuery from 'hooks/useUrlQuery';
import GetMinMax from 'lib/getMinMax';
import {
BarChart2,
ChevronsLeftRight,
Compass,
DraftingCompass,
ScrollText,
X,
} from 'lucide-react';
import { AppState } from 'store/reducers';
import { DataTypes } from 'types/api/queryBuilder/queryAutocompleteResponse';
import {
IBuilderQuery,
TagFilterItem,
} from 'types/api/queryBuilder/queryBuilderData';
import {
LogsAggregatorOperator,
TracesAggregatorOperator,
} from 'types/common/queryBuilder';
import { GlobalReducer } from 'types/reducer/globalTime';
import { v4 as uuidv4 } from 'uuid';
import DaemonSetEvents from '../../EntityDetailsUtils/EntityEvents';
import DaemonSetLogs from '../../EntityDetailsUtils/EntityLogs';
import DaemonSetMetrics from '../../EntityDetailsUtils/EntityMetrics';
import DaemonSetTraces from '../../EntityDetailsUtils/EntityTraces';
import { QUERY_KEYS } from '../../EntityDetailsUtils/utils';
import {
daemonSetWidgetInfo,
getDaemonSetMetricsQueryPayload,
} from './constants';
import { DaemonSetDetailsProps } from './DaemonSetDetails.interfaces';
import '../../EntityDetailsUtils/entityDetails.styles.scss';
function DaemonSetDetails({
daemonSet,
onClose,
isModalTimeSelection,
}: DaemonSetDetailsProps): JSX.Element {
const { maxTime, minTime, selectedTime } = useSelector<
AppState,
GlobalReducer
>((state) => state.globalTime);
const startMs = useMemo(() => Math.floor(Number(minTime) / 1000000000), [
minTime,
]);
const endMs = useMemo(() => Math.floor(Number(maxTime) / 1000000000), [
maxTime,
]);
const urlQuery = useUrlQuery();
const [modalTimeRange, setModalTimeRange] = useState(() => ({
startTime: startMs,
endTime: endMs,
}));
const lastSelectedInterval = useRef<Time | null>(null);
const [selectedInterval, setSelectedInterval] = useState<Time>(
lastSelectedInterval.current
? lastSelectedInterval.current
: (selectedTime as Time),
);
const [selectedView, setSelectedView] = useInfraMonitoringView();
const [logFiltersParam, setLogFiltersParam] = useInfraMonitoringLogFilters();
const [
tracesFiltersParam,
setTracesFiltersParam,
] = useInfraMonitoringTracesFilters();
const [
eventsFiltersParam,
setEventsFiltersParam,
] = useInfraMonitoringEventsFilters();
const isDarkMode = useIsDarkMode();
const initialFilters = useMemo(() => {
const filters =
selectedView === VIEW_TYPES.LOGS ? logFiltersParam : tracesFiltersParam;
if (filters) {
return filters;
}
return {
op: 'AND',
items: [
{
id: uuidv4(),
key: {
key: QUERY_KEYS.K8S_DAEMON_SET_NAME,
dataType: DataTypes.String,
type: 'resource',
id: 'k8s_daemonSet_name--string--resource--false',
},
op: '=',
value: daemonSet?.meta.k8s_daemonset_name || '',
},
{
id: uuidv4(),
key: {
key: QUERY_KEYS.K8S_NAMESPACE_NAME,
dataType: DataTypes.String,
type: 'resource',
id: 'k8s_daemonSet_name--string--resource--false',
},
op: '=',
value: daemonSet?.meta.k8s_namespace_name || '',
},
],
};
}, [
daemonSet?.meta.k8s_daemonset_name,
daemonSet?.meta.k8s_namespace_name,
selectedView,
logFiltersParam,
tracesFiltersParam,
]);
const initialEventsFilters = useMemo(() => {
if (eventsFiltersParam) {
return eventsFiltersParam;
}
return {
op: 'AND',
items: [
{
id: uuidv4(),
key: {
key: QUERY_KEYS.K8S_OBJECT_KIND,
dataType: DataTypes.String,
type: 'resource',
id: 'k8s.object.kind--string--resource--false',
},
op: '=',
value: 'DaemonSet',
},
{
id: uuidv4(),
key: {
key: QUERY_KEYS.K8S_OBJECT_NAME,
dataType: DataTypes.String,
type: 'resource',
id: 'k8s.object.name--string--resource--false',
},
op: '=',
value: daemonSet?.meta.k8s_daemonset_name || '',
},
],
};
}, [daemonSet?.meta.k8s_daemonset_name, eventsFiltersParam]);
const [logAndTracesFilters, setLogAndTracesFilters] = useState<
IBuilderQuery['filters']
>(initialFilters);
const [eventsFilters, setEventsFilters] = useState<IBuilderQuery['filters']>(
initialEventsFilters,
);
useEffect(() => {
if (daemonSet) {
logEvent(InfraMonitoringEvents.PageVisited, {
entity: InfraMonitoringEvents.K8sEntity,
page: InfraMonitoringEvents.DetailedPage,
category: InfraMonitoringEvents.DaemonSet,
});
}
}, [daemonSet]);
useEffect(() => {
setLogAndTracesFilters(initialFilters);
setEventsFilters(initialEventsFilters);
}, [initialFilters, initialEventsFilters]);
useEffect(() => {
const currentSelectedInterval = lastSelectedInterval.current || selectedTime;
setSelectedInterval(currentSelectedInterval as Time);
if (currentSelectedInterval !== 'custom') {
const { maxTime, minTime } = GetMinMax(currentSelectedInterval);
setModalTimeRange({
startTime: Math.floor(minTime / 1000000000),
endTime: Math.floor(maxTime / 1000000000),
});
}
}, [selectedTime, minTime, maxTime]);
const handleTabChange = (e: RadioChangeEvent): void => {
setSelectedView(e.target.value);
setLogFiltersParam(null);
setTracesFiltersParam(null);
setEventsFiltersParam(null);
logEvent(InfraMonitoringEvents.TabChanged, {
entity: InfraMonitoringEvents.K8sEntity,
page: InfraMonitoringEvents.DetailedPage,
category: InfraMonitoringEvents.DaemonSet,
view: e.target.value,
});
};
const handleTimeChange = useCallback(
(interval: Time | CustomTimeType, dateTimeRange?: [number, number]): void => {
lastSelectedInterval.current = interval as Time;
setSelectedInterval(interval as Time);
if (interval === 'custom' && dateTimeRange) {
setModalTimeRange({
startTime: Math.floor(dateTimeRange[0] / 1000),
endTime: Math.floor(dateTimeRange[1] / 1000),
});
} else {
const { maxTime, minTime } = GetMinMax(interval);
setModalTimeRange({
startTime: Math.floor(minTime / 1000000000),
endTime: Math.floor(maxTime / 1000000000),
});
}
logEvent(InfraMonitoringEvents.TimeUpdated, {
entity: InfraMonitoringEvents.K8sEntity,
page: InfraMonitoringEvents.DetailedPage,
category: InfraMonitoringEvents.DaemonSet,
interval,
view: selectedView,
});
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[],
);
const handleChangeLogFilters = useCallback(
(value: IBuilderQuery['filters'], view: VIEWS) => {
setLogAndTracesFilters((prevFilters) => {
const primaryFilters = prevFilters?.items?.filter((item) =>
[QUERY_KEYS.K8S_DAEMON_SET_NAME, QUERY_KEYS.K8S_NAMESPACE_NAME].includes(
item.key?.key ?? '',
),
);
const paginationFilter = value?.items?.find(
(item) => item.key?.key === 'id',
);
const newFilters = value?.items?.filter(
(item) =>
item.key?.key !== 'id' &&
item.key?.key !== QUERY_KEYS.K8S_DAEMON_SET_NAME,
);
if (newFilters && newFilters?.length > 0) {
logEvent(InfraMonitoringEvents.FilterApplied, {
entity: InfraMonitoringEvents.K8sEntity,
page: InfraMonitoringEvents.DetailedPage,
category: InfraMonitoringEvents.DaemonSet,
view: InfraMonitoringEvents.LogsView,
});
}
const updatedFilters = {
op: 'AND',
items: filterDuplicateFilters(
[
...(primaryFilters || []),
...(newFilters || []),
...(paginationFilter ? [paginationFilter] : []),
].filter((item): item is TagFilterItem => item !== undefined),
),
};
setLogFiltersParam(updatedFilters);
setSelectedView(view);
return updatedFilters;
});
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[],
);
const handleChangeTracesFilters = useCallback(
(value: IBuilderQuery['filters'], view: VIEWS) => {
setLogAndTracesFilters((prevFilters) => {
const primaryFilters = prevFilters?.items?.filter((item) =>
[QUERY_KEYS.K8S_DAEMON_SET_NAME, QUERY_KEYS.K8S_NAMESPACE_NAME].includes(
item.key?.key ?? '',
),
);
if (value?.items && value?.items?.length > 0) {
logEvent(InfraMonitoringEvents.FilterApplied, {
entity: InfraMonitoringEvents.K8sEntity,
page: InfraMonitoringEvents.DetailedPage,
category: InfraMonitoringEvents.DaemonSet,
view: InfraMonitoringEvents.TracesView,
});
}
const updatedFilters = {
op: 'AND',
items: filterDuplicateFilters(
[
...(primaryFilters || []),
...(value?.items?.filter(
(item) => item.key?.key !== QUERY_KEYS.K8S_DAEMON_SET_NAME,
) || []),
].filter((item): item is TagFilterItem => item !== undefined),
),
};
setTracesFiltersParam(updatedFilters);
setSelectedView(view);
return updatedFilters;
});
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[],
);
const handleChangeEventsFilters = useCallback(
(value: IBuilderQuery['filters'], view: VIEWS) => {
setEventsFilters((prevFilters) => {
const daemonSetKindFilter = prevFilters?.items?.find(
(item) => item.key?.key === QUERY_KEYS.K8S_OBJECT_KIND,
);
const daemonSetNameFilter = prevFilters?.items?.find(
(item) => item.key?.key === QUERY_KEYS.K8S_OBJECT_NAME,
);
if (value?.items && value?.items?.length > 0) {
logEvent(InfraMonitoringEvents.FilterApplied, {
entity: InfraMonitoringEvents.K8sEntity,
page: InfraMonitoringEvents.DetailedPage,
category: InfraMonitoringEvents.DaemonSet,
view: InfraMonitoringEvents.EventsView,
});
}
const updatedFilters = {
op: 'AND',
items: [
daemonSetKindFilter,
daemonSetNameFilter,
...(value?.items?.filter(
(item) =>
item.key?.key !== QUERY_KEYS.K8S_OBJECT_KIND &&
item.key?.key !== QUERY_KEYS.K8S_OBJECT_NAME,
) || []),
].filter((item): item is TagFilterItem => item !== undefined),
};
setEventsFiltersParam(updatedFilters);
setSelectedView(view);
return updatedFilters;
});
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[],
);
const handleExplorePagesRedirect = (): void => {
if (selectedInterval !== 'custom') {
urlQuery.set(QueryParams.relativeTime, selectedInterval);
} else {
urlQuery.delete(QueryParams.relativeTime);
urlQuery.set(QueryParams.startTime, modalTimeRange.startTime.toString());
urlQuery.set(QueryParams.endTime, modalTimeRange.endTime.toString());
}
logEvent(InfraMonitoringEvents.ExploreClicked, {
entity: InfraMonitoringEvents.K8sEntity,
page: InfraMonitoringEvents.DetailedPage,
category: InfraMonitoringEvents.DaemonSet,
view: selectedView,
});
if (selectedView === VIEW_TYPES.LOGS) {
const filtersWithoutPagination = {
...logAndTracesFilters,
items: logAndTracesFilters?.items?.filter((item) => item.key?.key !== 'id'),
};
const compositeQuery = {
...initialQueryState,
queryType: 'builder',
builder: {
...initialQueryState.builder,
queryData: [
{
...initialQueryBuilderFormValuesMap.logs,
aggregateOperator: LogsAggregatorOperator.NOOP,
filters: filtersWithoutPagination,
},
],
},
};
urlQuery.set('compositeQuery', JSON.stringify(compositeQuery));
window.open(
`${window.location.origin}${ROUTES.LOGS_EXPLORER}?${urlQuery.toString()}`,
'_blank',
);
} else if (selectedView === VIEW_TYPES.TRACES) {
const compositeQuery = {
...initialQueryState,
queryType: 'builder',
builder: {
...initialQueryState.builder,
queryData: [
{
...initialQueryBuilderFormValuesMap.traces,
aggregateOperator: TracesAggregatorOperator.NOOP,
filters: logAndTracesFilters,
},
],
},
};
urlQuery.set('compositeQuery', JSON.stringify(compositeQuery));
window.open(
`${window.location.origin}${ROUTES.TRACES_EXPLORER}?${urlQuery.toString()}`,
'_blank',
);
}
};
const handleClose = (): void => {
lastSelectedInterval.current = null;
setSelectedInterval(selectedTime as Time);
if (selectedTime !== 'custom') {
const { maxTime, minTime } = GetMinMax(selectedTime);
setModalTimeRange({
startTime: Math.floor(minTime / 1000000000),
endTime: Math.floor(maxTime / 1000000000),
});
}
setSelectedView(VIEW_TYPES.METRICS);
onClose();
};
return (
<Drawer
width="70%"
title={
<>
<Divider type="vertical" />
<Typography.Text className="title">
{daemonSet?.meta.k8s_daemonset_name}
</Typography.Text>
</>
}
placement="right"
onClose={handleClose}
open={!!daemonSet}
style={{
overscrollBehavior: 'contain',
background: isDarkMode ? Color.BG_INK_400 : Color.BG_VANILLA_100,
}}
className="entity-detail-drawer"
destroyOnClose
closeIcon={<X size={16} style={{ marginTop: Spacing.MARGIN_1 }} />}
>
{daemonSet && (
<>
<div className="entity-detail-drawer__entity">
<div className="entity-details-grid">
<div className="labels-row">
<Typography.Text
type="secondary"
className="entity-details-metadata-label"
>
Daemonset Name
</Typography.Text>
<Typography.Text
type="secondary"
className="entity-details-metadata-label"
>
Cluster Name
</Typography.Text>
<Typography.Text
type="secondary"
className="entity-details-metadata-label"
>
Namespace Name
</Typography.Text>
</div>
<div className="values-row">
<Typography.Text className="entity-details-metadata-value">
<Tooltip title={daemonSet.meta.k8s_daemonset_name}>
{daemonSet.meta.k8s_daemonset_name}
</Tooltip>
</Typography.Text>
<Typography.Text className="entity-details-metadata-value">
<Tooltip title={daemonSet.meta.k8s_cluster_name}>
{daemonSet.meta.k8s_cluster_name}
</Tooltip>
</Typography.Text>
<Typography.Text className="entity-details-metadata-value">
<Tooltip title={daemonSet.meta.k8s_namespace_name}>
{daemonSet.meta.k8s_namespace_name}
</Tooltip>
</Typography.Text>
</div>
</div>
</div>
<div className="views-tabs-container">
<Radio.Group
className="views-tabs"
onChange={handleTabChange}
value={selectedView}
>
<Radio.Button
className={
selectedView === VIEW_TYPES.METRICS ? 'selected_view tab' : 'tab'
}
value={VIEW_TYPES.METRICS}
>
<div className="view-title">
<BarChart2 size={14} />
Metrics
</div>
</Radio.Button>
<Radio.Button
className={
selectedView === VIEW_TYPES.LOGS ? 'selected_view tab' : 'tab'
}
value={VIEW_TYPES.LOGS}
>
<div className="view-title">
<ScrollText size={14} />
Logs
</div>
</Radio.Button>
<Radio.Button
className={
selectedView === VIEW_TYPES.TRACES ? 'selected_view tab' : 'tab'
}
value={VIEW_TYPES.TRACES}
>
<div className="view-title">
<DraftingCompass size={14} />
Traces
</div>
</Radio.Button>
<Radio.Button
className={
selectedView === VIEW_TYPES.EVENTS ? 'selected_view tab' : 'tab'
}
value={VIEW_TYPES.EVENTS}
>
<div className="view-title">
<ChevronsLeftRight size={14} />
Events
</div>
</Radio.Button>
</Radio.Group>
{(selectedView === VIEW_TYPES.LOGS ||
selectedView === VIEW_TYPES.TRACES) && (
<Button
icon={<Compass size={18} />}
className="compass-button"
onClick={handleExplorePagesRedirect}
/>
)}
</div>
{selectedView === VIEW_TYPES.METRICS && (
<DaemonSetMetrics
timeRange={modalTimeRange}
isModalTimeSelection={isModalTimeSelection}
handleTimeChange={handleTimeChange}
selectedInterval={selectedInterval}
entity={daemonSet}
entityWidgetInfo={daemonSetWidgetInfo}
getEntityQueryPayload={getDaemonSetMetricsQueryPayload}
category={K8sCategory.DAEMONSETS}
queryKey="daemonsetMetrics"
/>
)}
{selectedView === VIEW_TYPES.LOGS && (
<DaemonSetLogs
timeRange={modalTimeRange}
isModalTimeSelection={isModalTimeSelection}
handleTimeChange={handleTimeChange}
handleChangeLogFilters={handleChangeLogFilters}
logFilters={logAndTracesFilters}
selectedInterval={selectedInterval}
category={K8sCategory.DAEMONSETS}
queryKey="daemonsetLogs"
queryKeyFilters={[
QUERY_KEYS.K8S_DAEMON_SET_NAME,
QUERY_KEYS.K8S_NAMESPACE_NAME,
]}
/>
)}
{selectedView === VIEW_TYPES.TRACES && (
<DaemonSetTraces
timeRange={modalTimeRange}
isModalTimeSelection={isModalTimeSelection}
handleTimeChange={handleTimeChange}
handleChangeTracesFilters={handleChangeTracesFilters}
tracesFilters={logAndTracesFilters}
selectedInterval={selectedInterval}
queryKey="daemonsetTraces"
category={InfraMonitoringEvents.DaemonSet}
queryKeyFilters={[
QUERY_KEYS.K8S_DAEMON_SET_NAME,
QUERY_KEYS.K8S_NAMESPACE_NAME,
]}
/>
)}
{selectedView === VIEW_TYPES.EVENTS && (
<DaemonSetEvents
timeRange={modalTimeRange}
handleChangeEventFilters={handleChangeEventsFilters}
filters={eventsFilters}
isModalTimeSelection={isModalTimeSelection}
handleTimeChange={handleTimeChange}
selectedInterval={selectedInterval}
queryKey="daemonsetEvents"
category={K8sCategory.DAEMONSETS}
/>
)}
</>
)}
</Drawer>
);
}
export default DaemonSetDetails;

View File

@@ -1,72 +1,11 @@
import { K8sDaemonSetsData } from 'api/infraMonitoring/getK8sDaemonSetsList';
import { PANEL_TYPES } from 'constants/queryBuilder';
import { GetQueryResultsProps } from 'lib/dashboard/getQueryResults';
import { DataTypes } from 'types/api/queryBuilder/queryAutocompleteResponse';
import { TagFilter } from 'types/api/queryBuilder/queryBuilderData';
import { EQueryType } from 'types/common/dashboard';
import { DataSource, ReduceOperators } from 'types/common/queryBuilder';
import { v4 } from 'uuid';
import {
createFilterItem,
K8sDetailsMetadataConfig,
} from '../Base/K8sBaseDetails';
import { QUERY_KEYS } from '../EntityDetailsUtils/utils';
import { K8sDaemonSetsData } from './api';
export const k8sDaemonSetGetSelectedItemFilters = (
selectedItemId: string,
): TagFilter => ({
op: 'AND',
items: [
{
id: 'k8s_daemonset_name',
key: {
key: 'k8s_daemonset_name',
type: null,
},
op: '=',
value: selectedItemId,
},
],
});
export const k8sDaemonSetDetailsMetadataConfig: K8sDetailsMetadataConfig<K8sDaemonSetsData>[] = [
{
label: 'Daemonset Name',
getValue: (p): string => p.meta.k8s_daemonset_name,
},
{
label: 'Cluster Name',
getValue: (p): string => p.meta.k8s_cluster_name,
},
{
label: 'Namespace Name',
getValue: (p): string => p.meta.k8s_namespace_name,
},
];
export const k8sDaemonSetInitialFilters = [
QUERY_KEYS.K8S_DAEMON_SET_NAME,
QUERY_KEYS.K8S_NAMESPACE_NAME,
];
export const k8sDaemonSetInitialEventsFilter = (
item: K8sDaemonSetsData,
): ReturnType<typeof createFilterItem>[] => [
createFilterItem(QUERY_KEYS.K8S_OBJECT_KIND, 'DaemonSet'),
createFilterItem(QUERY_KEYS.K8S_OBJECT_NAME, item.meta.k8s_daemonset_name),
];
export const k8sDaemonSetInitialLogTracesFilter = (
item: K8sDaemonSetsData,
): ReturnType<typeof createFilterItem>[] => [
createFilterItem(QUERY_KEYS.K8S_DAEMON_SET_NAME, item.meta.k8s_daemonset_name),
createFilterItem(QUERY_KEYS.K8S_NAMESPACE_NAME, item.meta.k8s_namespace_name),
];
export const k8sDaemonSetGetEntityName = (item: K8sDaemonSetsData): string =>
item.meta.k8s_daemonset_name;
export const daemonSetWidgetInfo = [
{
title: 'CPU usage, request, limits',

View File

@@ -0,0 +1,3 @@
import DaemonSetDetails from './DaemonSetDetails';
export default DaemonSetDetails;

View File

@@ -0,0 +1,62 @@
.infra-monitoring-container {
.daemonSets-list-table {
.expanded-table-container {
padding-left: 20px;
}
.ant-table-cell.column-progress-bar {
min-width: 180px;
max-width: 180px;
}
.ant-table-row-expand-icon-cell {
min-width: 30px !important;
max-width: 30px !important;
}
.progress-container {
display: flex;
justify-content: start;
}
}
}
.ant-table-cell {
&:has(.daemonset-name-header) {
min-width: 250px !important;
max-width: 250px !important;
}
}
.ant-table-cell {
&:has(.namespace-name-header) {
min-width: 220px !important;
max-width: 220px !important;
}
}
.ant-table-cell {
&:has(.med-col) {
min-width: 200px !important;
max-width: 200px !important;
}
}
.ant-table-cell {
&:has(.small-col) {
min-width: 120px !important;
max-width: 120px !important;
}
}
.expanded-daemonsets-list-table {
.ant-table-cell {
min-width: 220px !important;
max-width: 220px !important;
}
.ant-table-row-expand-icon-cell {
min-width: 30px !important;
max-width: 30px !important;
}
}

View File

@@ -1,116 +1,715 @@
import React, { useCallback } from 'react';
import React, { useCallback, useEffect, useMemo, useState } from 'react';
// eslint-disable-next-line no-restricted-imports
import { useSelector } from 'react-redux';
import { LoadingOutlined } from '@ant-design/icons';
import {
Button,
Spin,
Table,
TableColumnType as ColumnType,
TablePaginationConfig,
TableProps,
Typography,
} from 'antd';
import type { SorterResult } from 'antd/es/table/interface';
import logEvent from 'api/common/logEvent';
import { K8sDaemonSetsListPayload } from 'api/infraMonitoring/getK8sDaemonSetsList';
import classNames from 'classnames';
import { InfraMonitoringEvents } from 'constants/events';
import { FeatureKeys } from 'constants/features';
import { useAppContext } from 'providers/App/App';
import { useGetK8sDaemonSetsList } from 'hooks/infraMonitoring/useGetK8sDaemonSetsList';
import { useGetAggregateKeys } from 'hooks/queryBuilder/useGetAggregateKeys';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { useQueryOperations } from 'hooks/queryBuilder/useQueryBuilderOperations';
import { ChevronDown, ChevronRight } from 'lucide-react';
import { AppState } from 'store/reducers';
import { IBuilderQuery } from 'types/api/queryBuilder/queryBuilderData';
import { GlobalReducer } from 'types/reducer/globalTime';
import { buildAbsolutePath, isModifierKeyPressed } from 'utils/app';
import { openInNewTab } from 'utils/navigation';
import K8sBaseDetails, { K8sDetailsFilters } from '../Base/K8sBaseDetails';
import { K8sBaseFilters, K8sBaseList } from '../Base/K8sBaseList';
import { K8sCategory } from '../constants';
import { getK8sDaemonSetsList, K8sDaemonSetsData } from './api';
import { FeatureKeys } from '../../../constants/features';
import { useAppContext } from '../../../providers/App/App';
import {
daemonSetWidgetInfo,
getDaemonSetMetricsQueryPayload,
k8sDaemonSetDetailsMetadataConfig,
k8sDaemonSetGetEntityName,
k8sDaemonSetGetSelectedItemFilters,
k8sDaemonSetInitialEventsFilter,
k8sDaemonSetInitialFilters,
k8sDaemonSetInitialLogTracesFilter,
} from './constants';
GetK8sEntityToAggregateAttribute,
INFRA_MONITORING_K8S_PARAMS_KEYS,
K8sCategory,
} from '../constants';
import {
k8sDaemonSetsColumns,
k8sDaemonSetsColumnsConfig,
k8sDaemonSetsRenderRowData,
} from './table';
useInfraMonitoringCurrentPage,
useInfraMonitoringDaemonSetUID,
useInfraMonitoringEventsFilters,
useInfraMonitoringGroupBy,
useInfraMonitoringLogFilters,
useInfraMonitoringOrderBy,
useInfraMonitoringTracesFilters,
useInfraMonitoringView,
} from '../hooks';
import K8sHeader from '../K8sHeader';
import LoadingContainer from '../LoadingContainer';
import { usePageSize } from '../utils';
import DaemonSetDetails from './DaemonSetDetails';
import {
defaultAddedColumns,
formatDataForTable,
getK8sDaemonSetsListColumns,
getK8sDaemonSetsListQuery,
K8sDaemonSetsRowData,
} from './utils';
import '../InfraMonitoringK8s.styles.scss';
import './K8sDaemonSetsList.styles.scss';
function K8sDaemonSetsList({
controlListPrefix,
isFiltersVisible,
handleFilterVisibilityChange,
quickFiltersLastUpdated,
}: {
controlListPrefix?: React.ReactNode;
isFiltersVisible: boolean;
handleFilterVisibilityChange: () => void;
quickFiltersLastUpdated: number;
}): JSX.Element {
const { maxTime, minTime } = useSelector<AppState, GlobalReducer>(
(state) => state.globalTime,
);
const [currentPage, setCurrentPage] = useInfraMonitoringCurrentPage();
const [filtersInitialised, setFiltersInitialised] = useState(false);
const [expandedRowKeys, setExpandedRowKeys] = useState<string[]>([]);
const [orderBy, setOrderBy] = useInfraMonitoringOrderBy();
const [
selectedDaemonSetUID,
setSelectedDaemonSetUID,
] = useInfraMonitoringDaemonSetUID();
const { pageSize, setPageSize } = usePageSize(K8sCategory.DAEMONSETS);
const [groupBy, setGroupBy] = useInfraMonitoringGroupBy();
const [, setView] = useInfraMonitoringView();
const [, setTracesFilters] = useInfraMonitoringTracesFilters();
const [, setEventsFilters] = useInfraMonitoringEventsFilters();
const [, setLogFilters] = useInfraMonitoringLogFilters();
const [
selectedRowData,
setSelectedRowData,
] = useState<K8sDaemonSetsRowData | null>(null);
const [groupByOptions, setGroupByOptions] = useState<
{ value: string; label: string }[]
>([]);
const { currentQuery } = useQueryBuilder();
const queryFilters = useMemo(
() =>
currentQuery?.builder?.queryData[0]?.filters || {
items: [],
op: 'and',
},
[currentQuery?.builder?.queryData],
);
// Reset pagination every time quick filters are changed
useEffect(() => {
if (quickFiltersLastUpdated !== -1) {
setCurrentPage(1);
}
}, [quickFiltersLastUpdated, setCurrentPage]);
const { featureFlags } = useAppContext();
const dotMetricsEnabled =
featureFlags?.find((flag) => flag.name === FeatureKeys.DOT_METRICS_ENABLED)
?.active || false;
const fetchListData = useCallback(
async (filters: K8sBaseFilters, signal?: AbortSignal) => {
filters.orderBy ||= {
columnName: 'cpu',
order: 'desc',
};
const createFiltersForSelectedRowData = (
selectedRowData: K8sDaemonSetsRowData,
groupBy: IBuilderQuery['groupBy'],
): IBuilderQuery['filters'] => {
const baseFilters: IBuilderQuery['filters'] = {
items: [...queryFilters.items],
op: 'and',
};
const response = await getK8sDaemonSetsList(
filters,
signal,
undefined,
dotMetricsEnabled,
);
if (!selectedRowData) {
return baseFilters;
}
return {
data: response.payload?.data.records || [],
total: response.payload?.data.total || 0,
error: response.error,
};
},
[dotMetricsEnabled],
);
const { groupedByMeta } = selectedRowData;
const fetchEntityData = useCallback(
async (
filters: K8sDetailsFilters,
signal?: AbortSignal,
): Promise<{ data: K8sDaemonSetsData | null; error?: string | null }> => {
const response = await getK8sDaemonSetsList(
{
filters: filters.filters,
start: filters.start,
end: filters.end,
limit: 1,
offset: 0,
for (const key of groupBy) {
baseFilters.items.push({
key: {
key: key.key,
type: null,
},
signal,
undefined,
dotMetricsEnabled,
);
op: '=',
value: groupedByMeta[key.key],
id: key.key,
});
}
const records = response.payload?.data.records || [];
return baseFilters;
};
return {
data: records.length > 0 ? records[0] : null,
error: response.error,
};
const fetchGroupedByRowDataQuery = useMemo(() => {
if (!selectedRowData) {
return null;
}
const baseQuery = getK8sDaemonSetsListQuery();
const filters = createFiltersForSelectedRowData(selectedRowData, groupBy);
return {
...baseQuery,
limit: 10,
offset: 0,
filters,
start: Math.floor(minTime / 1000000),
end: Math.floor(maxTime / 1000000),
orderBy: orderBy || baseQuery.orderBy,
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [minTime, maxTime, orderBy, selectedRowData, groupBy]);
const groupedByRowDataQueryKey = useMemo(() => {
// be careful with what you serialize from selectedRowData
// since it's react node, it could contain circular references
const selectedRowDataKey = JSON.stringify(selectedRowData?.groupedByMeta);
if (selectedDaemonSetUID) {
return [
'daemonSetList',
JSON.stringify(queryFilters),
JSON.stringify(orderBy),
selectedRowDataKey,
];
}
return [
'daemonSetList',
JSON.stringify(queryFilters),
JSON.stringify(orderBy),
selectedRowDataKey,
String(minTime),
String(maxTime),
];
}, [
queryFilters,
orderBy,
selectedDaemonSetUID,
minTime,
maxTime,
selectedRowData,
]);
const {
data: groupedByRowData,
isFetching: isFetchingGroupedByRowData,
isLoading: isLoadingGroupedByRowData,
isError: isErrorGroupedByRowData,
refetch: fetchGroupedByRowData,
} = useGetK8sDaemonSetsList(
fetchGroupedByRowDataQuery as K8sDaemonSetsListPayload,
{
queryKey: groupedByRowDataQueryKey,
enabled: !!fetchGroupedByRowDataQuery && !!selectedRowData,
},
[dotMetricsEnabled],
undefined,
dotMetricsEnabled,
);
const {
data: groupByFiltersData,
isLoading: isLoadingGroupByFilters,
} = useGetAggregateKeys(
{
dataSource: currentQuery.builder.queryData[0].dataSource,
aggregateAttribute: GetK8sEntityToAggregateAttribute(
K8sCategory.DAEMONSETS,
dotMetricsEnabled,
),
aggregateOperator: 'noop',
searchText: '',
tagType: '',
},
{
queryKey: [currentQuery.builder.queryData[0].dataSource, 'noop'],
},
true,
K8sCategory.DAEMONSETS,
);
const query = useMemo(() => {
const baseQuery = getK8sDaemonSetsListQuery();
const queryPayload = {
...baseQuery,
limit: pageSize,
offset: (currentPage - 1) * pageSize,
filters: queryFilters,
start: Math.floor(minTime / 1000000),
end: Math.floor(maxTime / 1000000),
orderBy: orderBy || baseQuery.orderBy,
};
if (groupBy.length > 0) {
queryPayload.groupBy = groupBy;
}
return queryPayload;
}, [pageSize, currentPage, queryFilters, minTime, maxTime, orderBy, groupBy]);
const formattedGroupedByDaemonSetsData = useMemo(
() =>
formatDataForTable(groupedByRowData?.payload?.data?.records || [], groupBy),
[groupedByRowData, groupBy],
);
const queryKey = useMemo(() => {
if (selectedDaemonSetUID) {
return [
'daemonSetList',
String(pageSize),
String(currentPage),
JSON.stringify(queryFilters),
JSON.stringify(orderBy),
JSON.stringify(groupBy),
];
}
return [
'daemonSetList',
String(pageSize),
String(currentPage),
JSON.stringify(queryFilters),
JSON.stringify(orderBy),
JSON.stringify(groupBy),
String(minTime),
String(maxTime),
];
}, [
selectedDaemonSetUID,
pageSize,
currentPage,
queryFilters,
orderBy,
groupBy,
minTime,
maxTime,
]);
const { data, isFetching, isLoading, isError } = useGetK8sDaemonSetsList(
query as K8sDaemonSetsListPayload,
{
queryKey,
enabled: !!query,
keepPreviousData: true,
},
undefined,
dotMetricsEnabled,
);
const daemonSetsData = useMemo(() => data?.payload?.data?.records || [], [
data,
]);
const totalCount = data?.payload?.data?.total || 0;
const formattedDaemonSetsData = useMemo(
() => formatDataForTable(daemonSetsData, groupBy),
[daemonSetsData, groupBy],
);
const nestedDaemonSetsData = useMemo(() => {
if (!selectedRowData || !groupedByRowData?.payload?.data.records) {
return [];
}
return groupedByRowData?.payload?.data?.records || [];
}, [groupedByRowData, selectedRowData]);
const columns = useMemo(() => getK8sDaemonSetsListColumns(groupBy), [groupBy]);
const handleGroupByRowClick = (record: K8sDaemonSetsRowData): void => {
setSelectedRowData(record);
if (expandedRowKeys.includes(record.key)) {
setExpandedRowKeys(expandedRowKeys.filter((key) => key !== record.key));
} else {
setExpandedRowKeys([record.key]);
}
};
useEffect(() => {
if (selectedRowData) {
fetchGroupedByRowData();
}
}, [selectedRowData, fetchGroupedByRowData]);
const handleTableChange: TableProps<K8sDaemonSetsRowData>['onChange'] = useCallback(
(
pagination: TablePaginationConfig,
_filters: Record<string, (string | number | boolean)[] | null>,
sorter:
| SorterResult<K8sDaemonSetsRowData>
| SorterResult<K8sDaemonSetsRowData>[],
): void => {
if (pagination.current) {
setCurrentPage(pagination.current);
logEvent(InfraMonitoringEvents.PageNumberChanged, {
entity: InfraMonitoringEvents.K8sEntity,
page: InfraMonitoringEvents.ListPage,
category: InfraMonitoringEvents.DaemonSet,
});
}
if ('field' in sorter && sorter.order) {
setOrderBy({
columnName: sorter.field as string,
order: (sorter.order === 'ascend' ? 'asc' : 'desc') as 'asc' | 'desc',
});
} else {
setOrderBy(null);
}
},
[setCurrentPage, setOrderBy],
);
const { handleChangeQueryData } = useQueryOperations({
index: 0,
query: currentQuery.builder.queryData[0],
entityVersion: '',
});
const handleFiltersChange = useCallback(
(value: IBuilderQuery['filters']): void => {
handleChangeQueryData('filters', value);
if (filtersInitialised) {
setCurrentPage(1);
} else {
setFiltersInitialised(true);
}
if (value?.items && value?.items?.length > 0) {
logEvent(InfraMonitoringEvents.FilterApplied, {
entity: InfraMonitoringEvents.K8sEntity,
page: InfraMonitoringEvents.ListPage,
category: InfraMonitoringEvents.DaemonSet,
});
}
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[],
);
useEffect(() => {
logEvent(InfraMonitoringEvents.PageVisited, {
entity: InfraMonitoringEvents.K8sEntity,
page: InfraMonitoringEvents.ListPage,
category: InfraMonitoringEvents.DaemonSet,
total: data?.payload?.data?.total,
});
}, [data?.payload?.data?.total]);
const selectedDaemonSetData = useMemo(() => {
if (groupBy.length > 0) {
// If grouped by, return the daemonset from the formatted grouped by data
return (
nestedDaemonSetsData.find(
(daemonSet) => daemonSet.daemonSetName === selectedDaemonSetUID,
) || null
);
}
// If not grouped by, return the daemonset from the daemonsets data
return (
daemonSetsData.find(
(daemonSet) => daemonSet.daemonSetName === selectedDaemonSetUID,
) || null
);
}, [
selectedDaemonSetUID,
groupBy.length,
daemonSetsData,
nestedDaemonSetsData,
]);
const openDaemonSetInNewTab = (record: K8sDaemonSetsRowData): void => {
const newParams = new URLSearchParams(document.location.search);
newParams.set(
INFRA_MONITORING_K8S_PARAMS_KEYS.DAEMONSET_UID,
record.daemonsetUID,
);
openInNewTab(
buildAbsolutePath({
relativePath: '',
urlQueryString: newParams.toString(),
}),
);
};
const handleRowClick = (
record: K8sDaemonSetsRowData,
event?: React.MouseEvent,
): void => {
if (event && isModifierKeyPressed(event)) {
openDaemonSetInNewTab(record);
return;
}
if (groupBy.length === 0) {
setSelectedRowData(null);
setSelectedDaemonSetUID(record.daemonsetUID);
} else {
handleGroupByRowClick(record);
}
logEvent(InfraMonitoringEvents.ItemClicked, {
entity: InfraMonitoringEvents.K8sEntity,
page: InfraMonitoringEvents.ListPage,
category: InfraMonitoringEvents.DaemonSet,
});
};
const nestedColumns = useMemo(() => getK8sDaemonSetsListColumns([]), []);
const isGroupedByAttribute = groupBy.length > 0;
const handleExpandedRowViewAllClick = (): void => {
if (!selectedRowData) {
return;
}
const filters = createFiltersForSelectedRowData(selectedRowData, groupBy);
handleFiltersChange(filters);
setCurrentPage(1);
setSelectedRowData(null);
setGroupBy([]);
setOrderBy(null);
};
const expandedRowRender = (): JSX.Element => (
<div className="expanded-table-container">
{isErrorGroupedByRowData && (
<Typography>{groupedByRowData?.error || 'Something went wrong'}</Typography>
)}
{isFetchingGroupedByRowData || isLoadingGroupedByRowData ? (
<LoadingContainer />
) : (
<div className="expanded-table">
<Table
columns={nestedColumns as ColumnType<K8sDaemonSetsRowData>[]}
dataSource={formattedGroupedByDaemonSetsData}
pagination={false}
scroll={{ x: true }}
tableLayout="fixed"
size="small"
loading={{
spinning: isFetchingGroupedByRowData || isLoadingGroupedByRowData,
indicator: <Spin indicator={<LoadingOutlined size={14} spin />} />,
}}
showHeader={false}
onRow={(
record,
): { onClick: (event: React.MouseEvent) => void; className: string } => ({
onClick: (event: React.MouseEvent): void => {
if (event && isModifierKeyPressed(event)) {
openDaemonSetInNewTab(record);
return;
}
setSelectedDaemonSetUID(record.daemonsetUID);
},
className: 'expanded-clickable-row',
})}
/>
{groupedByRowData?.payload?.data?.total &&
groupedByRowData?.payload?.data?.total > 10 ? (
<div className="expanded-table-footer">
<Button
type="default"
size="small"
className="periscope-btn secondary"
onClick={handleExpandedRowViewAllClick}
>
View All
</Button>
</div>
) : null}
</div>
)}
</div>
);
const expandRowIconRenderer = ({
expanded,
onExpand,
record,
}: {
expanded: boolean;
onExpand: (
record: K8sDaemonSetsRowData,
e: React.MouseEvent<HTMLButtonElement>,
) => void;
record: K8sDaemonSetsRowData;
}): JSX.Element | null => {
if (!isGroupedByAttribute) {
return null;
}
return expanded ? (
<Button
className="periscope-btn ghost"
onClick={(e: React.MouseEvent<HTMLButtonElement>): void =>
onExpand(record, e)
}
role="button"
>
<ChevronDown size={14} />
</Button>
) : (
<Button
className="periscope-btn ghost"
onClick={(e: React.MouseEvent<HTMLButtonElement>): void =>
onExpand(record, e)
}
role="button"
>
<ChevronRight size={14} />
</Button>
);
};
const handleCloseDaemonSetDetail = (): void => {
setSelectedDaemonSetUID(null);
setView(null);
setTracesFilters(null);
setEventsFilters(null);
setLogFilters(null);
};
const handleGroupByChange = useCallback(
(value: IBuilderQuery['groupBy']) => {
const groupBy = [];
for (let index = 0; index < value.length; index++) {
const element = (value[index] as unknown) as string;
const key = groupByFiltersData?.payload?.attributeKeys?.find(
(key) => key.key === element,
);
if (key) {
groupBy.push(key);
}
}
setCurrentPage(1);
setGroupBy(groupBy);
setExpandedRowKeys([]);
logEvent(InfraMonitoringEvents.GroupByChanged, {
entity: InfraMonitoringEvents.K8sEntity,
page: InfraMonitoringEvents.ListPage,
category: InfraMonitoringEvents.DaemonSet,
});
},
[groupByFiltersData, setCurrentPage, setGroupBy],
);
useEffect(() => {
if (groupByFiltersData?.payload) {
setGroupByOptions(
groupByFiltersData?.payload?.attributeKeys?.map((filter) => ({
value: filter.key,
label: filter.key,
})) || [],
);
}
}, [groupByFiltersData]);
const onPaginationChange = (page: number, pageSize: number): void => {
setCurrentPage(page);
setPageSize(pageSize);
logEvent(InfraMonitoringEvents.PageNumberChanged, {
entity: InfraMonitoringEvents.K8sEntity,
page: InfraMonitoringEvents.ListPage,
category: InfraMonitoringEvents.DaemonSet,
});
};
const showTableLoadingState =
(isFetching || isLoading) && formattedDaemonSetsData.length === 0;
return (
<>
<K8sBaseList<K8sDaemonSetsData>
controlListPrefix={controlListPrefix}
<div className="k8s-list">
<K8sHeader
isFiltersVisible={isFiltersVisible}
handleFilterVisibilityChange={handleFilterVisibilityChange}
defaultAddedColumns={defaultAddedColumns}
handleFiltersChange={handleFiltersChange}
groupByOptions={groupByOptions}
isLoadingGroupByFilters={isLoadingGroupByFilters}
handleGroupByChange={handleGroupByChange}
selectedGroupBy={groupBy}
entity={K8sCategory.DAEMONSETS}
tableColumnsDefinitions={k8sDaemonSetsColumns}
tableColumns={k8sDaemonSetsColumnsConfig}
fetchListData={fetchListData}
renderRowData={k8sDaemonSetsRenderRowData}
eventCategory={InfraMonitoringEvents.DaemonSet}
showAutoRefresh={!selectedDaemonSetData}
/>
{isError && <Typography>{data?.error || 'Something went wrong'}</Typography>}
<Table
className={classNames('k8s-list-table', 'daemonSets-list-table', {
'expanded-daemonsets-list-table': isGroupedByAttribute,
})}
dataSource={showTableLoadingState ? [] : formattedDaemonSetsData}
columns={columns}
pagination={{
current: currentPage,
pageSize,
total: totalCount,
showSizeChanger: true,
hideOnSinglePage: false,
onChange: onPaginationChange,
}}
scroll={{ x: true }}
loading={{
spinning: showTableLoadingState,
indicator: <Spin indicator={<LoadingOutlined size={14} spin />} />,
}}
locale={{
emptyText: showTableLoadingState ? null : (
<div className="no-filtered-hosts-message-container">
<div className="no-filtered-hosts-message-content">
<img
src="/Icons/emptyState.svg"
alt="thinking-emoji"
className="empty-state-svg"
/>
<Typography.Text className="no-filtered-hosts-message">
This query had no results. Edit your query and try again!
</Typography.Text>
</div>
</div>
),
}}
tableLayout="fixed"
onChange={handleTableChange}
onRow={(
record,
): { onClick: (event: React.MouseEvent) => void; className: string } => ({
onClick: (event: React.MouseEvent): void => handleRowClick(record, event),
className: 'clickable-row',
})}
expandable={{
expandedRowRender: isGroupedByAttribute ? expandedRowRender : undefined,
expandIcon: expandRowIconRenderer,
expandedRowKeys,
}}
/>
<K8sBaseDetails<K8sDaemonSetsData>
category={K8sCategory.DAEMONSETS}
eventCategory={InfraMonitoringEvents.DaemonSet}
getSelectedItemFilters={k8sDaemonSetGetSelectedItemFilters}
fetchEntityData={fetchEntityData}
getEntityName={k8sDaemonSetGetEntityName}
getInitialLogTracesFilters={k8sDaemonSetInitialLogTracesFilter}
getInitialEventsFilters={k8sDaemonSetInitialEventsFilter}
primaryFilterKeys={k8sDaemonSetInitialFilters}
metadataConfig={k8sDaemonSetDetailsMetadataConfig}
entityWidgetInfo={daemonSetWidgetInfo}
getEntityQueryPayload={getDaemonSetMetricsQueryPayload}
queryKeyPrefix="daemonset"
<DaemonSetDetails
daemonSet={selectedDaemonSetData}
isModalTimeSelection
onClose={handleCloseDaemonSetDetail}
/>
</>
</div>
);
}

View File

@@ -1,11 +0,0 @@
.entityGroupHeader {
padding-left: var(--spacing-5);
gap: var(--spacing-5);
display: flex;
align-items: center;
}
.progressBar {
display: flex;
justify-content: start;
}

View File

@@ -1,297 +0,0 @@
import { TableColumnType as ColumnType, Tooltip } from 'antd';
import { Group } from 'lucide-react';
import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
import { K8sRenderedRowData } from '../Base/K8sBaseList';
import { IEntityColumn } from '../Base/useInfraMonitoringTableColumnsStore';
import { getGroupByEl, getGroupedByMeta, getRowKey } from '../Base/utils';
import {
EntityProgressBar,
formatBytes,
ValidateColumnValueWrapper,
} from '../commonUtils';
import { K8sCategory } from '../constants';
import { K8sDaemonSetsData } from './api';
import styles from './table.module.scss';
export const k8sDaemonSetsColumns: IEntityColumn[] = [
{
label: 'DaemonSet Group',
value: 'daemonSetGroup',
id: 'daemonSetGroup',
canBeHidden: false,
defaultVisibility: true,
behavior: 'hidden-on-collapse',
},
{
label: 'DaemonSet Name',
value: 'daemonsetName',
id: 'daemonsetName',
canBeHidden: false,
defaultVisibility: true,
behavior: 'hidden-on-expand',
},
{
label: 'Namespace Name',
value: 'namespaceName',
id: 'namespaceName',
canBeHidden: false,
defaultVisibility: true,
behavior: 'always-visible',
},
{
label: 'Available',
value: 'available_nodes',
id: 'available_nodes',
canBeHidden: false,
defaultVisibility: true,
behavior: 'always-visible',
},
{
label: 'Desired',
value: 'desired_nodes',
id: 'desired_nodes',
canBeHidden: false,
defaultVisibility: true,
behavior: 'always-visible',
},
{
label: 'CPU Req Usage (%)',
value: 'cpu_request',
id: 'cpu_request',
canBeHidden: false,
defaultVisibility: true,
behavior: 'always-visible',
},
{
label: 'CPU Limit Usage (%)',
value: 'cpu_limit',
id: 'cpu_limit',
canBeHidden: false,
defaultVisibility: true,
behavior: 'always-visible',
},
{
label: 'CPU Usage (cores)',
value: 'cpu',
id: 'cpu',
canBeHidden: false,
defaultVisibility: true,
behavior: 'always-visible',
},
{
label: 'Mem Req Usage (%)',
value: 'memory_request',
id: 'memory_request',
canBeHidden: false,
defaultVisibility: true,
behavior: 'always-visible',
},
{
label: 'Mem Limit Usage (%)',
value: 'memory_limit',
id: 'memory_limit',
canBeHidden: false,
defaultVisibility: true,
behavior: 'always-visible',
},
{
label: 'Mem Usage (WSS)',
value: 'memory',
id: 'memory',
canBeHidden: false,
defaultVisibility: true,
behavior: 'always-visible',
},
];
export const k8sDaemonSetsColumnsConfig: ColumnType<K8sRenderedRowData>[] = [
{
title: (
<div className={styles.entityGroupHeader}>
<Group size={14} /> DAEMONSET GROUP
</div>
),
dataIndex: 'daemonSetGroup',
key: 'daemonSetGroup',
ellipsis: true,
width: 150,
align: 'left',
sorter: false,
},
{
title: <div>DaemonSet Name</div>,
dataIndex: 'daemonsetName',
key: 'daemonsetName',
ellipsis: true,
width: 150,
sorter: false,
align: 'left',
},
{
title: <div>Namespace Name</div>,
dataIndex: 'namespaceName',
key: 'namespaceName',
ellipsis: true,
width: 80,
sorter: false,
align: 'left',
},
{
title: <div>Available</div>,
dataIndex: 'available_nodes',
key: 'available_nodes',
width: 50,
ellipsis: true,
sorter: true,
align: 'left',
},
{
title: <div>Desired</div>,
dataIndex: 'desired_nodes',
key: 'desired_nodes',
width: 50,
sorter: true,
align: 'left',
},
{
title: <div>CPU Req Usage (%)</div>,
dataIndex: 'cpu_request',
key: 'cpu_request',
width: 180,
ellipsis: true,
sorter: true,
align: 'left',
},
{
title: <div>CPU Limit Usage (%)</div>,
dataIndex: 'cpu_limit',
key: 'cpu_limit',
width: 120,
sorter: true,
align: 'left',
},
{
title: <div>CPU Usage (cores)</div>,
dataIndex: 'cpu',
key: 'cpu',
width: 80,
sorter: true,
align: 'left',
},
{
title: <div>Mem Req Usage (%)</div>,
dataIndex: 'memory_request',
key: 'memory_request',
width: 170,
sorter: true,
align: 'left',
},
{
title: <div>Mem Limit Usage (%)</div>,
dataIndex: 'memory_limit',
key: 'memory_limit',
width: 120,
sorter: true,
align: 'left',
},
{
title: <div>Mem Usage (WSS)</div>,
dataIndex: 'memory',
key: 'memory',
width: 120,
ellipsis: true,
sorter: true,
align: 'left',
},
];
export const k8sDaemonSetsRenderRowData = (
entity: K8sDaemonSetsData,
groupBy: BaseAutocompleteData[],
): K8sRenderedRowData => ({
key: getRowKey(
entity,
() => entity.daemonSetName || entity.meta.k8s_daemonset_name || '',
groupBy,
),
itemKey: entity.meta.k8s_daemonset_name,
daemonsetName: (
<Tooltip title={entity.meta.k8s_daemonset_name}>
{entity.meta.k8s_daemonset_name || ''}
</Tooltip>
),
namespaceName: (
<Tooltip title={entity.meta.k8s_namespace_name}>
{entity.meta.k8s_namespace_name || ''}
</Tooltip>
),
cpu_request: (
<ValidateColumnValueWrapper
value={entity.cpuRequest}
entity={K8sCategory.DAEMONSETS}
attribute="CPU Request"
>
<div className={styles.progressBar}>
<EntityProgressBar value={entity.cpuRequest} type="request" />
</div>
</ValidateColumnValueWrapper>
),
cpu_limit: (
<ValidateColumnValueWrapper
value={entity.cpuLimit}
entity={K8sCategory.DAEMONSETS}
attribute="CPU Limit"
>
<div className={styles.progressBar}>
<EntityProgressBar value={entity.cpuLimit} type="limit" />
</div>
</ValidateColumnValueWrapper>
),
cpu: (
<ValidateColumnValueWrapper value={entity.cpuUsage}>
{entity.cpuUsage}
</ValidateColumnValueWrapper>
),
memory_request: (
<ValidateColumnValueWrapper
value={entity.memoryRequest}
entity={K8sCategory.DAEMONSETS}
attribute="Memory Request"
>
<div className={styles.progressBar}>
<EntityProgressBar value={entity.memoryRequest} type="request" />
</div>
</ValidateColumnValueWrapper>
),
memory_limit: (
<ValidateColumnValueWrapper
value={entity.memoryLimit}
entity={K8sCategory.DAEMONSETS}
attribute="Memory Limit"
>
<div className={styles.progressBar}>
<EntityProgressBar value={entity.memoryLimit} type="limit" />
</div>
</ValidateColumnValueWrapper>
),
memory: (
<ValidateColumnValueWrapper value={entity.memoryUsage}>
{formatBytes(entity.memoryUsage)}
</ValidateColumnValueWrapper>
),
available_nodes: (
<ValidateColumnValueWrapper value={entity.availableNodes}>
{entity.availableNodes}
</ValidateColumnValueWrapper>
),
desired_nodes: (
<ValidateColumnValueWrapper value={entity.desiredNodes}>
{entity.desiredNodes}
</ValidateColumnValueWrapper>
),
daemonSetGroup: getGroupByEl(entity, groupBy),
...entity.meta,
groupedByMeta: getGroupedByMeta(entity, groupBy),
});

View File

@@ -0,0 +1,356 @@
import { Color } from '@signozhq/design-tokens';
import { TableColumnType as ColumnType, Tag, Tooltip } from 'antd';
import {
K8sDaemonSetsData,
K8sDaemonSetsListPayload,
} from 'api/infraMonitoring/getK8sDaemonSetsList';
import { Group } from 'lucide-react';
import { IBuilderQuery } from 'types/api/queryBuilder/queryBuilderData';
import {
EntityProgressBar,
formatBytes,
ValidateColumnValueWrapper,
} from '../commonUtils';
import { K8sCategory } from '../constants';
import { IEntityColumn } from '../utils';
export const defaultAddedColumns: IEntityColumn[] = [
{
label: 'DaemonSet Name',
value: 'daemonsetName',
id: 'daemonsetName',
canRemove: false,
},
{
label: 'Namespace Name',
value: 'namespaceName',
id: 'namespaceName',
canRemove: false,
},
{
label: 'Available',
value: 'available_nodes',
id: 'available_nodes',
canRemove: false,
},
{
label: 'Desired',
value: 'desired_nodes',
id: 'desired_nodes',
canRemove: false,
},
{
label: 'CPU Req Usage (%)',
value: 'cpu_request',
id: 'cpu_request',
canRemove: false,
},
{
label: 'CPU Limit Usage (%)',
value: 'cpu_limit',
id: 'cpu_limit',
canRemove: false,
},
{
label: 'CPU Usage (cores)',
value: 'cpu',
id: 'cpu',
canRemove: false,
},
{
label: 'Mem Req Usage (%)',
value: 'memory_request',
id: 'memory_request',
canRemove: false,
},
{
label: 'Mem Limit Usage (%)',
value: 'memory_limit',
id: 'memory_limit',
canRemove: false,
},
{
label: 'Mem Usage (WSS)',
value: 'memory',
id: 'memory',
canRemove: false,
},
];
export interface K8sDaemonSetsRowData {
key: string;
daemonsetUID: string;
daemonsetName: React.ReactNode;
cpu_request: React.ReactNode;
cpu_limit: React.ReactNode;
cpu: React.ReactNode;
memory_request: React.ReactNode;
memory_limit: React.ReactNode;
memory: React.ReactNode;
desired_nodes: React.ReactNode;
available_nodes: React.ReactNode;
namespaceName: React.ReactNode;
groupedByMeta?: any;
}
const daemonSetGroupColumnConfig = {
title: (
<div className="column-header entity-group-header">
<Group size={14} /> DAEMONSET GROUP
</div>
),
dataIndex: 'daemonSetGroup',
key: 'daemonSetGroup',
ellipsis: true,
width: 150,
align: 'left',
sorter: false,
className: 'column entity-group-header',
};
export const getK8sDaemonSetsListQuery = (): K8sDaemonSetsListPayload => ({
filters: {
items: [],
op: 'and',
},
orderBy: { columnName: 'cpu', order: 'desc' },
});
const columnProgressBarClassName = 'column-progress-bar';
const columnsConfig = [
{
title: (
<div className="column-header-left daemonset-name-header">
DaemonSet Name
</div>
),
dataIndex: 'daemonsetName',
key: 'daemonsetName',
ellipsis: true,
width: 80,
sorter: false,
align: 'left',
},
{
title: (
<div className="column-header-left namespace-name-header">
Namespace Name
</div>
),
dataIndex: 'namespaceName',
key: 'namespaceName',
ellipsis: true,
width: 80,
sorter: false,
align: 'left',
},
{
title: <div className="column-header small-col">Available</div>,
dataIndex: 'available_nodes',
key: 'available_nodes',
ellipsis: true,
sorter: true,
align: 'left',
className: `column ${columnProgressBarClassName}`,
},
{
title: <div className="column-header small-col">Desired</div>,
dataIndex: 'desired_nodes',
key: 'desired_nodes',
sorter: true,
align: 'left',
className: `column ${columnProgressBarClassName}`,
},
{
title: <div className="column-header med-col">CPU Req Usage (%)</div>,
dataIndex: 'cpu_request',
key: 'cpu_request',
width: 180,
ellipsis: true,
sorter: true,
align: 'left',
className: `column ${columnProgressBarClassName}`,
},
{
title: <div className="column-header med-col">CPU Limit Usage (%)</div>,
dataIndex: 'cpu_limit',
key: 'cpu_limit',
width: 120,
sorter: true,
align: 'left',
className: `column ${columnProgressBarClassName}`,
},
{
title: <div className="column-header small-col">CPU Usage (cores)</div>,
dataIndex: 'cpu',
key: 'cpu',
width: 80,
sorter: true,
align: 'left',
className: `column ${columnProgressBarClassName}`,
},
{
title: <div className="column-header med-col">Mem Req Usage (%)</div>,
dataIndex: 'memory_request',
key: 'memory_request',
width: 120,
sorter: true,
align: 'left',
className: `column ${columnProgressBarClassName}`,
},
{
title: <div className="column-header med-col">Mem Limit Usage (%)</div>,
dataIndex: 'memory_limit',
key: 'memory_limit',
width: 120,
sorter: true,
align: 'left',
className: `column ${columnProgressBarClassName}`,
},
{
title: <div className="column-header med-col">Mem Usage (WSS)</div>,
dataIndex: 'memory',
key: 'memory',
width: 120,
ellipsis: true,
sorter: true,
align: 'left',
className: `column ${columnProgressBarClassName}`,
},
];
export const getK8sDaemonSetsListColumns = (
groupBy: IBuilderQuery['groupBy'],
): ColumnType<K8sDaemonSetsRowData>[] => {
if (groupBy.length > 0) {
const filteredColumns = [...columnsConfig].filter(
(column) => column.key !== 'daemonsetName',
);
filteredColumns.unshift(daemonSetGroupColumnConfig);
return filteredColumns as ColumnType<K8sDaemonSetsRowData>[];
}
return columnsConfig as ColumnType<K8sDaemonSetsRowData>[];
};
const dotToUnder: Record<string, keyof K8sDaemonSetsData['meta']> = {
'k8s.daemonset.name': 'k8s_daemonset_name',
'k8s.namespace.name': 'k8s_namespace_name',
'k8s.cluster.name': 'k8s_cluster_name',
};
const getGroupByEle = (
daemonSet: K8sDaemonSetsData,
groupBy: IBuilderQuery['groupBy'],
): React.ReactNode => {
const groupByValues: string[] = [];
groupBy.forEach((group) => {
const rawKey = group.key as string;
// Choose mapped key if present, otherwise use rawKey
const metaKey = (dotToUnder[rawKey] ?? rawKey) as keyof typeof daemonSet.meta;
const value = daemonSet.meta[metaKey];
groupByValues.push(value);
});
return (
<div className="pod-group">
{groupByValues.map((value) => (
<Tag key={value} color={Color.BG_SLATE_400} className="pod-group-tag-item">
{value === '' ? '<no-value>' : value}
</Tag>
))}
</div>
);
};
export const formatDataForTable = (
data: K8sDaemonSetsData[],
groupBy: IBuilderQuery['groupBy'],
): K8sDaemonSetsRowData[] =>
data.map((daemonSet, index) => ({
key: index.toString(),
daemonsetUID: daemonSet.daemonSetName,
daemonsetName: (
<Tooltip title={daemonSet.meta.k8s_daemonset_name}>
{daemonSet.meta.k8s_daemonset_name || ''}
</Tooltip>
),
namespaceName: (
<Tooltip title={daemonSet.meta.k8s_namespace_name}>
{daemonSet.meta.k8s_namespace_name || ''}
</Tooltip>
),
cpu_request: (
<ValidateColumnValueWrapper
value={daemonSet.cpuRequest}
entity={K8sCategory.DAEMONSETS}
attribute="CPU Request"
>
<div className="progress-container">
<EntityProgressBar value={daemonSet.cpuRequest} type="request" />
</div>
</ValidateColumnValueWrapper>
),
cpu_limit: (
<ValidateColumnValueWrapper
value={daemonSet.cpuLimit}
entity={K8sCategory.DAEMONSETS}
attribute="CPU Limit"
>
<div className="progress-container">
<EntityProgressBar value={daemonSet.cpuLimit} type="limit" />
</div>
</ValidateColumnValueWrapper>
),
cpu: (
<ValidateColumnValueWrapper value={daemonSet.cpuUsage}>
{daemonSet.cpuUsage}
</ValidateColumnValueWrapper>
),
memory_request: (
<ValidateColumnValueWrapper
value={daemonSet.memoryRequest}
entity={K8sCategory.DAEMONSETS}
attribute="Memory Request"
>
<div className="progress-container">
<EntityProgressBar value={daemonSet.memoryRequest} type="request" />
</div>
</ValidateColumnValueWrapper>
),
memory_limit: (
<ValidateColumnValueWrapper
value={daemonSet.memoryLimit}
entity={K8sCategory.DAEMONSETS}
attribute="Memory Limit"
>
<div className="progress-container">
<EntityProgressBar value={daemonSet.memoryLimit} type="limit" />
</div>
</ValidateColumnValueWrapper>
),
memory: (
<ValidateColumnValueWrapper value={daemonSet.memoryUsage}>
{formatBytes(daemonSet.memoryUsage)}
</ValidateColumnValueWrapper>
),
available_nodes: (
<ValidateColumnValueWrapper value={daemonSet.availableNodes}>
{daemonSet.availableNodes}
</ValidateColumnValueWrapper>
),
desired_nodes: (
<ValidateColumnValueWrapper value={daemonSet.desiredNodes}>
{daemonSet.desiredNodes}
</ValidateColumnValueWrapper>
),
daemonSetGroup: getGroupByEle(daemonSet, groupBy),
meta: daemonSet.meta,
...daemonSet.meta,
groupedByMeta: daemonSet.meta,
}));

View File

@@ -1,7 +1,7 @@
import { useCallback, useMemo, useState } from 'react';
import { useState } from 'react';
import { VerticalAlignTopOutlined } from '@ant-design/icons';
import * as Sentry from '@sentry/react';
import { Button, CollapseProps } from 'antd';
import type { CollapseProps } from 'antd';
import { Collapse, Tooltip, Typography } from 'antd';
import logEvent from 'api/common/logEvent';
import QuickFilters from 'components/QuickFilters/QuickFilters';
@@ -16,7 +16,6 @@ import {
Computer,
Container,
FilePenLine,
Filter,
Group,
HardDrive,
Workflow,
@@ -68,9 +67,9 @@ export default function InfraMonitoringK8s(): JSX.Element {
const { currentQuery } = useQueryBuilder();
const handleFilterVisibilityChange = useCallback((): void => {
setShowFilters((show) => !show);
}, []);
const handleFilterVisibilityChange = (): void => {
setShowFilters(!showFilters);
};
const { handleChangeQueryData } = useQueryOperations({
index: 0,
@@ -322,25 +321,6 @@ export default function InfraMonitoringK8s(): JSX.Element {
}
};
const showFiltersComp = useMemo(() => {
return (
<>
{!showFilters && (
<div className="quick-filters-toggle-container">
<Button
className="periscope-btn ghost"
type="text"
size="small"
onClick={handleFilterVisibilityChange}
>
<Filter size={14} />
</Button>
</div>
)}
</>
);
}, [handleFilterVisibilityChange, showFilters]);
return (
<Sentry.ErrorBoundary fallback={<ErrorBoundaryFallback />}>
<div className="infra-monitoring-container">
@@ -375,7 +355,11 @@ export default function InfraMonitoringK8s(): JSX.Element {
}`}
>
{selectedCategory === K8sCategories.PODS && (
<K8sPodLists controlListPrefix={showFiltersComp} />
<K8sPodLists
isFiltersVisible={showFilters}
handleFilterVisibilityChange={handleFilterVisibilityChange}
quickFiltersLastUpdated={quickFiltersLastUpdated}
/>
)}
{selectedCategory === K8sCategories.NODES && (
@@ -427,7 +411,11 @@ export default function InfraMonitoringK8s(): JSX.Element {
)}
{selectedCategory === K8sCategories.DAEMONSETS && (
<K8sDaemonSetsList controlListPrefix={showFiltersComp} />
<K8sDaemonSetsList
isFiltersVisible={showFilters}
handleFilterVisibilityChange={handleFilterVisibilityChange}
quickFiltersLastUpdated={quickFiltersLastUpdated}
/>
)}
{selectedCategory === K8sCategories.VOLUMES && (

View File

@@ -1,116 +1,753 @@
import React, { useCallback } from 'react';
import React, { useCallback, useEffect, useMemo, useState } from 'react';
// eslint-disable-next-line no-restricted-imports
import { useSelector } from 'react-redux';
import { LoadingOutlined } from '@ant-design/icons';
import {
Button,
Spin,
Table,
TableColumnType as ColumnType,
TablePaginationConfig,
TableProps,
Typography,
} from 'antd';
import type { SorterResult } from 'antd/es/table/interface';
import get from 'api/browser/localstorage/get';
import set from 'api/browser/localstorage/set';
import logEvent from 'api/common/logEvent';
import { K8sPodsListPayload } from 'api/infraMonitoring/getK8sPodsList';
import classNames from 'classnames';
import { InfraMonitoringEvents } from 'constants/events';
import { FeatureKeys } from 'constants/features';
import { useGetK8sPodsList } from 'hooks/infraMonitoring/useGetK8sPodsList';
import { useGetAggregateKeys } from 'hooks/queryBuilder/useGetAggregateKeys';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { useQueryOperations } from 'hooks/queryBuilder/useQueryBuilderOperations';
import { ChevronDown, ChevronRight, CornerDownRight } from 'lucide-react';
import { useAppContext } from 'providers/App/App';
import { AppState } from 'store/reducers';
import { IBuilderQuery } from 'types/api/queryBuilder/queryBuilderData';
import { GlobalReducer } from 'types/reducer/globalTime';
import { buildAbsolutePath, isModifierKeyPressed } from 'utils/app';
import { openInNewTab } from 'utils/navigation';
import K8sBaseDetails, { K8sDetailsFilters } from '../Base/K8sBaseDetails';
import { K8sBaseFilters, K8sBaseList } from '../Base/K8sBaseList';
import { K8sCategory } from '../constants';
import { getK8sPodsList, K8sPodsData } from './api';
import {
getPodMetricsQueryPayload,
k8sPodDetailsMetadataConfig,
k8sPodGetEntityName,
k8sPodGetSelectedItemFilters,
k8sPodInitialEventsFilter,
k8sPodInitialFilters,
k8sPodInitialLogTracesFilter,
podWidgetInfo,
} from './constants';
GetK8sEntityToAggregateAttribute,
INFRA_MONITORING_K8S_PARAMS_KEYS,
K8sCategory,
} from '../constants';
import {
k8sPodColumns,
k8sPodColumnsConfig,
k8sPodRenderRowData,
} from './table';
useInfraMonitoringCurrentPage,
useInfraMonitoringEventsFilters,
useInfraMonitoringGroupBy,
useInfraMonitoringLogFilters,
useInfraMonitoringOrderBy,
useInfraMonitoringPodUID,
useInfraMonitoringTracesFilters,
useInfraMonitoringView,
} from '../hooks';
import K8sHeader from '../K8sHeader';
import LoadingContainer from '../LoadingContainer';
import {
defaultAddedColumns,
defaultAvailableColumns,
formatDataForTable,
getK8sPodsListColumns,
getK8sPodsListQuery,
IEntityColumn,
K8sPodsRowData,
usePageSize,
} from '../utils';
import PodDetails from './PodDetails/PodDetails';
import '../InfraMonitoringK8s.styles.scss';
function K8sPodsList({
controlListPrefix,
isFiltersVisible,
handleFilterVisibilityChange,
quickFiltersLastUpdated,
}: {
controlListPrefix?: React.ReactNode;
isFiltersVisible: boolean;
handleFilterVisibilityChange: () => void;
quickFiltersLastUpdated: number;
}): JSX.Element {
const { maxTime, minTime } = useSelector<AppState, GlobalReducer>(
(state) => state.globalTime,
);
const [currentPage, setCurrentPage] = useInfraMonitoringCurrentPage();
const [groupBy, setGroupBy] = useInfraMonitoringGroupBy();
const [orderBy, setOrderBy] = useInfraMonitoringOrderBy();
const [defaultOrderBy] = useState(orderBy);
const [selectedPodUID, setSelectedPodUID] = useInfraMonitoringPodUID();
const [, setView] = useInfraMonitoringView();
const [, setTracesFilters] = useInfraMonitoringTracesFilters();
const [, setEventsFilters] = useInfraMonitoringEventsFilters();
const [, setLogFilters] = useInfraMonitoringLogFilters();
const [filtersInitialised, setFiltersInitialised] = useState(false);
const [addedColumns, setAddedColumns] = useState<IEntityColumn[]>([]);
const [availableColumns, setAvailableColumns] = useState<IEntityColumn[]>(
defaultAvailableColumns,
);
const [selectedRowData, setSelectedRowData] = useState<K8sPodsRowData | null>(
null,
);
const [expandedRowKeys, setExpandedRowKeys] = useState<string[]>([]);
const [groupByOptions, setGroupByOptions] = useState<
{ value: string; label: string }[]
>([]);
const { currentQuery } = useQueryBuilder();
const queryFilters = useMemo(
() =>
currentQuery?.builder?.queryData[0]?.filters || {
items: [],
op: 'and',
},
[currentQuery?.builder?.queryData],
);
const { featureFlags } = useAppContext();
const dotMetricsEnabled =
featureFlags?.find((flag) => flag.name === FeatureKeys.DOT_METRICS_ENABLED)
?.active || false;
const fetchListData = useCallback(
async (filters: K8sBaseFilters, signal?: AbortSignal) => {
filters.orderBy ||= {
columnName: 'cpu',
order: 'desc',
};
const response = await getK8sPodsList(
filters,
signal,
undefined,
const {
data: groupByFiltersData,
isLoading: isLoadingGroupByFilters,
} = useGetAggregateKeys(
{
dataSource: currentQuery.builder.queryData[0].dataSource,
aggregateAttribute: GetK8sEntityToAggregateAttribute(
K8sCategory.PODS,
dotMetricsEnabled,
);
return {
data: response.payload?.data.records || [],
total: response.payload?.data.total || 0,
error: response.error,
};
),
aggregateOperator: 'noop',
searchText: '',
tagType: '',
},
[dotMetricsEnabled],
{
queryKey: [currentQuery.builder.queryData[0].dataSource, 'noop'],
},
true, // isInfraMonitoring
K8sCategory.PODS, // infraMonitoringEntity
);
const fetchEntityData = useCallback(
async (
filters: K8sDetailsFilters,
signal?: AbortSignal,
): Promise<{ data: K8sPodsData | null; error?: string | null }> => {
const response = await getK8sPodsList(
{
filters: filters.filters,
start: filters.start,
end: filters.end,
limit: 1,
offset: 0,
// Reset pagination every time quick filters are changed
useEffect(() => {
if (quickFiltersLastUpdated !== -1) {
setCurrentPage(1);
}
}, [quickFiltersLastUpdated, setCurrentPage]);
useEffect(() => {
const addedColumns = JSON.parse(get('k8sPodsAddedColumns') ?? '[]');
if (addedColumns && addedColumns.length > 0) {
const availableColumns = defaultAvailableColumns.filter(
(column) => !addedColumns.includes(column.id),
);
const newAddedColumns = defaultAvailableColumns.filter((column) =>
addedColumns.includes(column.id),
);
setAvailableColumns(availableColumns);
setAddedColumns(newAddedColumns);
}
}, []);
const { pageSize, setPageSize } = usePageSize(K8sCategory.PODS);
const query = useMemo(() => {
const baseQuery = getK8sPodsListQuery();
const queryPayload = {
...baseQuery,
limit: pageSize,
offset: (currentPage - 1) * pageSize,
filters: queryFilters,
start: Math.floor(minTime / 1000000),
end: Math.floor(maxTime / 1000000),
orderBy: orderBy || baseQuery.orderBy,
};
if (groupBy.length > 0) {
queryPayload.groupBy = groupBy;
}
return queryPayload;
}, [pageSize, currentPage, queryFilters, minTime, maxTime, orderBy, groupBy]);
const queryKey = useMemo(() => {
if (selectedPodUID) {
return [
'podList',
String(pageSize),
String(currentPage),
JSON.stringify(queryFilters),
JSON.stringify(orderBy),
JSON.stringify(groupBy),
];
}
return [
'podList',
String(pageSize),
String(currentPage),
JSON.stringify(queryFilters),
JSON.stringify(orderBy),
JSON.stringify(groupBy),
String(minTime),
String(maxTime),
];
}, [
selectedPodUID,
pageSize,
currentPage,
queryFilters,
orderBy,
groupBy,
minTime,
maxTime,
]);
const { data, isFetching, isLoading, isError } = useGetK8sPodsList(
query as K8sPodsListPayload,
{
queryKey,
enabled: !!query,
keepPreviousData: true,
},
undefined,
dotMetricsEnabled,
);
const createFiltersForSelectedRowData = (
selectedRowData: K8sPodsRowData,
): IBuilderQuery['filters'] => {
const baseFilters: IBuilderQuery['filters'] = {
items: [...queryFilters.items],
op: 'and',
};
if (!selectedRowData) {
return baseFilters;
}
const { groupedByMeta } = selectedRowData;
for (const key of Object.keys(groupedByMeta)) {
baseFilters.items.push({
key: {
key,
type: null,
},
signal,
undefined,
dotMetricsEnabled,
);
op: '=',
value: groupedByMeta[key],
id: key,
});
}
const records = response.payload?.data.records || [];
return baseFilters;
};
return {
data: records.length > 0 ? records[0] : null,
error: response.error,
};
const fetchGroupedByRowDataQuery = useMemo(() => {
if (!selectedRowData) {
return null;
}
const baseQuery = getK8sPodsListQuery();
const filters = createFiltersForSelectedRowData(selectedRowData);
return {
...baseQuery,
limit: 10,
offset: 0,
filters,
start: Math.floor(minTime / 1000000),
end: Math.floor(maxTime / 1000000),
orderBy: orderBy || baseQuery.orderBy,
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [minTime, maxTime, orderBy, selectedRowData]);
const groupedByRowDataQueryKey = useMemo(() => {
// be careful with what you serialize from selectedRowData
// since it's react node, it could contain circular references
const selectedRowDataKey = JSON.stringify(selectedRowData?.groupedByMeta);
if (selectedPodUID) {
return [
'podList',
JSON.stringify(queryFilters),
JSON.stringify(orderBy),
selectedRowDataKey,
];
}
return [
'podList',
JSON.stringify(queryFilters),
JSON.stringify(orderBy),
selectedRowDataKey,
String(minTime),
String(maxTime),
];
}, [queryFilters, orderBy, selectedPodUID, minTime, maxTime, selectedRowData]);
const {
data: groupedByRowData,
isFetching: isFetchingGroupedByRowData,
isLoading: isLoadingGroupedByRowData,
isError: isErrorGroupedByRowData,
refetch: fetchGroupedByRowData,
} = useGetK8sPodsList(
fetchGroupedByRowDataQuery as K8sPodsListPayload,
{
queryKey: groupedByRowDataQueryKey,
enabled: !!fetchGroupedByRowDataQuery && !!selectedRowData,
},
[dotMetricsEnabled],
undefined,
dotMetricsEnabled,
);
const podsData = useMemo(() => data?.payload?.data?.records || [], [data]);
const totalCount = data?.payload?.data?.total || 0;
const nestedPodsData = useMemo(() => {
if (!selectedRowData || !groupedByRowData?.payload?.data.records) {
return [];
}
return groupedByRowData?.payload?.data?.records || [];
}, [groupedByRowData, selectedRowData]);
const formattedPodsData = useMemo(
() => formatDataForTable(podsData, groupBy),
[podsData, groupBy],
);
const formattedGroupedByPodsData = useMemo(
() =>
formatDataForTable(groupedByRowData?.payload?.data?.records || [], groupBy),
[groupedByRowData, groupBy],
);
const columns = useMemo(
() => getK8sPodsListColumns(addedColumns, groupBy, defaultOrderBy),
[addedColumns, groupBy, defaultOrderBy],
);
const handleTableChange: TableProps<K8sPodsRowData>['onChange'] = useCallback(
(
pagination: TablePaginationConfig,
_filters: Record<string, (string | number | boolean)[] | null>,
sorter: SorterResult<K8sPodsRowData> | SorterResult<K8sPodsRowData>[],
): void => {
if (pagination.current) {
setCurrentPage(pagination.current);
logEvent(InfraMonitoringEvents.PageNumberChanged, {
entity: InfraMonitoringEvents.K8sEntity,
page: InfraMonitoringEvents.ListPage,
category: InfraMonitoringEvents.Pod,
});
}
if ('field' in sorter && sorter.order) {
setOrderBy({
columnName: sorter.field as string,
order: (sorter.order === 'ascend' ? 'asc' : 'desc') as 'asc' | 'desc',
});
} else {
setOrderBy(null);
}
},
[setCurrentPage, setOrderBy],
);
const { handleChangeQueryData } = useQueryOperations({
index: 0,
query: currentQuery.builder.queryData[0],
entityVersion: '',
});
const handleFiltersChange = useCallback(
(value: IBuilderQuery['filters']): void => {
handleChangeQueryData('filters', value);
if (filtersInitialised) {
setCurrentPage(1);
} else {
setFiltersInitialised(true);
}
if (value?.items && value?.items?.length > 0) {
logEvent(InfraMonitoringEvents.FilterApplied, {
entity: InfraMonitoringEvents.K8sEntity,
page: InfraMonitoringEvents.ListPage,
category: InfraMonitoringEvents.Pod,
});
}
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[],
);
const handleGroupByChange = useCallback(
(value: IBuilderQuery['groupBy']) => {
const newGroupBy = [];
for (let index = 0; index < value.length; index++) {
const element = (value[index] as unknown) as string;
const key = groupByFiltersData?.payload?.attributeKeys?.find(
(k) => k.key === element,
);
if (key) {
newGroupBy.push(key);
}
}
// Reset pagination on switching to groupBy
setCurrentPage(1);
setGroupBy(newGroupBy);
setExpandedRowKeys([]);
logEvent(InfraMonitoringEvents.GroupByChanged, {
entity: InfraMonitoringEvents.K8sEntity,
page: InfraMonitoringEvents.ListPage,
category: InfraMonitoringEvents.Pod,
});
},
[groupByFiltersData, setCurrentPage, setGroupBy],
);
useEffect(() => {
logEvent(InfraMonitoringEvents.PageVisited, {
entity: InfraMonitoringEvents.K8sEntity,
page: InfraMonitoringEvents.ListPage,
category: InfraMonitoringEvents.Pod,
total: data?.payload?.data?.total,
});
}, [data?.payload?.data?.total]);
const selectedPodData = useMemo(() => {
if (!selectedPodUID) {
return null;
}
if (groupBy.length > 0) {
// If grouped by, return the pod from the formatted grouped by pods data
return nestedPodsData.find((pod) => pod.podUID === selectedPodUID) || null;
}
// If not grouped by, return the node from the nodes data
return podsData.find((pod) => pod.podUID === selectedPodUID) || null;
}, [selectedPodUID, groupBy.length, podsData, nestedPodsData]);
const handleGroupByRowClick = (record: K8sPodsRowData): void => {
setSelectedRowData(record);
if (expandedRowKeys.includes(record.key)) {
setExpandedRowKeys(expandedRowKeys.filter((key) => key !== record.key));
} else {
setExpandedRowKeys([record.key]);
}
};
useEffect(() => {
if (selectedRowData) {
fetchGroupedByRowData();
}
}, [selectedRowData, fetchGroupedByRowData]);
const openPodInNewTab = (record: K8sPodsRowData): void => {
const newParams = new URLSearchParams(document.location.search);
newParams.set(INFRA_MONITORING_K8S_PARAMS_KEYS.POD_UID, record.podUID);
openInNewTab(
buildAbsolutePath({
relativePath: '',
urlQueryString: newParams.toString(),
}),
);
};
const handleRowClick = (
record: K8sPodsRowData,
event: React.MouseEvent,
): void => {
if (event && isModifierKeyPressed(event)) {
openPodInNewTab(record);
return;
}
if (groupBy.length === 0) {
setSelectedPodUID(record.podUID);
setSelectedRowData(null);
} else {
handleGroupByRowClick(record);
}
logEvent(InfraMonitoringEvents.ItemClicked, {
entity: InfraMonitoringEvents.K8sEntity,
page: InfraMonitoringEvents.ListPage,
category: InfraMonitoringEvents.Pod,
});
};
const handleClosePodDetail = (): void => {
setSelectedPodUID(null);
setView(null);
setTracesFilters(null);
setEventsFilters(null);
setLogFilters(null);
};
const handleAddColumn = useCallback(
(column: IEntityColumn): void => {
setAddedColumns((prev) => [...prev, column]);
setAvailableColumns((prev) => prev.filter((c) => c.value !== column.value));
},
[setAddedColumns, setAvailableColumns],
);
// Update local storage when added columns updated
useEffect(() => {
const addedColumnIDs = addedColumns.map((column) => column.id);
set('k8sPodsAddedColumns', JSON.stringify(addedColumnIDs));
}, [addedColumns]);
useEffect(() => {
if (groupByFiltersData?.payload) {
setGroupByOptions(
groupByFiltersData?.payload?.attributeKeys?.map((filter) => ({
value: filter.key,
label: filter.key,
})) || [],
);
}
}, [groupByFiltersData]);
const handleRemoveColumn = useCallback(
(column: IEntityColumn): void => {
setAddedColumns((prev) => prev.filter((c) => c.value !== column.value));
setAvailableColumns((prev) => [...prev, column]);
},
[setAddedColumns, setAvailableColumns],
);
const nestedColumns = useMemo(
() => getK8sPodsListColumns(addedColumns, [], defaultOrderBy),
[addedColumns, defaultOrderBy],
);
const isGroupedByAttribute = groupBy.length > 0;
const handleExpandedRowViewAllClick = (): void => {
if (!selectedRowData) {
return;
}
const filters = createFiltersForSelectedRowData(selectedRowData);
handleFiltersChange(filters);
setCurrentPage(1);
setSelectedRowData(null);
setGroupBy([]);
setOrderBy(null);
};
const expandedRowRender = (): JSX.Element => (
<div className="expanded-table-container">
{isErrorGroupedByRowData && (
<Typography>{groupedByRowData?.error || 'Something went wrong'}</Typography>
)}
{isFetchingGroupedByRowData || isLoadingGroupedByRowData ? (
<LoadingContainer />
) : (
<div className="expanded-table">
<Table
columns={nestedColumns as ColumnType<K8sPodsRowData>[]}
dataSource={formattedGroupedByPodsData}
pagination={false}
scroll={{ x: true }}
tableLayout="fixed"
showHeader={false}
loading={{
spinning: isFetchingGroupedByRowData || isLoadingGroupedByRowData,
indicator: <Spin indicator={<LoadingOutlined size={14} spin />} />,
}}
onRow={(
record: K8sPodsRowData,
): { onClick: (event: React.MouseEvent) => void; className: string } => ({
onClick: (event: React.MouseEvent): void => {
if (isModifierKeyPressed(event)) {
openPodInNewTab(record);
return;
}
setSelectedPodUID(record.podUID);
},
className: 'expanded-clickable-row',
})}
/>
{groupedByRowData?.payload?.data?.total &&
groupedByRowData?.payload?.data?.total > 10 && (
<div className="expanded-table-footer">
<Button
type="default"
size="small"
className="periscope-btn secondary"
onClick={handleExpandedRowViewAllClick}
>
<CornerDownRight size={14} />
View All
</Button>
</div>
)}
</div>
)}
</div>
);
const expandRowIconRenderer = ({
expanded,
onExpand,
record,
}: {
expanded: boolean;
onExpand: (
record: K8sPodsRowData,
e: React.MouseEvent<HTMLButtonElement>,
) => void;
record: K8sPodsRowData;
}): JSX.Element | null => {
if (!isGroupedByAttribute) {
return null;
}
return expanded ? (
<Button
className="periscope-btn ghost"
onClick={(e: React.MouseEvent<HTMLButtonElement>): void =>
onExpand(record, e)
}
role="button"
>
<ChevronDown size={14} />
</Button>
) : (
<Button
className="periscope-btn ghost"
onClick={(e: React.MouseEvent<HTMLButtonElement>): void =>
onExpand(record, e)
}
role="button"
>
<ChevronRight size={14} />
</Button>
);
};
const onPaginationChange = (page: number, pageSize: number): void => {
setCurrentPage(page);
setPageSize(pageSize);
logEvent(InfraMonitoringEvents.PageNumberChanged, {
entity: InfraMonitoringEvents.K8sEntity,
page: InfraMonitoringEvents.ListPage,
category: InfraMonitoringEvents.Pod,
});
};
const showTableLoadingState =
(isFetching || isLoading) && formattedPodsData.length === 0;
return (
<>
<K8sBaseList<K8sPodsData>
controlListPrefix={controlListPrefix}
<div className="k8s-list">
<K8sHeader
selectedGroupBy={groupBy}
groupByOptions={groupByOptions}
isLoadingGroupByFilters={isLoadingGroupByFilters}
isFiltersVisible={isFiltersVisible}
handleFilterVisibilityChange={handleFilterVisibilityChange}
defaultAddedColumns={defaultAddedColumns}
addedColumns={addedColumns}
availableColumns={availableColumns}
handleFiltersChange={handleFiltersChange}
handleGroupByChange={handleGroupByChange}
onAddColumn={handleAddColumn}
onRemoveColumn={handleRemoveColumn}
entity={K8sCategory.PODS}
tableColumnsDefinitions={k8sPodColumns}
tableColumns={k8sPodColumnsConfig}
fetchListData={fetchListData}
renderRowData={k8sPodRenderRowData}
eventCategory={InfraMonitoringEvents.Pod}
showAutoRefresh={!selectedPodData}
/>
{isError && <Typography>{data?.error || 'Something went wrong'}</Typography>}
<Table
className={classNames('k8s-list-table', {
'expanded-k8s-list-table': isGroupedByAttribute,
})}
dataSource={showTableLoadingState ? [] : formattedPodsData}
columns={columns}
pagination={{
current: currentPage,
pageSize,
total: totalCount,
showSizeChanger: true,
hideOnSinglePage: false,
onChange: onPaginationChange,
}}
loading={{
spinning: showTableLoadingState,
indicator: <Spin indicator={<LoadingOutlined size={14} spin />} />,
}}
locale={{
emptyText: showTableLoadingState ? null : (
<div className="no-filtered-hosts-message-container">
<div className="no-filtered-hosts-message-content">
<img
src="/Icons/emptyState.svg"
alt="thinking-emoji"
className="empty-state-svg"
/>
<Typography.Text className="no-filtered-hosts-message">
This query had no results. Edit your query and try again!
</Typography.Text>
</div>
</div>
),
}}
scroll={{ x: true }}
tableLayout="fixed"
onChange={handleTableChange}
onRow={(
record: K8sPodsRowData,
): { onClick: (event: React.MouseEvent) => void; className: string } => ({
onClick: (event: React.MouseEvent): void => handleRowClick(record, event),
className: 'clickable-row',
})}
expandable={{
expandedRowRender: isGroupedByAttribute ? expandedRowRender : undefined,
expandIcon: expandRowIconRenderer,
expandedRowKeys,
}}
/>
<K8sBaseDetails<K8sPodsData>
category={K8sCategory.PODS}
eventCategory={InfraMonitoringEvents.Pod}
getSelectedItemFilters={k8sPodGetSelectedItemFilters}
fetchEntityData={fetchEntityData}
getEntityName={k8sPodGetEntityName}
getInitialLogTracesFilters={k8sPodInitialLogTracesFilter}
getInitialEventsFilters={k8sPodInitialEventsFilter}
primaryFilterKeys={k8sPodInitialFilters}
metadataConfig={k8sPodDetailsMetadataConfig}
entityWidgetInfo={podWidgetInfo}
getEntityQueryPayload={getPodMetricsQueryPayload}
queryKeyPrefix="pod"
/>
</>
{selectedPodData && (
<PodDetails
pod={selectedPodData}
isModalTimeSelection
onClose={handleClosePodDetail}
/>
)}
</div>
);
}

View File

@@ -0,0 +1,7 @@
import { K8sPodsData } from 'api/infraMonitoring/getK8sPodsList';
export type PodDetailProps = {
pod: K8sPodsData | null;
isModalTimeSelection: boolean;
onClose: () => void;
};

View File

@@ -1,10 +1,12 @@
/* eslint-disable sonarjs/no-identical-functions */
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useQuery } from 'react-query';
// eslint-disable-next-line no-restricted-imports
import { useSelector } from 'react-redux';
import { Color, Spacing } from '@signozhq/design-tokens';
import { Button, Divider, Drawer, Radio, Tooltip, Typography } from 'antd';
import type { RadioChangeEvent } from 'antd/lib';
import logEvent from 'api/common/logEvent';
import { K8sPodsData } from 'api/infraMonitoring/getK8sPodsList';
import { VIEW_TYPES, VIEWS } from 'components/HostMetricsDetail/constants';
import { InfraMonitoringEvents } from 'constants/events';
import { QueryParams } from 'constants/query';
@@ -13,13 +15,21 @@ import {
initialQueryState,
} from 'constants/queryBuilder';
import ROUTES from 'constants/routes';
import { filterDuplicateFilters } from 'container/InfraMonitoringK8s/commonUtils';
import { K8sCategory } from 'container/InfraMonitoringK8s/constants';
import { QUERY_KEYS } from 'container/InfraMonitoringK8s/EntityDetailsUtils/utils';
import {
useInfraMonitoringEventsFilters,
useInfraMonitoringLogFilters,
useInfraMonitoringTracesFilters,
useInfraMonitoringView,
} from 'container/InfraMonitoringK8s/hooks';
import {
CustomTimeType,
Time,
} from 'container/TopNav/DateTimeSelectionV2/types';
import { useIsDarkMode } from 'hooks/useDarkMode';
import useUrlQuery from 'hooks/useUrlQuery';
import { GetQueryResultsProps } from 'lib/dashboard/getQueryResults';
import GetMinMax from 'lib/getMinMax';
import {
BarChart2,
@@ -29,130 +39,48 @@ import {
ScrollText,
X,
} from 'lucide-react';
import { AppState } from 'store/reducers';
import { DataTypes } from 'types/api/queryBuilder/queryAutocompleteResponse';
import {
IBuilderQuery,
TagFilter,
TagFilterItem,
} from 'types/api/queryBuilder/queryBuilderData';
import {
LogsAggregatorOperator,
TracesAggregatorOperator,
} from 'types/common/queryBuilder';
import { GlobalReducer } from 'types/reducer/globalTime';
import { v4 as uuidv4 } from 'uuid';
import {
isCustomTimeRange,
useGlobalTimeStore,
} from '../../../store/globalTime';
import {
getAutoRefreshQueryKey,
NANO_SECOND_MULTIPLIER,
} from '../../../store/globalTime/utils';
import { DEFAULT_TIME_RANGE } from '../../TopNav/DateTimeSelectionV2/constants';
import { filterDuplicateFilters } from '../commonUtils';
import { K8sCategory } from '../constants';
import EntityEvents from '../EntityDetailsUtils/EntityEvents';
import EntityLogs from '../EntityDetailsUtils/EntityLogs';
import EntityMetrics from '../EntityDetailsUtils/EntityMetrics';
import EntityTraces from '../EntityDetailsUtils/EntityTraces';
import { QUERY_KEYS } from '../EntityDetailsUtils/utils';
import {
useInfraMonitoringEventsFilters,
useInfraMonitoringLogFilters,
useInfraMonitoringSelectedItem,
useInfraMonitoringTracesFilters,
useInfraMonitoringView,
} from '../hooks';
import LoadingContainer from '../LoadingContainer';
import PodEvents from '../../EntityDetailsUtils/EntityEvents';
import PodLogs from '../../EntityDetailsUtils/EntityLogs';
import PodMetrics from '../../EntityDetailsUtils/EntityMetrics';
import PodTraces from '../../EntityDetailsUtils/EntityTraces';
import { getPodMetricsQueryPayload, podWidgetInfo } from './constants';
import { PodDetailProps } from './PodDetail.interfaces';
import '../EntityDetailsUtils/entityDetails.styles.scss';
import '../../EntityDetailsUtils/entityDetails.styles.scss';
const TimeRangeOffset = 1000000000;
export interface K8sDetailsMetadataConfig<T> {
label: string;
getValue: (entity: T) => string;
}
function PodDetails({
pod,
onClose,
isModalTimeSelection,
}: PodDetailProps): JSX.Element {
const { maxTime, minTime, selectedTime } = useSelector<
AppState,
GlobalReducer
>((state) => state.globalTime);
export interface K8sDetailsFilters {
filters: TagFilter;
start: number;
end: number;
}
const startMs = useMemo(() => Math.floor(Number(minTime) / TimeRangeOffset), [
minTime,
]);
const endMs = useMemo(() => Math.floor(Number(maxTime) / TimeRangeOffset), [
maxTime,
]);
export interface K8sBaseDetailsProps<T> {
category: K8sCategory;
eventCategory: string;
// Data fetching configuration
getSelectedItemFilters: (selectedItem: string) => TagFilter;
fetchEntityData: (
filters: K8sDetailsFilters,
signal?: AbortSignal,
) => Promise<{ data: T | null; error?: string | null }>;
// Entity configuration
getEntityName: (entity: T) => string;
getInitialLogTracesFilters: (entity: T) => TagFilterItem[];
getInitialEventsFilters: (entity: T) => TagFilterItem[];
primaryFilterKeys: string[];
metadataConfig: K8sDetailsMetadataConfig<T>[];
entityWidgetInfo: {
title: string;
yAxisUnit: string;
}[];
getEntityQueryPayload: (
entity: T,
start: number,
end: number,
dotMetricsEnabled: boolean,
) => GetQueryResultsProps[];
queryKeyPrefix: string;
}
export function createFilterItem(
key: string,
value: string,
dataType: DataTypes = DataTypes.String,
): TagFilterItem {
return {
id: uuidv4(),
key: {
key,
dataType,
type: 'resource',
id: `${key}--string--resource--false`,
},
op: '=',
value,
};
}
// eslint-disable-next-line sonarjs/cognitive-complexity
function K8sBaseDetails<T>({
category,
eventCategory,
getSelectedItemFilters,
fetchEntityData,
getEntityName,
getInitialLogTracesFilters,
getInitialEventsFilters,
primaryFilterKeys,
metadataConfig,
entityWidgetInfo,
getEntityQueryPayload,
queryKeyPrefix,
}: K8sBaseDetailsProps<T>): JSX.Element {
const selectedTime = useGlobalTimeStore((s) => s.selectedTime);
const getMinMaxTime = useGlobalTimeStore((s) => s.getMinMaxTime);
const { startMs, endMs } = useMemo(() => {
const { minTime: startNs, maxTime: endNs } = getMinMaxTime(selectedTime);
return {
startMs: Math.floor(startNs / NANO_SECOND_MULTIPLIER),
endMs: Math.floor(endNs / NANO_SECOND_MULTIPLIER),
};
}, [getMinMaxTime, selectedTime]);
const urlQuery = useUrlQuery();
const [modalTimeRange, setModalTimeRange] = useState(() => ({
startTime: startMs,
@@ -160,12 +88,11 @@ function K8sBaseDetails<T>({
}));
const lastSelectedInterval = useRef<Time | null>(null);
const [selectedInterval, setSelectedInterval] = useState<Time>(
lastSelectedInterval.current
? lastSelectedInterval.current
: isCustomTimeRange(selectedTime)
? DEFAULT_TIME_RANGE
: selectedTime,
: (selectedTime as Time),
);
const [selectedView, setSelectedView] = useInfraMonitoringView();
@@ -180,79 +107,79 @@ function K8sBaseDetails<T>({
] = useInfraMonitoringEventsFilters();
const isDarkMode = useIsDarkMode();
const [selectedItem, setSelectedItem] = useInfraMonitoringSelectedItem();
const urlQuery = useUrlQuery();
const entityQueryKey = useMemo(
() =>
getAutoRefreshQueryKey(
selectedTime,
`${queryKeyPrefix}EntityDetails`,
selectedItem,
),
[queryKeyPrefix, selectedItem, selectedTime],
);
const {
data: entityResponse,
isLoading: isEntityLoading,
isError: isEntityError,
} = useQuery({
queryKey: entityQueryKey,
queryFn: ({ signal }) => {
if (!selectedItem) {
return { data: null };
}
const filters = getSelectedItemFilters(selectedItem);
const { minTime, maxTime } = getMinMaxTime();
return fetchEntityData(
{
filters,
start: Math.floor(minTime / NANO_SECOND_MULTIPLIER),
end: Math.floor(maxTime / NANO_SECOND_MULTIPLIER),
},
signal,
);
},
enabled: !!selectedItem,
});
const entity = entityResponse?.data ?? null;
const initialFilters = useMemo(() => {
const filters =
selectedView === VIEW_TYPES.LOGS ? logFiltersParam : tracesFiltersParam;
if (filters) {
return filters;
}
if (!entity) {
return { op: 'AND', items: [] };
}
return {
op: 'AND',
items: getInitialLogTracesFilters(entity),
items: [
{
id: uuidv4(),
key: {
key: QUERY_KEYS.K8S_POD_NAME,
dataType: DataTypes.String,
type: 'resource',
id: 'k8s_pod_name--string--resource--false',
},
op: '=',
value: pod?.meta.k8s_pod_name || '',
},
{
id: uuidv4(),
key: {
key: QUERY_KEYS.K8S_NAMESPACE_NAME,
dataType: DataTypes.String,
type: 'resource',
id: 'k8s_pod_name--string--resource--false',
},
op: '=',
value: pod?.meta.k8s_namespace_name || '',
},
],
};
}, [
entity,
pod?.meta.k8s_namespace_name,
pod?.meta.k8s_pod_name,
selectedView,
logFiltersParam,
tracesFiltersParam,
getInitialLogTracesFilters,
]);
const initialEventsFilters = useMemo(() => {
if (eventsFiltersParam) {
return eventsFiltersParam;
}
if (!entity) {
return { op: 'AND', items: [] };
}
return {
op: 'AND',
items: getInitialEventsFilters(entity),
items: [
{
id: uuidv4(),
key: {
key: QUERY_KEYS.K8S_OBJECT_KIND,
dataType: DataTypes.String,
type: 'resource',
id: 'k8s.object.kind--string--resource--false',
},
op: '=',
value: 'Pod',
},
{
id: uuidv4(),
key: {
key: QUERY_KEYS.K8S_OBJECT_NAME,
dataType: DataTypes.String,
type: 'resource',
id: 'k8s.object.name--string--resource--false',
},
op: '=',
value: pod?.meta.k8s_pod_name || '',
},
],
};
}, [entity, eventsFiltersParam, getInitialEventsFilters]);
}, [pod?.meta.k8s_pod_name, eventsFiltersParam]);
const [logsAndTracesFilters, setLogsAndTracesFilters] = useState<
IBuilderQuery['filters']
@@ -263,14 +190,14 @@ function K8sBaseDetails<T>({
);
useEffect(() => {
if (entity) {
if (pod) {
logEvent(InfraMonitoringEvents.PageVisited, {
entity: InfraMonitoringEvents.K8sEntity,
page: InfraMonitoringEvents.DetailedPage,
category: eventCategory,
category: InfraMonitoringEvents.Pod,
});
}
}, [entity, eventCategory]);
}, [pod]);
useEffect(() => {
setLogsAndTracesFilters(initialFilters);
@@ -279,16 +206,17 @@ function K8sBaseDetails<T>({
useEffect(() => {
const currentSelectedInterval = lastSelectedInterval.current || selectedTime;
if (!isCustomTimeRange(currentSelectedInterval)) {
setSelectedInterval(currentSelectedInterval);
const { minTime, maxTime } = getMinMaxTime();
setSelectedInterval(currentSelectedInterval as Time);
if (currentSelectedInterval !== 'custom') {
const { maxTime, minTime } = GetMinMax(currentSelectedInterval);
setModalTimeRange({
startTime: Math.floor(minTime / TimeRangeOffset),
endTime: Math.floor(maxTime / TimeRangeOffset),
});
}
}, [getMinMaxTime, selectedTime]);
}, [selectedTime, minTime, maxTime]);
const handleTabChange = (e: RadioChangeEvent): void => {
setSelectedView(e.target.value);
@@ -298,7 +226,7 @@ function K8sBaseDetails<T>({
logEvent(InfraMonitoringEvents.TabChanged, {
entity: InfraMonitoringEvents.K8sEntity,
page: InfraMonitoringEvents.DetailedPage,
category: eventCategory,
category: InfraMonitoringEvents.Pod,
view: e.target.value,
});
};
@@ -325,34 +253,38 @@ function K8sBaseDetails<T>({
logEvent(InfraMonitoringEvents.TimeUpdated, {
entity: InfraMonitoringEvents.K8sEntity,
page: InfraMonitoringEvents.DetailedPage,
category: eventCategory,
category: InfraMonitoringEvents.Pod,
interval,
view: selectedView,
});
},
[eventCategory, selectedView],
// eslint-disable-next-line react-hooks/exhaustive-deps
[],
);
const handleChangeLogFilters = useCallback(
(value: IBuilderQuery['filters'], view: VIEWS) => {
setLogsAndTracesFilters((prevFilters) => {
const primaryFilters = prevFilters?.items?.filter((item) =>
primaryFilterKeys.includes(item.key?.key ?? ''),
[
QUERY_KEYS.K8S_POD_NAME,
QUERY_KEYS.K8S_CLUSTER_NAME,
QUERY_KEYS.K8S_NAMESPACE_NAME,
].includes(item.key?.key ?? ''),
);
const paginationFilter = value?.items?.find(
(item) => item.key?.key === 'id',
);
const newFilters = value?.items?.filter(
(item) =>
item.key?.key !== 'id' &&
!primaryFilterKeys.includes(item.key?.key ?? ''),
item.key?.key !== 'id' && item.key?.key !== QUERY_KEYS.K8S_CLUSTER_NAME,
);
if (newFilters && newFilters?.length > 0) {
logEvent(InfraMonitoringEvents.FilterApplied, {
entity: InfraMonitoringEvents.K8sEntity,
page: InfraMonitoringEvents.DetailedPage,
category: eventCategory,
category: InfraMonitoringEvents.Pod,
view: selectedView,
});
}
@@ -374,27 +306,26 @@ function K8sBaseDetails<T>({
return updatedFilters;
});
},
[
setLogFiltersParam,
setSelectedView,
primaryFilterKeys,
eventCategory,
selectedView,
],
// eslint-disable-next-line react-hooks/exhaustive-deps
[],
);
const handleChangeTracesFilters = useCallback(
(value: IBuilderQuery['filters'], view: VIEWS) => {
setLogsAndTracesFilters((prevFilters) => {
const primaryFilters = prevFilters?.items?.filter((item) =>
primaryFilterKeys.includes(item.key?.key ?? ''),
[
QUERY_KEYS.K8S_POD_NAME,
QUERY_KEYS.K8S_CLUSTER_NAME,
QUERY_KEYS.K8S_NAMESPACE_NAME,
].includes(item.key?.key ?? ''),
);
if (value?.items && value?.items?.length > 0) {
logEvent(InfraMonitoringEvents.FilterApplied, {
entity: InfraMonitoringEvents.K8sEntity,
page: InfraMonitoringEvents.DetailedPage,
category: eventCategory,
category: InfraMonitoringEvents.Pod,
view: selectedView,
});
}
@@ -405,7 +336,7 @@ function K8sBaseDetails<T>({
[
...(primaryFilters || []),
...(value?.items?.filter(
(item) => !primaryFilterKeys.includes(item.key?.key ?? ''),
(item) => item.key?.key !== QUERY_KEYS.K8S_POD_NAME,
) || []),
].filter((item): item is TagFilterItem => item !== undefined),
),
@@ -417,22 +348,17 @@ function K8sBaseDetails<T>({
return updatedFilters;
});
},
[
setTracesFiltersParam,
setSelectedView,
primaryFilterKeys,
eventCategory,
selectedView,
],
// eslint-disable-next-line react-hooks/exhaustive-deps
[],
);
const handleChangeEventsFilters = useCallback(
(value: IBuilderQuery['filters'], view: VIEWS) => {
setEventsFilters((prevFilters) => {
const kindFilter = prevFilters?.items?.find(
const podKindFilter = prevFilters?.items?.find(
(item) => item.key?.key === QUERY_KEYS.K8S_OBJECT_KIND,
);
const nameFilter = prevFilters?.items?.find(
const podNameFilter = prevFilters?.items?.find(
(item) => item.key?.key === QUERY_KEYS.K8S_OBJECT_NAME,
);
@@ -440,24 +366,22 @@ function K8sBaseDetails<T>({
logEvent(InfraMonitoringEvents.FilterApplied, {
entity: InfraMonitoringEvents.K8sEntity,
page: InfraMonitoringEvents.DetailedPage,
category: eventCategory,
category: InfraMonitoringEvents.Pod,
view: selectedView,
});
}
const updatedFilters = {
op: 'AND',
items: filterDuplicateFilters(
[
kindFilter,
nameFilter,
...(value?.items?.filter(
(item) =>
item.key?.key !== QUERY_KEYS.K8S_OBJECT_KIND &&
item.key?.key !== QUERY_KEYS.K8S_OBJECT_NAME,
) || []),
].filter((item): item is TagFilterItem => item !== undefined),
),
items: [
podKindFilter,
podNameFilter,
...(value?.items?.filter(
(item) =>
item.key?.key !== QUERY_KEYS.K8S_OBJECT_KIND &&
item.key?.key !== QUERY_KEYS.K8S_OBJECT_NAME,
) || []),
].filter((item): item is TagFilterItem => item !== undefined),
};
setEventsFiltersParam(updatedFilters);
@@ -466,7 +390,8 @@ function K8sBaseDetails<T>({
return updatedFilters;
});
},
[eventCategory, selectedView, setEventsFiltersParam, setSelectedView],
// eslint-disable-next-line react-hooks/exhaustive-deps
[],
);
const handleExplorePagesRedirect = (): void => {
@@ -481,7 +406,7 @@ function K8sBaseDetails<T>({
logEvent(InfraMonitoringEvents.ExploreClicked, {
entity: InfraMonitoringEvents.K8sEntity,
page: InfraMonitoringEvents.DetailedPage,
category: eventCategory,
category: InfraMonitoringEvents.Pod,
view: selectedView,
});
@@ -551,28 +476,24 @@ function K8sBaseDetails<T>({
endTime: Math.floor(maxTime / TimeRangeOffset),
});
}
setSelectedItem(null);
setSelectedView(null);
setTracesFiltersParam(null);
setEventsFiltersParam(null);
setLogFiltersParam(null);
setSelectedView(VIEW_TYPES.METRICS);
onClose();
};
const entityName = entity ? getEntityName(entity) : '';
return (
<Drawer
width="70%"
title={
<>
<Divider type="vertical" />
<Typography.Text className="title">{entityName}</Typography.Text>
<Typography.Text className="title">
{pod?.meta.k8s_pod_name}
</Typography.Text>
</>
}
placement="right"
onClose={handleClose}
open={!!selectedItem}
open={!!pod}
style={{
overscrollBehavior: 'contain',
background: isDarkMode ? Color.BG_INK_400 : Color.BG_VANILLA_100,
@@ -581,40 +502,49 @@ function K8sBaseDetails<T>({
destroyOnClose
closeIcon={<X size={16} style={{ marginTop: Spacing.MARGIN_1 }} />}
>
{isEntityLoading && <LoadingContainer />}
{isEntityError && (
<Typography.Text type="danger">
{entityResponse?.error || 'Failed to load entity details'}
</Typography.Text>
)}
{entity && !isEntityLoading && (
{pod && (
<>
<div className="entity-detail-drawer__entity">
<div className="entity-details-grid">
<div className="labels-row">
{metadataConfig.map((config) => (
<Typography.Text
key={config.label}
type="secondary"
className="entity-details-metadata-label"
>
{config.label}
</Typography.Text>
))}
<Typography.Text
type="secondary"
className="entity-details-metadata-label"
>
NAMESPACE
</Typography.Text>
<Typography.Text
type="secondary"
className="entity-details-metadata-label"
>
Cluster Name
</Typography.Text>
<Typography.Text
type="secondary"
className="entity-details-metadata-label"
>
Node
</Typography.Text>
</div>
<div className="values-row">
{metadataConfig.map((config) => {
const value = config.getValue(entity);
return (
<Typography.Text
key={config.label}
className="entity-details-metadata-value"
>
<Tooltip title={value}>{value}</Tooltip>
</Typography.Text>
);
})}
<Typography.Text className="entity-details-metadata-value">
<Tooltip title={pod.meta.k8s_namespace_name}>
{pod.meta.k8s_namespace_name}
</Tooltip>
</Typography.Text>
<Typography.Text className="entity-details-metadata-value">
<Tooltip title={pod.meta.k8s_cluster_name}>
{pod.meta.k8s_cluster_name}
</Tooltip>
</Typography.Text>
<Typography.Text className="entity-details-metadata-value">
<Tooltip title={pod.meta.k8s_node_name}>
{pod.meta.k8s_node_name}
</Tooltip>
</Typography.Text>
</div>
</div>
</div>
@@ -682,54 +612,63 @@ function K8sBaseDetails<T>({
</div>
{selectedView === VIEW_TYPES.METRICS && (
<EntityMetrics<T>
entity={entity}
<PodMetrics<K8sPodsData>
entity={pod}
selectedInterval={selectedInterval}
timeRange={modalTimeRange}
handleTimeChange={handleTimeChange}
isModalTimeSelection
entityWidgetInfo={entityWidgetInfo}
getEntityQueryPayload={getEntityQueryPayload}
category={category}
queryKey={`${queryKeyPrefix}Metrics`}
isModalTimeSelection={isModalTimeSelection}
entityWidgetInfo={podWidgetInfo}
getEntityQueryPayload={getPodMetricsQueryPayload}
category={K8sCategory.PODS}
queryKey="podMetrics"
/>
)}
{selectedView === VIEW_TYPES.LOGS && (
<EntityLogs
<PodLogs
timeRange={modalTimeRange}
isModalTimeSelection
isModalTimeSelection={isModalTimeSelection}
handleTimeChange={handleTimeChange}
handleChangeLogFilters={handleChangeLogFilters}
logFilters={logsAndTracesFilters}
selectedInterval={selectedInterval}
queryKeyFilters={primaryFilterKeys}
queryKey={`${queryKeyPrefix}Logs`}
category={category}
queryKeyFilters={[
QUERY_KEYS.K8S_POD_NAME,
QUERY_KEYS.K8S_CLUSTER_NAME,
QUERY_KEYS.K8S_NAMESPACE_NAME,
]}
queryKey="podLogs"
category={K8sCategory.PODS}
/>
)}
{selectedView === VIEW_TYPES.TRACES && (
<EntityTraces
<PodTraces
timeRange={modalTimeRange}
isModalTimeSelection
isModalTimeSelection={isModalTimeSelection}
handleTimeChange={handleTimeChange}
handleChangeTracesFilters={handleChangeTracesFilters}
tracesFilters={logsAndTracesFilters}
selectedInterval={selectedInterval}
queryKey={`${queryKeyPrefix}Traces`}
category={eventCategory}
queryKeyFilters={primaryFilterKeys}
queryKey="podTraces"
category={InfraMonitoringEvents.Pod}
queryKeyFilters={[
QUERY_KEYS.K8S_POD_NAME,
QUERY_KEYS.K8S_CLUSTER_NAME,
QUERY_KEYS.K8S_NAMESPACE_NAME,
]}
/>
)}
{selectedView === VIEW_TYPES.EVENTS && (
<EntityEvents
<PodEvents
timeRange={modalTimeRange}
isModalTimeSelection
isModalTimeSelection={isModalTimeSelection}
handleTimeChange={handleTimeChange}
handleChangeEventFilters={handleChangeEventsFilters}
filters={eventsFilters}
selectedInterval={selectedInterval}
category={category}
queryKey={`${queryKeyPrefix}Events`}
category={K8sCategory.PODS}
queryKey="podEvents"
/>
)}
</>
@@ -738,4 +677,4 @@ function K8sBaseDetails<T>({
);
}
export default K8sBaseDetails;
export default PodDetails;

View File

@@ -1,69 +1,11 @@
import { K8sPodsData } from 'api/infraMonitoring/getK8sPodsList';
import { PANEL_TYPES } from 'constants/queryBuilder';
import { GetQueryResultsProps } from 'lib/dashboard/getQueryResults';
import { DataTypes } from 'types/api/queryBuilder/queryAutocompleteResponse';
import { TagFilter } from 'types/api/queryBuilder/queryBuilderData';
import { EQueryType } from 'types/common/dashboard';
import { DataSource, ReduceOperators } from 'types/common/queryBuilder';
import { v4 } from 'uuid';
import {
createFilterItem,
K8sDetailsMetadataConfig,
} from '../Base/K8sBaseDetails';
import { QUERY_KEYS } from '../EntityDetailsUtils/utils';
import { K8sPodsData } from './api';
export const k8sPodGetSelectedItemFilters = (
selectedItemId: string,
): TagFilter => {
return {
op: 'AND',
items: [
{
id: 'k8s_pod_uid',
key: {
key: 'k8s_pod_uid',
type: null,
},
op: '=',
value: selectedItemId,
},
],
};
};
export const k8sPodDetailsMetadataConfig: K8sDetailsMetadataConfig<K8sPodsData>[] = [
{ label: 'NAMESPACE', getValue: (p): string => p.meta.k8s_namespace_name },
{
label: 'Cluster Name',
getValue: (p): string => p.meta.k8s_cluster_name,
},
{ label: 'Node', getValue: (p): string => p.meta.k8s_node_name },
];
export const k8sPodInitialFilters = [
QUERY_KEYS.K8S_POD_NAME,
QUERY_KEYS.K8S_CLUSTER_NAME,
QUERY_KEYS.K8S_NAMESPACE_NAME,
];
export const k8sPodInitialEventsFilter = (
pod: K8sPodsData,
): ReturnType<typeof createFilterItem>[] => [
createFilterItem(QUERY_KEYS.K8S_OBJECT_KIND, 'Pod'),
createFilterItem(QUERY_KEYS.K8S_OBJECT_NAME, pod.meta.k8s_pod_name),
];
export const k8sPodInitialLogTracesFilter = (
pod: K8sPodsData,
): ReturnType<typeof createFilterItem>[] => [
createFilterItem(QUERY_KEYS.K8S_POD_NAME, pod.meta.k8s_pod_name),
createFilterItem(QUERY_KEYS.K8S_NAMESPACE_NAME, pod.meta.k8s_namespace_name),
];
export const k8sPodGetEntityName = (pod: K8sPodsData): string =>
pod.meta.k8s_pod_name;
export const podWidgetInfo = [
{
title: 'CPU Usage (cores)',

View File

@@ -0,0 +1,3 @@
import PodDetails from './PodDetails';
export default PodDetails;

View File

@@ -1,11 +0,0 @@
.entityGroupHeader {
padding-left: var(--spacing-5);
gap: var(--spacing-5);
display: flex;
align-items: center;
}
.progressBar {
display: flex;
justify-content: start;
}

View File

@@ -1,328 +0,0 @@
import React from 'react';
import { TableColumnType as ColumnType, Tooltip } from 'antd';
import { Group } from 'lucide-react';
import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
import { K8sRenderedRowData } from '../Base/K8sBaseList';
import { IEntityColumn } from '../Base/useInfraMonitoringTableColumnsStore';
import { getGroupByEl, getGroupedByMeta, getRowKey } from '../Base/utils';
import {
EntityProgressBar,
formatBytes,
ValidateColumnValueWrapper,
} from '../commonUtils';
import { K8sCategory } from '../constants';
import { K8sPodsData } from './api';
import styles from './table.module.scss';
export interface K8sPodsRowData {
key: string;
podName: React.ReactNode;
podUID: string;
cpu_request: React.ReactNode;
cpu_limit: React.ReactNode;
cpu: React.ReactNode;
memory_request: React.ReactNode;
memory_limit: React.ReactNode;
memory: React.ReactNode;
restarts: React.ReactNode;
groupedByMeta?: any;
}
export const k8sPodColumns: IEntityColumn[] = [
{
label: 'Pod Group',
value: 'podGroup',
id: 'podGroup',
canBeHidden: false,
defaultVisibility: true,
behavior: 'hidden-on-collapse',
},
{
label: 'Pod name',
value: 'podName',
id: 'podName',
canBeHidden: false,
defaultVisibility: true,
behavior: 'hidden-on-expand',
},
{
label: 'CPU Req Usage (%)',
value: 'cpu_request',
id: 'cpu_request',
canBeHidden: false,
defaultVisibility: true,
behavior: 'always-visible',
},
{
label: 'CPU Limit Usage (%)',
value: 'cpu_limit',
id: 'cpu_limit',
canBeHidden: false,
defaultVisibility: true,
behavior: 'always-visible',
},
{
label: 'CPU Usage (cores)',
value: 'cpu',
id: 'cpu',
canBeHidden: false,
defaultVisibility: true,
behavior: 'always-visible',
},
{
label: 'Mem Req Usage (%)',
value: 'memory_request',
id: 'memory_request',
canBeHidden: false,
defaultVisibility: true,
behavior: 'always-visible',
},
{
label: 'Mem Limit Usage (%)',
value: 'memory_limit',
id: 'memory_limit',
canBeHidden: false,
defaultVisibility: true,
behavior: 'always-visible',
},
{
label: 'Mem Usage (WSS)',
value: 'memory',
id: 'memory',
canBeHidden: false,
defaultVisibility: true,
behavior: 'always-visible',
},
{
label: 'Namespace name',
value: 'namespace',
id: 'namespace',
canBeHidden: true,
defaultVisibility: false,
behavior: 'always-visible',
},
{
label: 'Node name',
value: 'node',
id: 'node',
canBeHidden: true,
defaultVisibility: false,
behavior: 'always-visible',
},
{
label: 'Cluster name',
value: 'cluster',
id: 'cluster',
canBeHidden: true,
defaultVisibility: false,
behavior: 'always-visible',
},
// TODO - Re-enable the column once backend issue is fixed
// {
// label: 'Restarts',
// value: 'restarts',
// id: 'restarts',
// canRemove: false,
// },
];
export const k8sPodColumnsConfig: ColumnType<K8sRenderedRowData>[] = [
{
title: (
<div className={styles.entityGroupHeader}>
<Group size={14} /> POD GROUP
</div>
),
dataIndex: 'podGroup',
key: 'podGroup',
ellipsis: true,
width: 180,
sorter: false,
},
{
title: <div>Pod Name</div>,
dataIndex: 'podName',
key: 'podName',
width: 180,
ellipsis: true,
sorter: false,
},
{
title: <div>CPU Req Usage (%)</div>,
dataIndex: 'cpu_request',
key: 'cpu_request',
width: 180,
ellipsis: true,
sorter: true,
align: 'left',
},
{
title: <div>CPU Limit Usage (%)</div>,
dataIndex: 'cpu_limit',
key: 'cpu_limit',
width: 120,
sorter: true,
align: 'left',
},
{
title: <div>CPU Usage (cores)</div>,
dataIndex: 'cpu',
key: 'cpu',
width: 80,
sorter: true,
align: 'left',
},
{
title: <div>Mem Req Usage (%)</div>,
dataIndex: 'memory_request',
key: 'memory_request',
width: 120,
sorter: true,
align: 'left',
},
{
title: <div>Mem Limit Usage (%)</div>,
dataIndex: 'memory_limit',
key: 'memory_limit',
width: 120,
sorter: true,
align: 'left',
},
{
title: <div>Mem Usage (WSS)</div>,
dataIndex: 'memory',
key: 'memory',
width: 120,
ellipsis: true,
sorter: true,
align: 'left',
},
{
title: <div>Namespace</div>,
dataIndex: 'namespace',
key: 'namespace',
width: 100,
sorter: false,
ellipsis: true,
align: 'left',
},
{
title: <div>Node</div>,
dataIndex: 'node',
key: 'node',
width: 100,
sorter: false,
ellipsis: true,
align: 'left',
},
{
title: <div>Cluster</div>,
dataIndex: 'cluster',
key: 'cluster',
width: 100,
sorter: false,
ellipsis: true,
align: 'left',
},
// TODO - Re-enable the column once backend issue is fixed
// {
// title: (
// <div className="column-header">
// <Tooltip title="Container Restarts">Restarts</Tooltip>
// </div>
// ),
// dataIndex: 'restarts',
// key: 'restarts',
// width: 40,
// ellipsis: true,
// sorter: true,
// align: 'left',
// className: `column ${columnProgressBarClassName}`,
// },
];
export const k8sPodRenderRowData = (
pod: K8sPodsData,
groupBy: BaseAutocompleteData[],
): K8sRenderedRowData => ({
key: getRowKey(
pod,
() => pod.podUID || pod.meta.k8s_pod_uid || pod.meta.k8s_pod_name,
groupBy,
),
itemKey: pod.podUID,
podName: (
<Tooltip title={pod.meta.k8s_pod_name || ''}>
{pod.meta.k8s_pod_name || ''}
</Tooltip>
),
podUID: pod.podUID || '',
cpu_request: (
<ValidateColumnValueWrapper
value={pod.podCPURequest}
entity={K8sCategory.PODS}
attribute="CPU Request"
>
<div className={styles.progressBar}>
<EntityProgressBar value={pod.podCPURequest} type="request" />
</div>
</ValidateColumnValueWrapper>
),
cpu_limit: (
<ValidateColumnValueWrapper
value={pod.podCPULimit}
entity={K8sCategory.PODS}
attribute="CPU Limit"
>
<div className={styles.progressBar}>
<EntityProgressBar value={pod.podCPULimit} type="limit" />
</div>
</ValidateColumnValueWrapper>
),
cpu: (
<ValidateColumnValueWrapper value={pod.podCPU}>
{pod.podCPU}
</ValidateColumnValueWrapper>
),
memory_request: (
<ValidateColumnValueWrapper
value={pod.podMemoryRequest}
entity={K8sCategory.PODS}
attribute="Memory Request"
>
<div className={styles.progressBar}>
<EntityProgressBar value={pod.podMemoryRequest} type="request" />
</div>
</ValidateColumnValueWrapper>
),
memory_limit: (
<ValidateColumnValueWrapper
value={pod.podMemoryLimit}
entity={K8sCategory.PODS}
attribute="Memory Limit"
>
<div className={styles.progressBar}>
<EntityProgressBar value={pod.podMemoryLimit} type="limit" />
</div>
</ValidateColumnValueWrapper>
),
memory: (
<ValidateColumnValueWrapper value={pod.podMemory}>
{formatBytes(pod.podMemory)}
</ValidateColumnValueWrapper>
),
restarts: (
<ValidateColumnValueWrapper value={pod.restartCount}>
{pod.restartCount}
</ValidateColumnValueWrapper>
),
namespace: pod.meta.k8s_namespace_name,
node: pod.meta.k8s_node_name,
cluster: pod.meta.k8s_cluster_name,
meta: pod.meta,
podGroup: getGroupByEl(pod, groupBy),
...pod.meta,
groupedByMeta: getGroupedByMeta(pod, groupBy),
});

View File

@@ -0,0 +1,153 @@
import setupCommonMocks from '../../commonMocks';
setupCommonMocks();
import { QueryClient, QueryClientProvider } from 'react-query';
// eslint-disable-next-line no-restricted-imports
import { Provider } from 'react-redux';
import { MemoryRouter } from 'react-router-dom';
import { fireEvent, render, screen } from '@testing-library/react';
import DaemonSetDetails from 'container/InfraMonitoringK8s/DaemonSets/DaemonSetDetails/DaemonSetDetails';
import { withNuqsTestingAdapter } from 'nuqs/adapters/testing';
import store from 'store';
const queryClient = new QueryClient();
const Wrapper = withNuqsTestingAdapter({ searchParams: {} });
describe('DaemonSetDetails', () => {
const mockDaemonSet = {
meta: {
k8s_daemonset_name: 'test-daemon-set',
k8s_cluster_name: 'test-cluster',
k8s_namespace_name: 'test-namespace',
},
} as any;
const mockOnClose = jest.fn();
it('should render modal with relevant metadata', () => {
render(
<Wrapper>
<QueryClientProvider client={queryClient}>
<Provider store={store}>
<MemoryRouter>
<DaemonSetDetails
daemonSet={mockDaemonSet}
isModalTimeSelection
onClose={mockOnClose}
/>
</MemoryRouter>
</Provider>
</QueryClientProvider>
</Wrapper>,
);
const daemonSetNameElements = screen.getAllByText('test-daemon-set');
expect(daemonSetNameElements.length).toBeGreaterThan(0);
expect(daemonSetNameElements[0]).toBeInTheDocument();
const clusterNameElements = screen.getAllByText('test-cluster');
expect(clusterNameElements.length).toBeGreaterThan(0);
expect(clusterNameElements[0]).toBeInTheDocument();
const namespaceNameElements = screen.getAllByText('test-namespace');
expect(namespaceNameElements.length).toBeGreaterThan(0);
expect(namespaceNameElements[0]).toBeInTheDocument();
});
it('should render modal with 4 tabs', () => {
render(
<Wrapper>
<QueryClientProvider client={queryClient}>
<Provider store={store}>
<MemoryRouter>
<DaemonSetDetails
daemonSet={mockDaemonSet}
isModalTimeSelection
onClose={mockOnClose}
/>
</MemoryRouter>
</Provider>
</QueryClientProvider>
</Wrapper>,
);
const metricsTab = screen.getByText('Metrics');
expect(metricsTab).toBeInTheDocument();
const eventsTab = screen.getByText('Events');
expect(eventsTab).toBeInTheDocument();
const logsTab = screen.getByText('Logs');
expect(logsTab).toBeInTheDocument();
const tracesTab = screen.getByText('Traces');
expect(tracesTab).toBeInTheDocument();
});
it('default tab should be metrics', () => {
render(
<Wrapper>
<QueryClientProvider client={queryClient}>
<Provider store={store}>
<MemoryRouter>
<DaemonSetDetails
daemonSet={mockDaemonSet}
isModalTimeSelection
onClose={mockOnClose}
/>
</MemoryRouter>
</Provider>
</QueryClientProvider>
</Wrapper>,
);
const metricsTab = screen.getByRole('radio', { name: 'Metrics' });
expect(metricsTab).toBeChecked();
});
it('should switch to events tab when events tab is clicked', () => {
render(
<Wrapper>
<QueryClientProvider client={queryClient}>
<Provider store={store}>
<MemoryRouter>
<DaemonSetDetails
daemonSet={mockDaemonSet}
isModalTimeSelection
onClose={mockOnClose}
/>
</MemoryRouter>
</Provider>
</QueryClientProvider>
</Wrapper>,
);
const eventsTab = screen.getByRole('radio', { name: 'Events' });
expect(eventsTab).not.toBeChecked();
fireEvent.click(eventsTab);
expect(eventsTab).toBeChecked();
});
it('should close modal when close button is clicked', () => {
render(
<Wrapper>
<QueryClientProvider client={queryClient}>
<Provider store={store}>
<MemoryRouter>
<DaemonSetDetails
daemonSet={mockDaemonSet}
isModalTimeSelection
onClose={mockOnClose}
/>
</MemoryRouter>
</Provider>
</QueryClientProvider>
</Wrapper>,
);
const closeButton = screen.getByRole('button', { name: 'Close' });
fireEvent.click(closeButton);
expect(mockOnClose).toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,155 @@
/**
* Tests for URL parameter parsing in K8s Infra Monitoring components.
*
* These tests verify the fix for the double URL decoding bug where
* components were calling decodeURIComponent() on values already
* decoded by URLSearchParams.get(), causing crashes on K8s parameters
* with special characters.
*/
import { getFiltersFromParams } from '../../commonUtils';
describe('K8sPodsList URL Parameter Parsing', () => {
describe('getFiltersFromParams', () => {
it('should return null when no filters in params', () => {
const searchParams = new URLSearchParams();
const result = getFiltersFromParams(searchParams, 'filters');
expect(result).toBeNull();
});
it('should parse filters from URL params', () => {
const filters = {
items: [
{
id: '1',
key: { key: 'k8s_namespace_name' },
op: '=',
value: 'default',
},
],
op: 'AND',
};
const searchParams = new URLSearchParams();
searchParams.set('filters', JSON.stringify(filters));
const result = getFiltersFromParams(searchParams, 'filters');
expect(result).toEqual(filters);
});
it('should handle URL-encoded filters (auto-decoded by URLSearchParams)', () => {
const filters = {
items: [
{
id: '1',
key: { key: 'k8s_pod_name' },
op: 'contains',
value: 'api-server',
},
],
op: 'AND',
};
const encodedValue = encodeURIComponent(JSON.stringify(filters));
const searchParams = new URLSearchParams(`filters=${encodedValue}`);
const result = getFiltersFromParams(searchParams, 'filters');
expect(result).toEqual(filters);
});
it('should return null on malformed JSON instead of crashing', () => {
const consoleSpy = jest.spyOn(console, 'error').mockImplementation(() => {});
const searchParams = new URLSearchParams();
searchParams.set('filters', '{invalid-json}');
const result = getFiltersFromParams(searchParams, 'filters');
expect(result).toBeNull();
consoleSpy.mockRestore();
});
it('should handle filters with K8s container image names', () => {
const filters = {
items: [
{
id: '1',
key: { key: 'k8s_container_name' },
op: '=',
value: 'registry.k8s.io/coredns/coredns:v1.10.1',
},
],
op: 'AND',
};
const encodedValue = encodeURIComponent(JSON.stringify(filters));
const searchParams = new URLSearchParams(`filters=${encodedValue}`);
const result = getFiltersFromParams(searchParams, 'filters');
expect(result).toEqual(filters);
});
});
describe('regression: double decoding issue', () => {
it('should not crash when URL params are already decoded by URLSearchParams', () => {
// The key bug: URLSearchParams.get() auto-decodes, so encoding once in URL
// means .get() returns decoded value. Old code called decodeURIComponent()
// again which could crash on certain characters.
const filters = {
items: [
{
id: '1',
key: { key: 'k8s_namespace_name' },
op: '=',
value: 'kube-system',
},
],
op: 'AND',
};
const encodedValue = encodeURIComponent(JSON.stringify(filters));
const searchParams = new URLSearchParams(`filters=${encodedValue}`);
// This should work without crashing
const result = getFiltersFromParams(searchParams, 'filters');
expect(result).toEqual(filters);
});
it('should handle values with percent signs in labels', () => {
// K8s labels might contain literal "%" characters like "cpu-usage-50%"
const filters = {
items: [
{
id: '1',
key: { key: 'k8s_label' },
op: '=',
value: 'cpu-50%',
},
],
op: 'AND',
};
const encodedValue = encodeURIComponent(JSON.stringify(filters));
const searchParams = new URLSearchParams(`filters=${encodedValue}`);
const result = getFiltersFromParams(searchParams, 'filters');
expect(result).toEqual(filters);
});
it('should handle complex K8s deployment names with special chars', () => {
const filters = {
items: [
{
id: '1',
key: { key: 'k8s_deployment_name' },
op: '=',
value: 'nginx-ingress-controller',
},
],
op: 'AND',
};
const encodedValue = encodeURIComponent(JSON.stringify(filters));
const searchParams = new URLSearchParams(`filters=${encodedValue}`);
const result = getFiltersFromParams(searchParams, 'filters');
expect(result).toEqual(filters);
});
});
});

View File

@@ -0,0 +1,134 @@
import setupCommonMocks from '../../commonMocks';
setupCommonMocks();
import { QueryClient, QueryClientProvider } from 'react-query';
// eslint-disable-next-line no-restricted-imports
import { Provider } from 'react-redux';
import { MemoryRouter } from 'react-router-dom';
import { fireEvent, render, screen } from '@testing-library/react';
import PodDetails from 'container/InfraMonitoringK8s/Pods/PodDetails/PodDetails';
import { withNuqsTestingAdapter } from 'nuqs/adapters/testing';
import store from 'store';
const queryClient = new QueryClient();
const Wrapper = withNuqsTestingAdapter({ searchParams: {} });
describe('PodDetails', () => {
const mockPod = {
podName: 'test-pod',
meta: {
k8s_cluster_name: 'test-cluster',
k8s_namespace_name: 'test-namespace',
k8s_node_name: 'test-node',
},
} as any;
const mockOnClose = jest.fn();
it('should render modal with relevant metadata', () => {
render(
<Wrapper>
<QueryClientProvider client={queryClient}>
<Provider store={store}>
<MemoryRouter>
<PodDetails pod={mockPod} isModalTimeSelection onClose={mockOnClose} />
</MemoryRouter>
</Provider>
</QueryClientProvider>
</Wrapper>,
);
const clusterNameElements = screen.getAllByText('test-cluster');
expect(clusterNameElements.length).toBeGreaterThan(0);
expect(clusterNameElements[0]).toBeInTheDocument();
const namespaceNameElements = screen.getAllByText('test-namespace');
expect(namespaceNameElements.length).toBeGreaterThan(0);
expect(namespaceNameElements[0]).toBeInTheDocument();
const nodeNameElements = screen.getAllByText('test-node');
expect(nodeNameElements.length).toBeGreaterThan(0);
expect(nodeNameElements[0]).toBeInTheDocument();
});
it('should render modal with 4 tabs', () => {
render(
<Wrapper>
<QueryClientProvider client={queryClient}>
<Provider store={store}>
<MemoryRouter>
<PodDetails pod={mockPod} isModalTimeSelection onClose={mockOnClose} />
</MemoryRouter>
</Provider>
</QueryClientProvider>
</Wrapper>,
);
const metricsTab = screen.getByText('Metrics');
expect(metricsTab).toBeInTheDocument();
const eventsTab = screen.getByText('Events');
expect(eventsTab).toBeInTheDocument();
const logsTab = screen.getByText('Logs');
expect(logsTab).toBeInTheDocument();
const tracesTab = screen.getByText('Traces');
expect(tracesTab).toBeInTheDocument();
});
it('default tab should be metrics', () => {
render(
<Wrapper>
<QueryClientProvider client={queryClient}>
<Provider store={store}>
<MemoryRouter>
<PodDetails pod={mockPod} isModalTimeSelection onClose={mockOnClose} />
</MemoryRouter>
</Provider>
</QueryClientProvider>
</Wrapper>,
);
const metricsTab = screen.getByRole('radio', { name: 'Metrics' });
expect(metricsTab).toBeChecked();
});
it('should switch to events tab when events tab is clicked', () => {
render(
<Wrapper>
<QueryClientProvider client={queryClient}>
<Provider store={store}>
<MemoryRouter>
<PodDetails pod={mockPod} isModalTimeSelection onClose={mockOnClose} />
</MemoryRouter>
</Provider>
</QueryClientProvider>
</Wrapper>,
);
const eventsTab = screen.getByRole('radio', { name: 'Events' });
expect(eventsTab).not.toBeChecked();
fireEvent.click(eventsTab);
expect(eventsTab).toBeChecked();
});
it('should close modal when close button is clicked', () => {
render(
<Wrapper>
<QueryClientProvider client={queryClient}>
<Provider store={store}>
<MemoryRouter>
<PodDetails pod={mockPod} isModalTimeSelection onClose={mockOnClose} />
</MemoryRouter>
</Provider>
</QueryClientProvider>
</Wrapper>,
);
const closeButton = screen.getByRole('button', { name: 'Close' });
fireEvent.click(closeButton);
expect(mockOnClose).toHaveBeenCalled();
});
});

View File

@@ -791,5 +791,4 @@ export const INFRA_MONITORING_K8S_PARAMS_KEYS = {
EVENTS_FILTERS: 'eventsFilters',
HOSTS_FILTERS: 'hostsFilters',
CURRENT_PAGE: 'currentPage',
SELECTED_ITEM: 'selectedItem',
};

View File

@@ -1,6 +1,4 @@
import { useMemo } from 'react';
import { VIEWS } from 'components/HostMetricsDetail/constants';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import {
Options,
parseAsInteger,
@@ -224,26 +222,3 @@ export const useInfraMonitoringVolumeUID = (): UseQueryStateReturn<
INFRA_MONITORING_K8S_PARAMS_KEYS.VOLUME_UID,
parseAsString.withOptions(defaultNuqsOptions),
);
export const useInfraMonitoringQueryFilters = (): TagFilter => {
const { currentQuery } = useQueryBuilder();
return useMemo(
() =>
currentQuery?.builder?.queryData[0]?.filters || {
items: [],
op: 'and',
},
[currentQuery?.builder?.queryData],
);
};
export const useInfraMonitoringSelectedItem = (): UseQueryStateReturn<
string,
string | undefined
> => {
return useQueryState(
INFRA_MONITORING_K8S_PARAMS_KEYS.SELECTED_ITEM,
parseAsString,
);
};

View File

@@ -1,8 +1,22 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { Color } from '@signozhq/design-tokens';
import { TableColumnType as ColumnType, Tag, Tooltip } from 'antd';
import get from 'api/browser/localstorage/get';
import set from 'api/browser/localstorage/set';
import {
K8sPodsData,
K8sPodsListPayload,
} from 'api/infraMonitoring/getK8sPodsList';
import { Group } from 'lucide-react';
import { IBuilderQuery } from 'types/api/queryBuilder/queryBuilderData';
import { DEFAULT_PAGE_SIZE } from './constants';
import {
EntityProgressBar,
formatBytes,
ValidateColumnValueWrapper,
} from './commonUtils';
import { DEFAULT_PAGE_SIZE, K8sCategory } from './constants';
import { OrderBySchemaType } from './schemas';
import './InfraMonitoringK8s.styles.scss';
@@ -12,6 +26,404 @@ export interface IEntityColumn {
id: string;
canRemove: boolean;
}
const columnProgressBarClassName = 'column-progress-bar';
export const defaultAddedColumns: IEntityColumn[] = [
{
label: 'Pod name',
value: 'podName',
id: 'podName',
canRemove: false,
},
{
label: 'CPU Req Usage (%)',
value: 'cpu_request',
id: 'cpu_request',
canRemove: false,
},
{
label: 'CPU Limit Usage (%)',
value: 'cpu_limit',
id: 'cpu_limit',
canRemove: false,
},
{
label: 'CPU Usage (cores)',
value: 'cpu',
id: 'cpu',
canRemove: false,
},
{
label: 'Mem Req Usage (%)',
value: 'memory_request',
id: 'memory_request',
canRemove: false,
},
{
label: 'Mem Limit Usage (%)',
value: 'memory_limit',
id: 'memory_limit',
canRemove: false,
},
{
label: 'Mem Usage (WSS)',
value: 'memory',
id: 'memory',
canRemove: false,
},
// TODO - Re-enable the column once backend issue is fixed
// {
// label: 'Restarts',
// value: 'restarts',
// id: 'restarts',
// canRemove: false,
// },
];
export const defaultAvailableColumns = [
{
label: 'Namespace name',
value: 'namespace',
id: 'namespace',
canRemove: true,
},
{
label: 'Node name',
value: 'node',
id: 'node',
canRemove: true,
},
{
label: 'Cluster name',
value: 'cluster',
id: 'cluster',
canRemove: true,
},
];
export interface K8sPodsRowData {
key: string;
podName: React.ReactNode;
podUID: string;
cpu_request: React.ReactNode;
cpu_limit: React.ReactNode;
cpu: React.ReactNode;
memory_request: React.ReactNode;
memory_limit: React.ReactNode;
memory: React.ReactNode;
restarts: React.ReactNode;
groupedByMeta?: any;
}
export const getK8sPodsListQuery = (): K8sPodsListPayload => ({
filters: {
items: [],
op: 'and',
},
orderBy: { columnName: 'cpu', order: 'desc' },
});
const podGroupColumnConfig = {
title: (
<div className="column-header entity-group-header">
<Group size={14} /> POD GROUP
</div>
),
dataIndex: 'podGroup',
key: 'podGroup',
ellipsis: true,
width: 180,
sorter: false,
className: 'column entity-group-header',
};
export const dummyColumnConfig = {
title: <div className="column-header dummy-column">&nbsp;</div>,
dataIndex: 'dummy',
key: 'dummy',
width: 40,
sorter: false,
align: 'left',
className: 'column column-dummy',
};
const columnsConfig: ColumnType<K8sPodsRowData>[] = [
{
title: <div className="column-header pod-name-header">Pod Name</div>,
dataIndex: 'podName',
key: 'podName',
width: 180,
ellipsis: true,
sorter: false,
className: 'column column-pod-name',
},
{
title: <div className="column-header med-col">CPU Req Usage (%)</div>,
dataIndex: 'cpu_request',
key: 'cpu_request',
width: 180,
ellipsis: true,
sorter: true,
align: 'left',
className: `column ${columnProgressBarClassName}`,
},
{
title: <div className="column-header med-col">CPU Limit Usage (%)</div>,
dataIndex: 'cpu_limit',
key: 'cpu_limit',
width: 120,
sorter: true,
align: 'left',
className: `column ${columnProgressBarClassName}`,
},
{
title: <div className="column-header">CPU Usage (cores)</div>,
dataIndex: 'cpu',
key: 'cpu',
width: 80,
sorter: true,
align: 'left',
className: `column ${columnProgressBarClassName}`,
},
{
title: <div className="column-heade med-col">Mem Req Usage (%)</div>,
dataIndex: 'memory_request',
key: 'memory_request',
width: 120,
sorter: true,
align: 'left',
className: `column ${columnProgressBarClassName}`,
},
{
title: <div className="column-header med-col">Mem Limit Usage (%)</div>,
dataIndex: 'memory_limit',
key: 'memory_limit',
width: 120,
sorter: true,
align: 'left',
className: `column ${columnProgressBarClassName}`,
},
{
title: <div className="column-header med-col">Mem Usage (WSS)</div>,
dataIndex: 'memory',
key: 'memory',
width: 120,
ellipsis: true,
sorter: true,
align: 'left',
className: `column ${columnProgressBarClassName}`,
},
// TODO - Re-enable the column once backend issue is fixed
// {
// title: (
// <div className="column-header">
// <Tooltip title="Container Restarts">Restarts</Tooltip>
// </div>
// ),
// dataIndex: 'restarts',
// key: 'restarts',
// width: 40,
// ellipsis: true,
// sorter: true,
// align: 'left',
// className: `column ${columnProgressBarClassName}`,
// },
];
export const namespaceColumnConfig: ColumnType<K8sPodsRowData> = {
title: <div className="column-header">Namespace</div>,
dataIndex: 'namespace',
key: 'namespace',
width: 100,
sorter: false,
ellipsis: true,
align: 'left',
className: 'column column-namespace',
};
export const nodeColumnConfig: ColumnType<K8sPodsRowData> = {
title: <div className="column-header">Node</div>,
dataIndex: 'node',
key: 'node',
width: 100,
sorter: false,
ellipsis: true,
align: 'left',
className: 'column column-node',
};
export const clusterColumnConfig: ColumnType<K8sPodsRowData> = {
title: <div className="column-header">Cluster</div>,
dataIndex: 'cluster',
key: 'cluster',
width: 100,
sorter: false,
ellipsis: true,
align: 'left',
className: 'column column-cluster',
};
export const columnConfigMap: Record<string, ColumnType<K8sPodsRowData>> = {
namespace: namespaceColumnConfig,
node: nodeColumnConfig,
cluster: clusterColumnConfig,
};
export const getK8sPodsListColumns = (
addedColumns: IEntityColumn[],
groupBy: IBuilderQuery['groupBy'],
defaultOrderBy: OrderBySchemaType,
): ColumnType<K8sPodsRowData>[] => {
const updatedColumnsConfig: ColumnType<K8sPodsRowData>[] = [...columnsConfig];
for (const column of addedColumns) {
const config = columnConfigMap[column.id as keyof typeof columnConfigMap];
if (config) {
updatedColumnsConfig.push(config);
}
}
if (groupBy.length > 0) {
const filteredColumns = [...updatedColumnsConfig].filter(
(column) => column.key !== 'podName',
);
filteredColumns.unshift(podGroupColumnConfig);
return filteredColumns as ColumnType<K8sPodsRowData>[];
}
for (const column of updatedColumnsConfig) {
if (column.sorter && column.key === defaultOrderBy?.columnName) {
column.defaultSortOrder =
defaultOrderBy?.order === 'asc' ? 'ascend' : 'descend';
}
}
return updatedColumnsConfig;
};
const dotToUnder: Record<string, keyof K8sPodsData['meta']> = {
'k8s.cronjob.name': 'k8s_cronjob_name',
'k8s.daemonset.name': 'k8s_daemonset_name',
'k8s.deployment.name': 'k8s_deployment_name',
'k8s.job.name': 'k8s_job_name',
'k8s.namespace.name': 'k8s_namespace_name',
'k8s.node.name': 'k8s_node_name',
'k8s.pod.name': 'k8s_pod_name',
'k8s.pod.uid': 'k8s_pod_uid',
'k8s.statefulset.name': 'k8s_statefulset_name',
'k8s.cluster.name': 'k8s_cluster_name',
};
const getGroupByEle = (
pod: K8sPodsData,
groupBy: IBuilderQuery['groupBy'],
): React.ReactNode => {
const groupByValues: string[] = [];
groupBy.forEach((group) => {
const rawKey = group.key as string;
// Choose mapped key if present, otherwise use rawKey
const metaKey = (dotToUnder[rawKey] ?? rawKey) as keyof typeof pod.meta;
const value = pod.meta[metaKey];
groupByValues.push(value);
});
return (
<div className="pod-group">
{groupByValues.map((value) => (
<Tag key={value} color={Color.BG_SLATE_400} className="pod-group-tag-item">
{value === '' ? '<no-value>' : value}
</Tag>
))}
</div>
);
};
export const formatDataForTable = (
data: K8sPodsData[],
groupBy: IBuilderQuery['groupBy'],
): K8sPodsRowData[] =>
data.map((pod, index) => ({
key: `${pod.podUID}-${index}`,
podName: (
<Tooltip title={pod.meta.k8s_pod_name || ''}>
{pod.meta.k8s_pod_name || ''}
</Tooltip>
),
podUID: pod.podUID || '',
cpu_request: (
<ValidateColumnValueWrapper
value={pod.podCPURequest}
entity={K8sCategory.PODS}
attribute="CPU Request"
>
<div className="progress-container">
<EntityProgressBar value={pod.podCPURequest} type="request" />
</div>
</ValidateColumnValueWrapper>
),
cpu_limit: (
<ValidateColumnValueWrapper
value={pod.podCPULimit}
entity={K8sCategory.PODS}
attribute="CPU Limit"
>
<div className="progress-container">
<EntityProgressBar value={pod.podCPULimit} type="limit" />
</div>
</ValidateColumnValueWrapper>
),
cpu: (
<ValidateColumnValueWrapper value={pod.podCPU}>
{pod.podCPU}
</ValidateColumnValueWrapper>
),
memory_request: (
<ValidateColumnValueWrapper
value={pod.podMemoryRequest}
entity={K8sCategory.PODS}
attribute="Memory Request"
>
<div className="progress-container">
<EntityProgressBar value={pod.podMemoryRequest} type="request" />
</div>
</ValidateColumnValueWrapper>
),
memory_limit: (
<ValidateColumnValueWrapper
value={pod.podMemoryLimit}
entity={K8sCategory.PODS}
attribute="Memory Limit"
>
<div className="progress-container">
<EntityProgressBar value={pod.podMemoryLimit} type="limit" />
</div>
</ValidateColumnValueWrapper>
),
memory: (
<ValidateColumnValueWrapper value={pod.podMemory}>
{formatBytes(pod.podMemory)}
</ValidateColumnValueWrapper>
),
restarts: (
<ValidateColumnValueWrapper value={pod.restartCount}>
{pod.restartCount}
</ValidateColumnValueWrapper>
),
namespace: pod.meta.k8s_namespace_name,
node: pod.meta.k8s_node_name,
cluster: pod.meta.k8s_job_name,
meta: pod.meta,
podGroup: getGroupByEle(pod, groupBy),
...pod.meta,
groupedByMeta: pod.meta,
}));
/**
* Custom hook to manage the page size for a table.
* The page size is stored in local storage and is retrieved on initialization.

View File

@@ -1,3 +1,16 @@
.live-logs-container {
display: flex;
flex: 1;
min-height: 0;
}
.live-logs-content {
display: flex;
flex: 1;
flex-direction: column;
min-height: 0;
}
.live-logs-chart-container {
height: 200px;
min-height: 200px;
@@ -5,6 +18,12 @@
border-right: none;
}
.live-logs-list-container {
display: flex;
flex: 1;
min-height: 0;
}
.live-logs-settings-panel {
display: flex;
align-items: center;

View File

@@ -18,6 +18,19 @@
}
.live-logs-list {
display: flex;
flex: 1;
flex-direction: column;
min-height: 0;
.ant-card,
.ant-card-body {
display: flex;
flex: 1;
min-height: 0;
flex-direction: column;
}
.live-logs-list-loading {
padding: 16px;
text-align: center;

View File

@@ -8,8 +8,8 @@ import OverlayScrollbar from 'components/OverlayScrollbar/OverlayScrollbar';
import { CARD_BODY_STYLE } from 'constants/card';
import { LOCALSTORAGE } from 'constants/localStorage';
import { OptionFormatTypes } from 'constants/optionsFormatTypes';
import InfinityTableView from 'container/LogsExplorerList/InfinityTableView';
import { InfinityWrapperStyled } from 'container/LogsExplorerList/styles';
import TanStackTableView from 'container/LogsExplorerList/TanStackTableView';
import { convertKeysToColumnFields } from 'container/LogsExplorerList/utils';
import { useOptionsMenu } from 'container/OptionsMenu';
import { defaultLogsSelectedColumns } from 'container/OptionsMenu/constants';
@@ -50,7 +50,7 @@ function LiveLogsList({
[logs],
);
const { options } = useOptionsMenu({
const { options, config } = useOptionsMenu({
storageKey: LOCALSTORAGE.LOGS_LIST_OPTIONS,
dataSource: DataSource.LOGS,
aggregateOperator: StringOperators.NOOP,
@@ -158,7 +158,7 @@ function LiveLogsList({
{formattedLogs.length !== 0 && (
<InfinityWrapperStyled>
{options.format === OptionFormatTypes.TABLE ? (
<InfinityTableView
<TanStackTableView
ref={ref}
isLoading={false}
tableViewProps={{
@@ -174,6 +174,7 @@ function LiveLogsList({
onSetActiveLog={handleSetActiveLog}
onClearActiveLog={handleCloseLogDetail}
activeLog={activeLog}
onRemoveColumn={config.addColumn?.onRemove}
/>
) : (
<Card style={{ width: '100%' }} bodyStyle={CARD_BODY_STYLE}>

View File

@@ -4,18 +4,11 @@ import { FontSize } from 'container/OptionsMenu/types';
export const infinityDefaultStyles: CSSProperties = {
width: '100%',
overflowX: 'scroll',
marginTop: '15px',
};
export function getInfinityDefaultStyles(fontSize: FontSize): CSSProperties {
export function getInfinityDefaultStyles(_fontSize: FontSize): CSSProperties {
return {
width: '100%',
overflowX: 'scroll',
marginTop:
fontSize === FontSize.SMALL
? '10px'
: fontSize === FontSize.MEDIUM
? '12px'
: '15px',
};
}

View File

@@ -14,6 +14,78 @@ interface TableHeaderCellStyledProps {
export const TableStyled = styled.table`
width: 100%;
border-collapse: separate;
border-spacing: 0;
`;
/**
* TanStack column sizing uses table-layout:fixed + colgroup widths; without clipping,
* cell content overflows visually on top of neighbouring columns (overlap / "ghost" text).
*/
export const TanStackTableStyled = styled(TableStyled)`
table-layout: fixed;
width: 100%;
min-width: 100%;
max-width: 100%;
& td,
& th {
overflow: hidden;
min-width: 0;
box-sizing: border-box;
vertical-align: middle;
}
& td.table-actions-cell {
overflow: visible;
}
& td.body {
word-break: break-word;
overflow-wrap: anywhere;
}
/* Let nested body HTML / line-clamp shrink inside fixed columns */
& td.body > * {
min-width: 0;
max-width: 100%;
}
/* Long column titles: ellipsis when wider than the column (TanStackHeaderRow) */
& thead th .tanstack-header-title {
min-width: 0;
flex: 1 1 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
& thead th .tanstack-header-title > * {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 100%;
}
& td.logs-table-filler-cell,
& th.logs-table-filler-header {
padding: 0 !important;
min-width: 0;
border-left: none;
}
& th.logs-table-actions-header {
position: sticky;
right: 0;
z-index: 2;
width: 0 !important;
min-width: 0 !important;
max-width: 0 !important;
padding: 0 !important;
overflow: visible;
white-space: nowrap;
border-left: none;
}
`;
const getTimestampColumnWidth = (
@@ -46,6 +118,19 @@ export const TableCellStyled = styled.td<TableHeaderCellStyledProps>`
${({ columnKey, $hasSingleColumn }): string =>
getTimestampColumnWidth(columnKey, $hasSingleColumn)}
&.table-actions-cell {
position: sticky;
right: 0;
z-index: 2;
width: 0;
min-width: 0;
max-width: 0;
padding: 0 !important;
white-space: nowrap;
overflow: visible;
background-color: inherit;
}
`;
export const TableRowStyled = styled.tr<{
@@ -64,7 +149,10 @@ export const TableRowStyled = styled.tr<{
position: relative;
.log-line-action-buttons {
display: none;
display: flex;
opacity: 0;
pointer-events: none;
transition: opacity 80ms linear;
}
&:hover {
@@ -73,13 +161,25 @@ export const TableRowStyled = styled.tr<{
getActiveLogBackground(true, $isDarkMode, $logType)}
}
.log-line-action-buttons {
display: flex;
opacity: 1;
pointer-events: auto;
}
}
${({ $isActiveLog }): string =>
$isActiveLog
? `
.log-line-action-buttons {
opacity: 1;
pointer-events: auto;
}
`
: ''}
`;
export const TableHeaderCellStyled = styled.th<TableHeaderCellStyledProps>`
padding: 0.5rem;
height: 36px;
text-align: left;
font-size: 14px;
font-style: normal;
font-weight: 400;
@@ -98,6 +198,12 @@ export const TableHeaderCellStyled = styled.th<TableHeaderCellStyledProps>`
: ``};
${({ $isLogIndicator }): string =>
$isLogIndicator ? 'padding: 0px; width: 1%;' : ''}
border-top: 1px solid var(--l2-border);
border-bottom: 1px solid var(--l2-border);
box-shadow: inset 0 -1px 0 var(--l2-border);
&:first-child {
border-left: 1px solid var(--l2-border);
}
color: ${(props): string =>
props.$isDarkMode ? 'var(--bg-vanilla-100, #fff)' : themeColors.bckgGrey};

View File

@@ -5,6 +5,8 @@ import { ILog } from 'types/api/logs/log';
export type InfinityTableProps = {
isLoading?: boolean;
isFetching?: boolean;
onRemoveColumn?: (columnKey: string) => void;
tableViewProps: Omit<UseTableViewProps, 'onOpenLogsContext' | 'onClickExpand'>;
infitiyTableProps?: {
onEndReached: (index: number) => void;

View File

@@ -8,7 +8,7 @@
line-height: 18px;
letter-spacing: -0.005em;
text-align: left;
min-height: 500px;
min-height: 0;
.logs-list-table-view-container {
.data-table-container {
@@ -24,11 +24,11 @@
color: white !important;
.cursor-col-resize {
width: 3px !important;
width: 24px !important;
cursor: col-resize !important;
opacity: 0.5 !important;
background-color: var(--bg-ink-500) !important;
border: 1px solid var(--bg-ink-500) !important;
opacity: 1 !important;
background-color: transparent !important;
border: none !important;
&:hover {
opacity: 1 !important;
@@ -85,7 +85,7 @@
}
thead {
z-index: 0 !important;
z-index: 2 !important;
}
.log-state-indicator {

View File

@@ -0,0 +1,8 @@
// eslint-disable-next-line no-restricted-imports
import { createContext, useContext } from 'react';
const RowHoverContext = createContext(false);
export const useRowHover = (): boolean => useContext(RowHoverContext);
export default RowHoverContext;

View File

@@ -0,0 +1,84 @@
import { ComponentProps, memo, useCallback, useState } from 'react';
import { TableComponents } from 'react-virtuoso';
import {
getLogIndicatorType,
getLogIndicatorTypeForTable,
} from 'components/Logs/LogStateIndicator/utils';
import { useCopyLogLink } from 'hooks/logs/useCopyLogLink';
import { useIsDarkMode } from 'hooks/useDarkMode';
import { ILog } from 'types/api/logs/log';
import { TableRowStyled } from '../InfinityTableView/styles';
import RowHoverContext from '../RowHoverContext';
import { TanStackTableRowData } from './types';
export type TableRowContext = {
activeLog?: ILog | null;
activeContextLog?: ILog | null;
logsById: Map<string, ILog>;
};
type VirtuosoTableRowProps = ComponentProps<
NonNullable<TableComponents<TanStackTableRowData, TableRowContext>['TableRow']>
>;
type TanStackCustomTableRowProps = VirtuosoTableRowProps;
function TanStackCustomTableRow({
children,
item,
context,
...props
}: TanStackCustomTableRowProps): JSX.Element {
const { isHighlighted } = useCopyLogLink(item.currentLog.id);
const isDarkMode = useIsDarkMode();
const [hasHovered, setHasHovered] = useState(false);
const rowId = String(item.currentLog.id ?? '');
const activeLog = context?.activeLog;
const activeContextLog = context?.activeContextLog;
const logsById = context?.logsById;
const rowLog = logsById?.get(rowId) || item.currentLog;
const logType = rowLog
? getLogIndicatorType(rowLog)
: getLogIndicatorTypeForTable(item.log);
const handleMouseEnter = useCallback(() => {
if (!hasHovered) {
setHasHovered(true);
}
}, [hasHovered]);
return (
<RowHoverContext.Provider value={hasHovered}>
<TableRowStyled
{...props}
$isDarkMode={isDarkMode}
$isActiveLog={
isHighlighted ||
rowId === String(activeLog?.id ?? '') ||
rowId === String(activeContextLog?.id ?? '')
}
$logType={logType}
onMouseEnter={handleMouseEnter}
>
{children}
</TableRowStyled>
</RowHoverContext.Provider>
);
}
export default memo(TanStackCustomTableRow, (prev, next) => {
const prevId = String(prev.item.currentLog.id ?? '');
const nextId = String(next.item.currentLog.id ?? '');
if (prevId !== nextId) {
return false;
}
const prevIsActive =
prevId === String(prev.context?.activeLog?.id ?? '') ||
prevId === String(prev.context?.activeContextLog?.id ?? '');
const nextIsActive =
nextId === String(next.context?.activeLog?.id ?? '') ||
nextId === String(next.context?.activeContextLog?.id ?? '');
return prevIsActive === nextIsActive;
});

View File

@@ -0,0 +1,193 @@
import type {
CSSProperties,
MouseEvent as ReactMouseEvent,
TouchEvent as ReactTouchEvent,
} from 'react';
import { useMemo } from 'react';
import { CloseOutlined, MoreOutlined } from '@ant-design/icons';
import { useSortable } from '@dnd-kit/sortable';
import { Popover, PopoverContent, PopoverTrigger } from '@signozhq/popover';
import { flexRender, Header as TanStackHeader } from '@tanstack/react-table';
import { GripVertical } from 'lucide-react';
import { TableHeaderCellStyled } from '../InfinityTableView/styles';
import { InfinityTableProps } from '../InfinityTableView/types';
import { OrderedColumn, TanStackTableRowData } from './types';
import { getColumnId } from './utils';
import './styles/TanStackHeaderRow.styles.scss';
type TanStackHeaderRowProps = {
column: OrderedColumn;
header?: TanStackHeader<TanStackTableRowData, unknown>;
isDarkMode: boolean;
fontSize: InfinityTableProps['tableViewProps']['fontSize'];
hasSingleColumn: boolean;
canRemoveColumn?: boolean;
onRemoveColumn?: (columnKey: string) => void;
};
const GRIP_ICON_SIZE = 12;
// eslint-disable-next-line sonarjs/cognitive-complexity
function TanStackHeaderRow({
column,
header,
isDarkMode,
fontSize,
hasSingleColumn,
canRemoveColumn = false,
onRemoveColumn,
}: TanStackHeaderRowProps): JSX.Element {
const columnId = getColumnId(column);
const isDragColumn =
column.key !== 'expand' && column.key !== 'state-indicator';
const isResizableColumn = Boolean(header?.column.getCanResize());
const isColumnRemovable = Boolean(
canRemoveColumn &&
onRemoveColumn &&
column.key !== 'expand' &&
column.key !== 'state-indicator',
);
const isResizing = Boolean(header?.column.getIsResizing());
const resizeHandler = header?.getResizeHandler();
const headerText =
typeof column.title === 'string' && column.title
? column.title
: String(header?.id ?? columnId);
const headerTitleAttr = headerText.replace(/^\w/, (c) => c.toUpperCase());
const handleResizeStart = (
event: ReactMouseEvent<HTMLElement> | ReactTouchEvent<HTMLElement>,
): void => {
event.preventDefault();
event.stopPropagation();
resizeHandler?.(event);
};
const {
attributes,
listeners,
setNodeRef,
setActivatorNodeRef,
transform,
transition,
isDragging,
} = useSortable({
id: columnId,
disabled: !isDragColumn,
});
const headerCellStyle = useMemo(
() =>
({
'--tanstack-header-translate-x': `${Math.round(transform?.x ?? 0)}px`,
'--tanstack-header-translate-y': `${Math.round(transform?.y ?? 0)}px`,
'--tanstack-header-transition': isResizing ? 'none' : transition || 'none',
} as CSSProperties),
[isResizing, transform?.x, transform?.y, transition],
);
const headerCellClassName = [
'tanstack-header-cell',
isDragging ? 'is-dragging' : '',
isResizing ? 'is-resizing' : '',
]
.filter(Boolean)
.join(' ');
const headerContentClassName = [
'tanstack-header-content',
isResizableColumn ? 'has-resize-control' : '',
isColumnRemovable ? 'has-action-control' : '',
]
.filter(Boolean)
.join(' ');
return (
<TableHeaderCellStyled
ref={setNodeRef}
$isLogIndicator={column.key === 'state-indicator'}
$isDarkMode={isDarkMode}
$isDragColumn={false}
className={headerCellClassName}
key={columnId}
fontSize={fontSize}
$hasSingleColumn={hasSingleColumn}
style={headerCellStyle}
>
<span className={headerContentClassName}>
{isDragColumn ? (
<span className="tanstack-grip-slot">
<span
ref={setActivatorNodeRef}
{...attributes}
{...listeners}
role="button"
aria-label={`Drag ${String(
column.title || header?.id || columnId,
)} column`}
className="tanstack-grip-activator"
>
<GripVertical size={GRIP_ICON_SIZE} />
</span>
</span>
) : null}
<span className="tanstack-header-title" title={headerTitleAttr}>
{header
? flexRender(header.column.columnDef.header, header.getContext())
: String(column.title || '').replace(/^\w/, (c) => c.toUpperCase())}
</span>
{isColumnRemovable && (
<Popover>
<PopoverTrigger asChild>
<span
role="button"
aria-label={`Column actions for ${headerTitleAttr}`}
className="tanstack-header-action-trigger"
onMouseDown={(event): void => {
event.stopPropagation();
}}
>
<MoreOutlined />
</span>
</PopoverTrigger>
<PopoverContent
align="end"
sideOffset={6}
className="tanstack-column-actions-content"
>
<button
type="button"
className="tanstack-remove-column-action"
onClick={(event): void => {
event.preventDefault();
event.stopPropagation();
onRemoveColumn?.(String(column.key));
}}
>
<CloseOutlined className="tanstack-remove-column-action-icon" />
Remove column
</button>
</PopoverContent>
</Popover>
)}
</span>
{isResizableColumn && (
<span
role="presentation"
className="cursor-col-resize"
title="Drag to resize column"
onClick={(event): void => {
event.preventDefault();
event.stopPropagation();
}}
onMouseDown={(event): void => {
handleResizeStart(event);
}}
onTouchStart={(event): void => {
handleResizeStart(event);
}}
>
<span className="tanstack-resize-handle-line" />
</span>
)}
</TableHeaderCellStyled>
);
}
export default TanStackHeaderRow;

View File

@@ -0,0 +1,95 @@
import {
MouseEvent as ReactMouseEvent,
MouseEventHandler,
useCallback,
} from 'react';
import { flexRender, Row as TanStackRowModel } from '@tanstack/react-table';
import { VIEW_TYPES } from 'components/LogDetail/constants';
import LogLinesActionButtons from 'components/Logs/LogLinesActionButtons/LogLinesActionButtons';
import { TableCellStyled } from '../InfinityTableView/styles';
import { InfinityTableProps } from '../InfinityTableView/types';
import { useRowHover } from '../RowHoverContext';
import { TanStackTableRowData } from './types';
type TanStackRowCellsProps = {
row: TanStackRowModel<TanStackTableRowData>;
fontSize: InfinityTableProps['tableViewProps']['fontSize'];
onSetActiveLog?: InfinityTableProps['onSetActiveLog'];
onClearActiveLog?: InfinityTableProps['onClearActiveLog'];
isActiveLog?: boolean;
isDarkMode: boolean;
onLogCopy: (logId: string, event: ReactMouseEvent<HTMLElement>) => void;
isLogsExplorerPage: boolean;
};
function TanStackRowCells({
row,
fontSize,
onSetActiveLog,
onClearActiveLog,
isActiveLog = false,
isDarkMode,
onLogCopy,
isLogsExplorerPage,
}: TanStackRowCellsProps): JSX.Element {
const { currentLog } = row.original;
const hasHovered = useRowHover();
const handleShowContext: MouseEventHandler<HTMLElement> = useCallback(
(event) => {
event.preventDefault();
event.stopPropagation();
onSetActiveLog?.(currentLog, VIEW_TYPES.CONTEXT);
},
[currentLog, onSetActiveLog],
);
const handleShowLogDetails = useCallback(() => {
if (!currentLog) {
return;
}
if (isActiveLog && onClearActiveLog) {
onClearActiveLog();
return;
}
onSetActiveLog?.(currentLog);
}, [currentLog, isActiveLog, onClearActiveLog, onSetActiveLog]);
const visibleCells = row.getVisibleCells();
const lastCellIndex = visibleCells.length - 1;
return (
<>
{visibleCells.map((cell, index) => {
const columnKey = cell.column.id;
const isLastCell = index === lastCellIndex;
return (
<TableCellStyled
$isDragColumn={false}
$isLogIndicator={columnKey === 'state-indicator'}
$hasSingleColumn={visibleCells.length <= 2}
$isDarkMode={isDarkMode}
key={cell.id}
fontSize={fontSize}
className={columnKey}
onClick={handleShowLogDetails}
>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
{isLastCell && isLogsExplorerPage && hasHovered && (
<LogLinesActionButtons
handleShowContext={handleShowContext}
onLogCopy={(event): void => onLogCopy(currentLog.id, event)}
customClassName="table-view-log-actions"
/>
)}
</TableCellStyled>
);
})}
</>
);
}
export default TanStackRowCells;

View File

@@ -0,0 +1,105 @@
import { render, screen } from '@testing-library/react';
import TanStackCustomTableRow, {
TableRowContext,
} from '../TanStackCustomTableRow';
import type { TanStackTableRowData } from '../types';
jest.mock('../../InfinityTableView/styles', () => ({
TableRowStyled: 'tr',
}));
jest.mock('hooks/logs/useCopyLogLink', () => ({
useCopyLogLink: (): { isHighlighted: boolean } => ({ isHighlighted: false }),
}));
jest.mock('hooks/useDarkMode', () => ({
useIsDarkMode: (): boolean => false,
}));
jest.mock('components/Logs/LogStateIndicator/utils', () => ({
getLogIndicatorType: (): string => 'info',
getLogIndicatorTypeForTable: (): string => 'info',
}));
const item: TanStackTableRowData = {
log: {},
currentLog: { id: 'row-1' } as TanStackTableRowData['currentLog'],
rowIndex: 0,
};
const virtuosoTableRowAttrs = {
'data-index': 0,
'data-item-index': 0,
'data-known-size': 40,
} as const;
const defaultContext: TableRowContext = {
activeLog: null,
activeContextLog: null,
logsById: new Map(),
};
describe('TanStackCustomTableRow', () => {
it('renders children inside TableRowStyled', () => {
render(
<table>
<tbody>
<TanStackCustomTableRow
{...virtuosoTableRowAttrs}
item={item}
context={defaultContext}
>
<td>cell</td>
</TanStackCustomTableRow>
</tbody>
</table>,
);
expect(screen.getByText('cell')).toBeInTheDocument();
});
it('marks row active when activeLog matches item id', () => {
const { container } = render(
<table>
<tbody>
<TanStackCustomTableRow
{...virtuosoTableRowAttrs}
item={item}
context={{
...defaultContext,
activeLog: { id: 'row-1' } as never,
}}
>
<td>x</td>
</TanStackCustomTableRow>
</tbody>
</table>,
);
const row = container.querySelector('tr');
expect(row).toBeTruthy();
});
it('uses logsById entry when present for indicator type', () => {
const logFromMap = { id: 'row-1', severity_text: 'error' } as never;
render(
<table>
<tbody>
<TanStackCustomTableRow
{...virtuosoTableRowAttrs}
item={item}
context={{
...defaultContext,
logsById: new Map([['row-1', logFromMap]]),
}}
>
<td>x</td>
</TanStackCustomTableRow>
</tbody>
</table>,
);
expect(screen.getByText('x')).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,152 @@
import type { Header } from '@tanstack/react-table';
import { render, screen } from '@testing-library/react';
import { FontSize } from 'container/OptionsMenu/types';
import TanStackHeaderRow from '../TanStackHeaderRow';
import type { OrderedColumn, TanStackTableRowData } from '../types';
jest.mock('../../InfinityTableView/styles', () => ({
TableHeaderCellStyled: 'th',
}));
const mockUseSortable = jest.fn((_args?: unknown) => ({
attributes: {},
listeners: {},
setNodeRef: jest.fn(),
setActivatorNodeRef: jest.fn(),
transform: null,
transition: undefined,
isDragging: false,
}));
jest.mock('@dnd-kit/sortable', () => ({
useSortable: (args: unknown): ReturnType<typeof mockUseSortable> =>
mockUseSortable(args),
}));
jest.mock('@tanstack/react-table', () => ({
flexRender: (def: unknown, ctx: unknown): unknown => {
if (typeof def === 'string') {
return def;
}
if (typeof def === 'function') {
return (def as (c: unknown) => unknown)(ctx);
}
return def;
},
}));
const column = (key: string): OrderedColumn =>
({ key, title: key } as OrderedColumn);
const mockHeader = (
id: string,
canResize = true,
): Header<TanStackTableRowData, unknown> =>
(({
id,
column: {
getCanResize: (): boolean => canResize,
getIsResizing: (): boolean => false,
columnDef: { header: id },
},
getContext: (): unknown => ({}),
getResizeHandler: (): (() => void) => jest.fn(),
flexRender: undefined,
} as unknown) as Header<TanStackTableRowData, unknown>);
describe('TanStackHeaderRow', () => {
beforeEach(() => {
mockUseSortable.mockClear();
});
it('renders column title when header is undefined', () => {
render(
<table>
<thead>
<tr>
<TanStackHeaderRow
column={column('timestamp')}
isDarkMode={false}
fontSize={FontSize.SMALL}
hasSingleColumn={false}
/>
</tr>
</thead>
</table>,
);
expect(screen.getByText('Timestamp')).toBeInTheDocument();
});
it('enables useSortable for draggable columns', () => {
render(
<table>
<thead>
<tr>
<TanStackHeaderRow
column={column('body')}
header={mockHeader('body')}
isDarkMode={false}
fontSize={FontSize.SMALL}
hasSingleColumn={false}
/>
</tr>
</thead>
</table>,
);
expect(mockUseSortable).toHaveBeenCalledWith(
expect.objectContaining({
id: 'body',
disabled: false,
}),
);
});
it('disables sortable for expand column', () => {
render(
<table>
<thead>
<tr>
<TanStackHeaderRow
column={column('expand')}
header={mockHeader('expand', false)}
isDarkMode={false}
fontSize={FontSize.SMALL}
hasSingleColumn={false}
/>
</tr>
</thead>
</table>,
);
expect(mockUseSortable).toHaveBeenCalledWith(
expect.objectContaining({
disabled: true,
}),
);
});
it('shows drag grip for draggable columns', () => {
render(
<table>
<thead>
<tr>
<TanStackHeaderRow
column={column('body')}
header={mockHeader('body')}
isDarkMode={false}
fontSize={FontSize.SMALL}
hasSingleColumn={false}
/>
</tr>
</thead>
</table>,
);
expect(
screen.getByRole('button', { name: /Drag body column/i }),
).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,193 @@
import { fireEvent, render, screen } from '@testing-library/react';
import RowHoverContext from 'container/LogsExplorerList/RowHoverContext';
import { FontSize } from 'container/OptionsMenu/types';
import TanStackRowCells from '../TanStackRow';
import type { TanStackTableRowData } from '../types';
jest.mock('../../InfinityTableView/styles', () => ({
TableCellStyled: 'td',
}));
jest.mock(
'components/Logs/LogLinesActionButtons/LogLinesActionButtons',
() => ({
__esModule: true,
default: ({
onLogCopy,
}: {
onLogCopy: (e: React.MouseEvent) => void;
}): JSX.Element => (
<button type="button" data-testid="copy-btn" onClick={onLogCopy}>
copy
</button>
),
}),
);
const flexRenderMock = jest.fn((def: unknown, _ctx?: unknown) =>
typeof def === 'function' ? def({}) : def,
);
jest.mock('@tanstack/react-table', () => ({
flexRender: (def: unknown, ctx: unknown): unknown => flexRenderMock(def, ctx),
}));
function buildMockRow(
visibleCells: Array<{ columnId: string }>,
): Parameters<typeof TanStackRowCells>[0]['row'] {
return {
original: {
currentLog: { id: 'log-1' } as TanStackTableRowData['currentLog'],
log: {},
rowIndex: 0,
},
getVisibleCells: () =>
visibleCells.map((cell, index) => ({
id: `cell-${index}`,
column: {
id: cell.columnId,
columnDef: {
cell: (): string => `content-${cell.columnId}`,
},
},
getContext: (): Record<string, unknown> => ({}),
})),
} as never;
}
describe('TanStackRowCells', () => {
beforeEach(() => {
flexRenderMock.mockClear();
});
it('renders a cell per visible column and calls flexRender', () => {
const row = buildMockRow([
{ columnId: 'state-indicator' },
{ columnId: 'body' },
]);
render(
<table>
<tbody>
<tr>
<TanStackRowCells
row={row}
fontSize={FontSize.SMALL}
isDarkMode={false}
onLogCopy={jest.fn()}
isLogsExplorerPage={false}
/>
</tr>
</tbody>
</table>,
);
expect(screen.getAllByRole('cell')).toHaveLength(2);
expect(flexRenderMock).toHaveBeenCalled();
});
it('applies state-indicator styling class on the indicator cell', () => {
const row = buildMockRow([{ columnId: 'state-indicator' }]);
const { container } = render(
<table>
<tbody>
<tr>
<TanStackRowCells
row={row}
fontSize={FontSize.SMALL}
isDarkMode={false}
onLogCopy={jest.fn()}
isLogsExplorerPage={false}
/>
</tr>
</tbody>
</table>,
);
expect(container.querySelector('td.state-indicator')).toBeInTheDocument();
});
it('renders row actions on logs explorer page after hover', () => {
const row = buildMockRow([{ columnId: 'body' }]);
render(
<RowHoverContext.Provider value>
<table>
<tbody>
<tr>
<TanStackRowCells
row={row}
fontSize={FontSize.SMALL}
isDarkMode={false}
onLogCopy={jest.fn()}
isLogsExplorerPage
/>
</tr>
</tbody>
</table>
</RowHoverContext.Provider>,
);
expect(screen.getByTestId('copy-btn')).toBeInTheDocument();
});
it('click on a data cell calls onSetActiveLog with current log', () => {
const onSetActiveLog = jest.fn();
const row = buildMockRow([{ columnId: 'body' }]);
render(
<table>
<tbody>
<tr>
<TanStackRowCells
row={row}
fontSize={FontSize.SMALL}
isDarkMode={false}
onSetActiveLog={onSetActiveLog}
onLogCopy={jest.fn()}
isLogsExplorerPage={false}
/>
</tr>
</tbody>
</table>,
);
fireEvent.click(screen.getAllByRole('cell')[0]);
expect(onSetActiveLog).toHaveBeenCalledWith(
expect.objectContaining({ id: 'log-1' }),
);
});
it('when row is active log, click on cell clears active log', () => {
const onSetActiveLog = jest.fn();
const onClearActiveLog = jest.fn();
const row = buildMockRow([{ columnId: 'body' }]);
render(
<table>
<tbody>
<tr>
<TanStackRowCells
row={row}
fontSize={FontSize.SMALL}
isDarkMode={false}
isActiveLog
onSetActiveLog={onSetActiveLog}
onClearActiveLog={onClearActiveLog}
onLogCopy={jest.fn()}
isLogsExplorerPage={false}
/>
</tr>
</tbody>
</table>,
);
fireEvent.click(screen.getAllByRole('cell')[0]);
expect(onClearActiveLog).toHaveBeenCalled();
expect(onSetActiveLog).not.toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,104 @@
import { forwardRef } from 'react';
import { render, screen } from '@testing-library/react';
import { FontSize } from 'container/OptionsMenu/types';
import type { InfinityTableProps } from '../../InfinityTableView/types';
import TanStackTableView from '../index';
jest.mock('react-virtuoso', () => ({
TableVirtuoso: forwardRef<
unknown,
{
fixedHeaderContent?: () => JSX.Element;
itemContent: (i: number) => JSX.Element;
}
>(function MockVirtuoso({ fixedHeaderContent, itemContent }, _ref) {
return (
<div data-testid="virtuoso">
{fixedHeaderContent?.()}
{itemContent(0)}
</div>
);
}),
}));
jest.mock('components/Logs/TableView/useTableView', () => ({
useTableView: (): {
dataSource: Record<string, string>[];
columns: unknown[];
} => ({
dataSource: [{ id: '1' }],
columns: [
{ key: 'body', title: 'body', render: (): string => 'x' },
{ key: 'state-indicator', title: 's', render: (): string => 'y' },
],
}),
}));
jest.mock('hooks/useDragColumns', () => ({
__esModule: true,
default: (): {
draggedColumns: unknown[];
onColumnOrderChange: () => void;
} => ({
draggedColumns: [],
onColumnOrderChange: jest.fn(),
}),
}));
jest.mock('hooks/logs/useActiveLog', () => ({
useActiveLog: (): { activeLog: null } => ({ activeLog: null }),
}));
jest.mock('hooks/logs/useCopyLogLink', () => ({
useCopyLogLink: (): { activeLogId: null } => ({ activeLogId: null }),
}));
jest.mock('hooks/useDarkMode', () => ({
useIsDarkMode: (): boolean => false,
}));
jest.mock('react-router-dom', () => ({
useLocation: (): { pathname: string } => ({ pathname: '/logs' }),
}));
jest.mock('react-use', () => ({
useCopyToClipboard: (): [unknown, () => void] => [null, jest.fn()],
}));
jest.mock('@signozhq/sonner', () => ({
toast: { success: jest.fn() },
}));
jest.mock('components/Spinner', () => ({
__esModule: true,
default: ({ tip }: { tip: string }): JSX.Element => (
<div data-testid="spinner">{tip}</div>
),
}));
const baseProps: InfinityTableProps = {
isLoading: false,
tableViewProps: {
logs: [{ id: '1' } as never],
fields: [],
linesPerRow: 3,
fontSize: FontSize.SMALL,
appendTo: 'end',
activeLogIndex: 0,
},
};
describe('TanStackTableView', () => {
it('shows spinner while loading', () => {
render(<TanStackTableView {...baseProps} isLoading />);
expect(screen.getByTestId('spinner')).toHaveTextContent('Getting Logs');
});
it('renders virtuoso when not loading', () => {
render(<TanStackTableView {...baseProps} />);
expect(screen.getByTestId('virtuoso')).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,173 @@
import { act, renderHook } from '@testing-library/react';
import { LOCALSTORAGE } from 'constants/localStorage';
import type { OrderedColumn } from '../types';
import { useColumnSizingPersistence } from '../useColumnSizingPersistence';
const mockGet = jest.fn();
const mockSet = jest.fn();
jest.mock('api/browser/localstorage/get', () => ({
__esModule: true,
default: (key: string): string | null => mockGet(key),
}));
jest.mock('api/browser/localstorage/set', () => ({
__esModule: true,
default: (key: string, value: string): void => {
mockSet(key, value);
},
}));
const col = (key: string): OrderedColumn =>
({ key, title: key } as OrderedColumn);
describe('useColumnSizingPersistence', () => {
beforeEach(() => {
jest.clearAllMocks();
mockGet.mockReturnValue(null);
jest.useFakeTimers();
});
afterEach(() => {
jest.runOnlyPendingTimers();
jest.useRealTimers();
});
it('initializes with empty sizing when localStorage is empty', () => {
const { result } = renderHook(() =>
useColumnSizingPersistence([col('body'), col('timestamp')]),
);
expect(result.current.columnSizing).toEqual({});
});
it('parses flat ColumnSizingState from localStorage', () => {
mockGet.mockReturnValue(JSON.stringify({ body: 400, timestamp: 180 }));
const { result } = renderHook(() =>
useColumnSizingPersistence([col('body'), col('timestamp')]),
);
expect(result.current.columnSizing).toEqual({ body: 400, timestamp: 180 });
});
it('parses PersistedColumnSizing wrapper with sizing + columnIdsSignature', () => {
mockGet.mockReturnValue(
JSON.stringify({
version: 1,
columnIdsSignature: 'body|timestamp',
sizing: { body: 300 },
}),
);
const { result } = renderHook(() =>
useColumnSizingPersistence([col('body'), col('timestamp')]),
);
expect(result.current.columnSizing).toEqual({ body: 300 });
});
it('drops invalid numeric entries when reading from localStorage', () => {
mockGet.mockReturnValue(
JSON.stringify({
body: 200,
bad: NaN,
zero: 0,
neg: -1,
str: 'wide',
}),
);
const { result } = renderHook(() =>
useColumnSizingPersistence([col('body'), col('bad'), col('zero')]),
);
expect(result.current.columnSizing).toEqual({ body: 200 });
});
it('returns empty sizing when JSON is invalid', () => {
const spy = jest.spyOn(console, 'error').mockImplementation(() => {});
mockGet.mockReturnValue('not-json');
const { result } = renderHook(() =>
useColumnSizingPersistence([col('body')]),
);
expect(result.current.columnSizing).toEqual({});
spy.mockRestore();
});
it('prunes sizing for columns not in orderedColumns and strips fixed columns', () => {
mockGet.mockReturnValue(JSON.stringify({ body: 400, expand: 32, gone: 100 }));
const { result, rerender } = renderHook(
({ columns }: { columns: OrderedColumn[] }) =>
useColumnSizingPersistence(columns),
{
initialProps: {
columns: [
col('body'),
col('expand'),
col('state-indicator'),
] as OrderedColumn[],
},
},
);
expect(result.current.columnSizing).toEqual({ body: 400 });
act(() => {
rerender({
columns: [col('body'), col('expand'), col('state-indicator')],
});
});
expect(result.current.columnSizing).toEqual({ body: 400 });
});
it('updates setColumnSizing manually', () => {
const { result } = renderHook(() =>
useColumnSizingPersistence([col('body')]),
);
act(() => {
result.current.setColumnSizing({ body: 500 });
});
expect(result.current.columnSizing).toEqual({ body: 500 });
});
it('debounces writes to localStorage', () => {
const { result } = renderHook(() =>
useColumnSizingPersistence([col('body')]),
);
act(() => {
result.current.setColumnSizing({ body: 600 });
});
expect(mockSet).not.toHaveBeenCalled();
act(() => {
jest.advanceTimersByTime(250);
});
expect(mockSet).toHaveBeenCalledWith(
LOCALSTORAGE.LOGS_LIST_COLUMN_SIZING,
expect.stringContaining('"body":600'),
);
});
it('does not persist when ordered columns signature effect runs with empty ids early — still debounces empty sizing', () => {
const { result } = renderHook(() => useColumnSizingPersistence([]));
expect(result.current.columnSizing).toEqual({});
act(() => {
jest.advanceTimersByTime(250);
});
expect(mockSet).toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,222 @@
import { act, renderHook } from '@testing-library/react';
import type { OrderedColumn } from '../types';
import { useOrderedColumns } from '../useOrderedColumns';
const mockGetDraggedColumns = jest.fn();
jest.mock('hooks/useDragColumns/utils', () => ({
getDraggedColumns: <T,>(current: unknown[], dragged: unknown[]): T[] =>
mockGetDraggedColumns(current, dragged) as T[],
}));
const col = (key: string, title?: string): OrderedColumn =>
({ key, title: title ?? key } as OrderedColumn);
describe('useOrderedColumns', () => {
beforeEach(() => {
jest.clearAllMocks();
});
it('returns columns from getDraggedColumns filtered to keys with string or number', () => {
mockGetDraggedColumns.mockReturnValue([
col('body'),
col('timestamp'),
{ title: 'no-key' },
]);
const { result } = renderHook(() =>
useOrderedColumns({
columns: [],
draggedColumns: [],
onColumnOrderChange: jest.fn(),
}),
);
expect(result.current.orderedColumns).toEqual([
col('body'),
col('timestamp'),
]);
expect(result.current.orderedColumnIds).toEqual(['body', 'timestamp']);
});
it('hasSingleColumn is true when exactly one column is not state-indicator', () => {
mockGetDraggedColumns.mockReturnValue([col('state-indicator'), col('body')]);
const { result } = renderHook(() =>
useOrderedColumns({
columns: [],
draggedColumns: [],
onColumnOrderChange: jest.fn(),
}),
);
expect(result.current.hasSingleColumn).toBe(true);
});
it('hasSingleColumn is false when more than one non-state-indicator column exists', () => {
mockGetDraggedColumns.mockReturnValue([
col('state-indicator'),
col('body'),
col('timestamp'),
]);
const { result } = renderHook(() =>
useOrderedColumns({
columns: [],
draggedColumns: [],
onColumnOrderChange: jest.fn(),
}),
);
expect(result.current.hasSingleColumn).toBe(false);
});
it('handleDragEnd reorders columns and calls onColumnOrderChange', () => {
const onColumnOrderChange = jest.fn();
mockGetDraggedColumns.mockReturnValue([col('a'), col('b'), col('c')]);
const { result } = renderHook(() =>
useOrderedColumns({
columns: [],
draggedColumns: [],
onColumnOrderChange,
}),
);
act(() => {
result.current.handleDragEnd({
active: { id: 'a' },
over: { id: 'c' },
} as never);
});
expect(onColumnOrderChange).toHaveBeenCalledWith([
expect.objectContaining({ key: 'b' }),
expect.objectContaining({ key: 'c' }),
expect.objectContaining({ key: 'a' }),
]);
// Derived-only: orderedColumns should remain until draggedColumns (URL/localStorage) updates.
expect(result.current.orderedColumns.map((c) => c.key)).toEqual([
'a',
'b',
'c',
]);
});
it('handleDragEnd no-ops when over is null', () => {
const onColumnOrderChange = jest.fn();
mockGetDraggedColumns.mockReturnValue([col('a'), col('b')]);
const { result } = renderHook(() =>
useOrderedColumns({
columns: [],
draggedColumns: [],
onColumnOrderChange,
}),
);
const before = result.current.orderedColumns;
act(() => {
result.current.handleDragEnd({
active: { id: 'a' },
over: null,
} as never);
});
expect(result.current.orderedColumns).toBe(before);
expect(onColumnOrderChange).not.toHaveBeenCalled();
});
it('handleDragEnd no-ops when active.id equals over.id', () => {
const onColumnOrderChange = jest.fn();
mockGetDraggedColumns.mockReturnValue([col('a'), col('b')]);
const { result } = renderHook(() =>
useOrderedColumns({
columns: [],
draggedColumns: [],
onColumnOrderChange,
}),
);
act(() => {
result.current.handleDragEnd({
active: { id: 'a' },
over: { id: 'a' },
} as never);
});
expect(onColumnOrderChange).not.toHaveBeenCalled();
});
it('handleDragEnd no-ops when indices cannot be resolved', () => {
const onColumnOrderChange = jest.fn();
mockGetDraggedColumns.mockReturnValue([col('a'), col('b')]);
const { result } = renderHook(() =>
useOrderedColumns({
columns: [],
draggedColumns: [],
onColumnOrderChange,
}),
);
act(() => {
result.current.handleDragEnd({
active: { id: 'missing' },
over: { id: 'a' },
} as never);
});
expect(onColumnOrderChange).not.toHaveBeenCalled();
});
it('exposes sensors from useSensors', () => {
mockGetDraggedColumns.mockReturnValue([col('a')]);
const { result } = renderHook(() =>
useOrderedColumns({
columns: [],
draggedColumns: [],
onColumnOrderChange: jest.fn(),
}),
);
expect(result.current.sensors).toBeDefined();
});
it('syncs ordered columns when base order changes externally (e.g. URL / localStorage)', () => {
mockGetDraggedColumns.mockReturnValue([col('a'), col('b'), col('c')]);
const { result, rerender } = renderHook(
({ draggedColumns }: { draggedColumns: unknown[] }) =>
useOrderedColumns({
columns: [],
draggedColumns,
onColumnOrderChange: jest.fn(),
}),
{ initialProps: { draggedColumns: [] as unknown[] } },
);
expect(result.current.orderedColumns.map((column) => column.key)).toEqual([
'a',
'b',
'c',
]);
mockGetDraggedColumns.mockReturnValue([col('c'), col('b'), col('a')]);
act(() => {
rerender({ draggedColumns: [{ title: 'from-url' }] as unknown[] });
});
expect(result.current.orderedColumns.map((column) => column.key)).toEqual([
'c',
'b',
'a',
]);
});
});

View File

@@ -0,0 +1,433 @@
import {
forwardRef,
memo,
MouseEvent as ReactMouseEvent,
ReactElement,
useCallback,
useEffect,
useImperativeHandle,
useMemo,
useRef,
useState,
} from 'react';
import { useLocation } from 'react-router-dom';
import { useCopyToClipboard } from 'react-use';
import { TableVirtuoso, TableVirtuosoHandle } from 'react-virtuoso';
import { DndContext, pointerWithin } from '@dnd-kit/core';
import {
horizontalListSortingStrategy,
SortableContext,
} from '@dnd-kit/sortable';
import { toast } from '@signozhq/sonner';
import {
ColumnDef,
getCoreRowModel,
useReactTable,
} from '@tanstack/react-table';
import { VIEW_TYPES } from 'components/LogDetail/constants';
import { ColumnTypeRender } from 'components/Logs/TableView/types';
import { useTableView } from 'components/Logs/TableView/useTableView';
import Spinner from 'components/Spinner';
import { LOCALSTORAGE } from 'constants/localStorage';
import { QueryParams } from 'constants/query';
import ROUTES from 'constants/routes';
import { useActiveLog } from 'hooks/logs/useActiveLog';
import { useIsDarkMode } from 'hooks/useDarkMode';
import useDragColumns from 'hooks/useDragColumns';
import { infinityDefaultStyles } from '../InfinityTableView/config';
import { TanStackTableStyled } from '../InfinityTableView/styles';
import { InfinityTableProps } from '../InfinityTableView/types';
import TanStackCustomTableRow from './TanStackCustomTableRow';
import TanStackHeaderRow from './TanStackHeaderRow';
import TanStackRowCells from './TanStackRow';
import { TableRecord, TanStackTableRowData } from './types';
import { useColumnSizingPersistence } from './useColumnSizingPersistence';
import { useOrderedColumns } from './useOrderedColumns';
import {
getColumnId,
getColumnMinWidthPx,
resolveColumnTypeRender,
} from './utils';
import '../logsTableVirtuosoScrollbar.scss';
import './styles/TanStackTableView.styles.scss';
const COLUMN_DND_AUTO_SCROLL = {
layoutShiftCompensation: false as const,
threshold: { x: 0.2, y: 0 },
};
const TanStackTableView = forwardRef<TableVirtuosoHandle, InfinityTableProps>(
function TanStackTableView(
{
isLoading,
isFetching,
onRemoveColumn,
tableViewProps,
infitiyTableProps,
onSetActiveLog,
onClearActiveLog,
activeLog,
}: InfinityTableProps,
forwardedRef,
): JSX.Element {
const { pathname } = useLocation();
const virtuosoRef = useRef<TableVirtuosoHandle | null>(null);
// could avoid this if directly use forwardedRef in TableVirtuoso, but need to verify if it causes any issue with react-virtuoso internal ref handling
useImperativeHandle(
forwardedRef,
() => virtuosoRef.current as TableVirtuosoHandle,
[],
);
const [, setCopy] = useCopyToClipboard();
const isDarkMode = useIsDarkMode();
const isLogsExplorerPage = pathname === ROUTES.LOGS_EXPLORER;
const { activeLog: activeContextLog } = useActiveLog();
// Column definitions (shared with existing logs table)
const { dataSource, columns } = useTableView({
...tableViewProps,
onClickExpand: onSetActiveLog,
onOpenLogsContext: (log): void => onSetActiveLog?.(log, VIEW_TYPES.CONTEXT),
});
// Column order (drag + persisted order)
const { draggedColumns, onColumnOrderChange } = useDragColumns<TableRecord>(
LOCALSTORAGE.LOGS_LIST_COLUMNS,
);
const {
orderedColumns,
orderedColumnIds,
hasSingleColumn,
handleDragEnd,
sensors,
} = useOrderedColumns({
columns,
draggedColumns,
onColumnOrderChange: onColumnOrderChange as (columns: unknown[]) => void,
});
// Column sizing (persisted). stored to localStorage.
const { columnSizing, setColumnSizing } = useColumnSizingPersistence(
orderedColumns,
);
// don't allow "remove column" when only state-indicator + one data col remain
const isAtMinimumRemovableColumns = useMemo(
() =>
orderedColumns.filter(
(column) => column.key !== 'state-indicator' && column.key !== 'expand',
).length <= 1,
[orderedColumns],
);
// Table data (TanStack row data shape)
// useTableView sends flattened log data. this would not be needed once we move to new log details view
const tableData = useMemo<TanStackTableRowData[]>(
() =>
dataSource
.map((log, rowIndex) => {
const currentLog = tableViewProps.logs[rowIndex];
if (!currentLog) {
return null;
}
return { log, currentLog, rowIndex };
})
.filter(Boolean) as TanStackTableRowData[],
[dataSource, tableViewProps.logs],
);
// TanStack columns + table instance
const tanstackColumns = useMemo<ColumnDef<TanStackTableRowData>[]>(
() =>
orderedColumns.map((column, index) => {
const isStateIndicator = column.key === 'state-indicator';
const isExpand = column.key === 'expand';
const isFixedColumn = isStateIndicator || isExpand;
const fixedWidth = isFixedColumn ? 32 : undefined;
const minWidthPx = getColumnMinWidthPx(column, orderedColumns);
const headerTitle = String(column.title || '');
return {
id: getColumnId(column),
header: headerTitle.replace(/^\w/, (character) =>
character.toUpperCase(),
),
accessorFn: (row): unknown => row.log[column.key as keyof TableRecord],
enableResizing: !isFixedColumn && index !== orderedColumns.length - 1,
minSize: fixedWidth ?? minWidthPx,
size: fixedWidth, // last column gets remaining space, so don't set initial size to avoid conflict with resizing
maxSize: fixedWidth,
cell: ({ row, getValue }): ReactElement | string | number | null => {
if (!column.render) {
return null;
}
return resolveColumnTypeRender(
column.render(
getValue(),
row.original.log,
row.original.rowIndex,
) as ColumnTypeRender<Record<string, unknown>>,
);
},
};
}),
[orderedColumns],
);
const table = useReactTable({
data: tableData,
columns: tanstackColumns,
enableColumnResizing: true,
getCoreRowModel: getCoreRowModel(),
columnResizeMode: 'onChange',
onColumnSizingChange: setColumnSizing,
state: {
columnSizing,
},
});
const tableRows = table.getRowModel().rows;
// Infinite-scroll footer UI state
const [loadMoreState, setLoadMoreState] = useState<{
active: boolean;
startCount: number;
}>({
active: false,
startCount: 0,
});
// Map to resolve full log object by id (row highlighting + indicator)
const logsById = useMemo(
() => new Map(tableViewProps.logs.map((log) => [String(log.id), log])),
[tableViewProps.logs],
);
// this is already written in parent. Check if this is needed.
useEffect(() => {
const activeLogIndex = tableViewProps.activeLogIndex ?? -1;
if (activeLogIndex < 0 || activeLogIndex >= tableRows.length) {
return;
}
virtuosoRef.current?.scrollToIndex({
index: activeLogIndex,
align: 'center',
behavior: 'auto',
});
}, [tableRows.length, tableViewProps.activeLogIndex]);
useEffect(() => {
if (!loadMoreState.active) {
return;
}
if (!isFetching || tableRows.length > loadMoreState.startCount) {
setLoadMoreState((prev) =>
prev.active ? { active: false, startCount: prev.startCount } : prev,
);
}
}, [isFetching, loadMoreState, tableRows.length]);
const handleLogCopy = useCallback(
(logId: string, event: ReactMouseEvent<HTMLElement>): void => {
event.preventDefault();
event.stopPropagation();
const urlQuery = new URLSearchParams(window.location.search);
urlQuery.delete(QueryParams.activeLogId);
urlQuery.delete(QueryParams.relativeTime);
urlQuery.set(QueryParams.activeLogId, `"${logId}"`);
const link = `${window.location.origin}${pathname}?${urlQuery.toString()}`;
setCopy(link);
toast.success('Copied to clipboard', { position: 'top-right' });
},
[pathname, setCopy],
);
const itemContent = useCallback(
(index: number): JSX.Element | null => {
const row = tableRows[index];
if (!row) {
return null;
}
return (
<TanStackRowCells
row={row}
fontSize={tableViewProps.fontSize}
onSetActiveLog={onSetActiveLog}
onClearActiveLog={onClearActiveLog}
isActiveLog={
String(activeLog?.id ?? '') === String(row.original.currentLog.id ?? '')
}
isDarkMode={isDarkMode}
onLogCopy={handleLogCopy}
isLogsExplorerPage={isLogsExplorerPage}
/>
);
},
[
activeLog?.id,
handleLogCopy,
isDarkMode,
isLogsExplorerPage,
onClearActiveLog,
onSetActiveLog,
tableRows,
tableViewProps.fontSize,
],
);
const flatHeaders = useMemo(
() => table.getFlatHeaders().filter((header) => !header.isPlaceholder),
// eslint-disable-next-line react-hooks/exhaustive-deps
[tanstackColumns],
);
const tableHeader = useCallback(() => {
const orderedColumnsById = new Map(
orderedColumns.map((column) => [getColumnId(column), column] as const),
);
return (
<DndContext
sensors={sensors}
collisionDetection={pointerWithin}
onDragEnd={handleDragEnd}
autoScroll={COLUMN_DND_AUTO_SCROLL}
>
<SortableContext
items={orderedColumnIds}
strategy={horizontalListSortingStrategy}
>
<tr>
{flatHeaders.map((header) => {
const column = orderedColumnsById.get(header.id);
if (!column) {
return null;
}
return (
<TanStackHeaderRow
key={header.id}
column={column}
header={header}
isDarkMode={isDarkMode}
fontSize={tableViewProps.fontSize}
hasSingleColumn={hasSingleColumn}
onRemoveColumn={onRemoveColumn}
canRemoveColumn={!isAtMinimumRemovableColumns}
/>
);
})}
</tr>
</SortableContext>
</DndContext>
);
}, [
flatHeaders,
handleDragEnd,
hasSingleColumn,
isDarkMode,
orderedColumnIds,
orderedColumns,
onRemoveColumn,
isAtMinimumRemovableColumns,
sensors,
tableViewProps.fontSize,
]);
const handleEndReached = useCallback(
(index: number): void => {
if (!infitiyTableProps?.onEndReached) {
return;
}
setLoadMoreState({
active: true,
startCount: tableRows.length,
});
infitiyTableProps.onEndReached(index);
},
[infitiyTableProps, tableRows.length],
);
if (isLoading) {
return <Spinner height="35px" tip="Getting Logs" />;
}
return (
<div className="tanstack-table-view-wrapper">
<TableVirtuoso
className="logs-table-virtuoso-scroll"
ref={virtuosoRef}
style={infinityDefaultStyles}
data={tableData}
totalCount={tableRows.length}
increaseViewportBy={{ top: 500, bottom: 500 }}
initialTopMostItemIndex={
tableViewProps.activeLogIndex !== -1 ? tableViewProps.activeLogIndex : 0
}
context={{ activeLog, activeContextLog, logsById }}
fixedHeaderContent={tableHeader}
itemContent={itemContent}
components={{
Table: ({ style, children }): JSX.Element => (
<TanStackTableStyled style={style}>
<colgroup>
{orderedColumns.map((column, colIndex) => {
const columnId = getColumnId(column);
const isFixedColumn =
column.key === 'expand' || column.key === 'state-indicator';
const minWidthPx = getColumnMinWidthPx(column, orderedColumns);
const persistedWidth = columnSizing[columnId];
const computedWidth = table.getColumn(columnId)?.getSize();
const effectiveWidth = persistedWidth ?? computedWidth;
if (isFixedColumn) {
return <col key={columnId} className="tanstack-fixed-col" />;
}
// Last data column should stretch to fill remaining space
const isLastColumn = colIndex === orderedColumns.length - 1;
if (isLastColumn) {
return (
<col
key={columnId}
style={{ width: '100%', minWidth: `${minWidthPx}px` }}
/>
);
}
const widthPx =
effectiveWidth != null
? Math.max(effectiveWidth, minWidthPx)
: minWidthPx;
return (
<col
key={columnId}
style={{ width: `${widthPx}px`, minWidth: `${minWidthPx}px` }}
/>
);
})}
</colgroup>
{children}
</TanStackTableStyled>
),
TableRow: TanStackCustomTableRow,
}}
{...(infitiyTableProps?.onEndReached
? { endReached: handleEndReached }
: {})}
/>
{loadMoreState.active && (
<div className="tanstack-load-more-container">
<Spinner height="20px" tip="Getting Logs" />
</div>
)}
</div>
);
},
);
export default memo(TanStackTableView);

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