Compare commits

..

12 Commits

Author SHA1 Message Date
Abhi Kumar
ff1cc79c00 feat(dashboard-v2): pin the query-type tabs to the top of the editor scroll area
Make the query-type tab nav + Run Query button sticky so they stay visible
while the query body scrolls underneath; move the top padding onto the nav so
it keeps its spacing at rest and when pinned.
2026-07-09 00:27:45 +05:30
Abhi Kumar
83dc3ef430 feat(dashboard-v2): support per-series legend colors for bar and histogram panels
Route BarChartPanel and HistogramPanel through the time-series legend path in
useLegendSeries and enable the Legend `colors` control in both kinds' sections,
extending the per-series color overrides to these kinds.
2026-07-09 00:27:31 +05:30
Abhi Kumar
03114ed645 refactor(dashboard-v2): read panel time preference via shared helper
Panel.tsx read visualization.timePreference through an inline cast; replace it
with getPanelTimePreference so the header pill and NoData's extend gate share
one reader.
2026-07-09 00:26:04 +05:30
Abhi Kumar
890234ac6c fix(dashboard-v2): hide "Extend time range" for panels with a fixed time preference
A panel locked to a non-global timePreference queries a fixed relative span
anchored to the dashboard end, so widening the ambient window can't surface
data for it — the "Extend time range" CTA was a no-op. NoData now takes the
panel and, via panelHasFixedTimePreference, drops the global extender for such
panels (only Retry remains). The View modal's local extender still wins.
2026-07-09 00:25:43 +05:30
Abhi Kumar
ec04a885f4 fix(dashboard-v2): lazy-load panels per-panel instead of per-section
Move the viewport intersection observer down from Section to a new
SectionGridItem wrapper so each panel gates its own fetch. A board with
many panels now only queries the ones actually on screen, instead of
loading every panel in any section that scrolls into view.
2026-07-08 23:49:28 +05:30
Abhi Kumar
a71aacc8fd fix(dashboard-v2): hide the empty panel header in editor preview
When a panel has no title, description, or status and the actions menu is
suppressed (editor preview), render nothing instead of an empty header bar.
Add a min-height so the header keeps a stable size when it does render.
2026-07-08 23:49:28 +05:30
Abhi Kumar
05f3862e23 feat(dashboard-v2): offer dashboard root and every section in the move menu
Rework the "Move to section" submenu to list the dashboard root plus every
titled section (minus the panel's own), instead of only titled sections with
a separate "move out of section" entry. Hide it entirely when there is
nowhere to move.
2026-07-08 23:49:28 +05:30
Abhi Kumar
d26a904d94 feat(dashboard-v2): support per-slice color overrides for pie charts
Enable the legend colors control for pie charts. The control is fed by
useLegendSeries, which only handled time-series data, so pie returned no
series. Resolve pie legend series from the same scalar slices the renderer
draws (without overrides, for default colors) so the color keys match what
the chart looks up.

Extract the series resolution into utils/legendSeries.ts (a shared dedupe
primitive plus a pie and a time-series resolver) and reduce the hook to a
kind switch that returns none for kinds without a colors control.
2026-07-08 23:49:28 +05:30
Abhi Kumar
85895d6c4f fix(dashboard-v2): measure chart dimensions before first paint
useResizeObserver seeded its size to 0 and only reported real dimensions
after paint via its debounced observer, so charts rendered once at 0 then
jumped — most visibly the pie legend, which rendered left-aligned then
re-centered once it learned it fit on one row. Add a useLayoutEffect that
measures the element synchronously before paint. In jsdom clientWidth is 0,
so tests are unaffected.
2026-07-08 23:49:28 +05:30
Abhi Kumar
2efbd04ae1 feat(dashboard-v2): add the list columns editor and pagination loader to the view modal
Surface the List panel's columns editor in the View modal (as in the full
editor) by exposing setSpec from useViewPanelMode and wiring it into the
query-builder footer. Its add-column combobox portals to body, so inside
the modal it opened behind the dialog — lift its z-index above the
query-builder popups so it stacks on top.

Also thread isPreviousData into PreviewPane so the List panel's page-change
loader shows during pagination, matching the full editor and dashboard.
2026-07-08 23:49:27 +05:30
Abhi Kumar
bb9acc2f65 fix(dashboard-v2): make antd popups usable inside the view panel modal
The editor's antd Selects/pickers portal to document.body, outside the
modal Radix dialog, where Radix disables pointer events and traps focus, so
their menus opened but couldn't be clicked. Wrap the modal body in an antd
ConfigProvider whose getPopupContainer points at the dialog content, so all
antd popups render inside the interactive, focus-trapped layer.
2026-07-08 21:26:26 +05:30
Abhi Kumar
67608b1d8c fix(dashboard-v2): tint chart-legend checkbox with its series color
The @signozhq/ui Checkbox declares --checkbox-checked-background on the
element itself via its data-color rule, so a value set on an ancestor was
shadowed and the per-series tint never applied. Feed the color through a
private --series-color var and override the vars on the element with a
selector that out-specifies the library rule.
2026-07-08 21:26:26 +05:30
214 changed files with 1185 additions and 27660 deletions

View File

@@ -116,15 +116,6 @@ func (ah *APIHandler) getFeatureFlags(w http.ResponseWriter, r *http.Request) {
Route: "",
})
infraMonitoringV2 := ah.Signoz.Flagger.BooleanOrEmpty(ctx, flagger.FeatureUseInfraMonitoringV2, evalCtx)
featureSet = append(featureSet, &licensetypes.Feature{
Name: valuer.NewString(flagger.FeatureUseInfraMonitoringV2.String()),
Active: infraMonitoringV2,
Usage: 0,
UsageLimit: -1,
Route: "",
})
if constants.IsDotMetricsEnabled {
for idx, feature := range featureSet {
if feature.Name == licensetypes.DotMetricsEnabled {

View File

@@ -11,7 +11,6 @@ export enum FeatureKeys {
USE_JSON_BODY = 'use_json_body',
USE_FINE_GRAINED_AUTHZ = 'use_fine_grained_authz',
USE_DASHBOARD_V2 = 'use_dashboard_v2',
USE_INFRA_MONITORING_V2 = 'use_infra_monitoring_v2',
ENABLE_AI_OBSERVABILITY = 'enable_ai_observability',
ENABLE_METRICS_REDUCTION = 'enable_metrics_reduction',
}

View File

@@ -0,0 +1,4 @@
.wrapper :global(button[data-color]) {
--checkbox-checked-background: var(--series-color);
--checkbox-border-color: var(--series-color);
}

View File

@@ -3,6 +3,7 @@ import { Checkbox } from '@signozhq/ui/checkbox';
import { CSSProperties } from 'react';
import { CheckBoxProps } from '../types';
import styles from './CustomCheckBox.module.scss';
function CustomCheckBox({
data,
@@ -15,12 +16,11 @@ function CustomCheckBox({
const isChecked = graphVisibilityState[index] || false;
const colorStyle = {
'--checkbox-checked-background': color,
'--checkbox-border-color': color,
'--series-color': color,
} as CSSProperties;
return (
<span style={colorStyle}>
<span className={styles.wrapper} style={colorStyle}>
<Checkbox
onChange={(checked): void => checkBoxOnChangeHandler(checked, index)}
value={isChecked}

View File

@@ -1,225 +0,0 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { Button, Tooltip } from 'antd';
import { Typography } from '@signozhq/ui/typography';
import logEvent from 'api/common/logEvent';
import QuickFilters from 'components/QuickFilters/QuickFilters';
import { QuickFiltersSource } from 'components/QuickFilters/types';
import { InfraMonitoringEvents } from 'constants/events';
import { FeatureKeys } from 'constants/features';
import { LOCALSTORAGE } from 'constants/localStorage';
import K8sBaseDetails from 'container/InfraMonitoringK8sV2/Base/K8sBaseDetails';
import { K8sBaseList } from 'container/InfraMonitoringK8sV2/Base/K8sBaseList';
import { K8sBaseFilters } from 'container/InfraMonitoringK8sV2/Base/types';
import { InfraMonitoringEntity } from 'container/InfraMonitoringK8sV2/constants';
import {
useInfraMonitoringFiltersK8s,
useInfraMonitoringPageListing,
} from 'container/InfraMonitoringK8sV2/hooks';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { useQueryOperations } from 'hooks/queryBuilder/useQueryBuilderOperations';
import { ResizableBox } from 'periscope/components/ResizableBox';
import usePanelWidth from 'periscope/components/ResizableBox/usePanelWidth';
import { useAppContext } from 'providers/App/App';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
import {
fetchHostEntityData,
fetchHostListData,
getHostMetricsQueryPayload,
hostDetailsMetadataConfig,
hostGetEntityName,
hostGetSelectedItemFilters,
hostInitialEventsFilter,
hostInitialLogTracesFilter,
hostWidgetInfo,
} from './constants';
import {
getHostItemKey,
getHostRowKey,
hostColumnsConfig,
} from './table.config';
import { getHostsQuickFiltersConfig } from './utils';
import styles from './InfraMonitoringHosts.module.scss';
import { ArrowUpToLine, Filter } from '@signozhq/icons';
const QUICK_FILTERS_DEFAULT_WIDTH = 280;
const QUICK_FILTERS_MIN_WIDTH = 240;
const QUICK_FILTERS_MAX_WIDTH = 500;
function Hosts(): JSX.Element {
const [showFilters, setShowFilters] = useState(true);
const {
initialWidth: quickFiltersInitialWidth,
persistWidth: persistQuickFiltersWidth,
} = usePanelWidth({
storageKey: LOCALSTORAGE.QUICK_FILTERS_WIDTH_INFRA,
defaultWidth: QUICK_FILTERS_DEFAULT_WIDTH,
minWidth: QUICK_FILTERS_MIN_WIDTH,
maxWidth: QUICK_FILTERS_MAX_WIDTH,
});
const [, setCurrentPage] = useInfraMonitoringPageListing();
const [urlFilters, setUrlFilters] = useInfraMonitoringFiltersK8s();
const { featureFlags } = useAppContext();
const dotMetricsEnabled =
featureFlags?.find((flag) => flag.name === FeatureKeys.DOT_METRICS_ENABLED)
?.active || false;
const { currentQuery } = useQueryBuilder();
const { handleChangeQueryData } = useQueryOperations({
index: 0,
query: currentQuery.builder.queryData[0],
entityVersion: '',
});
// Track previous urlFilters to only sync when the value actually changes
// (not when handleChangeQueryData changes due to query updates)
const prevUrlFiltersRef = useRef<string | null>(null);
useEffect(() => {
const currentFiltersJson = urlFilters ? JSON.stringify(urlFilters) : null;
// Only sync if urlFilters value has actually changed
if (prevUrlFiltersRef.current !== currentFiltersJson) {
prevUrlFiltersRef.current = currentFiltersJson;
// Sync filters to query builder, using empty filter when urlFilters is null
handleChangeQueryData('filters', urlFilters || { items: [], op: 'and' });
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [urlFilters]); // handleChangeQueryData intentionally omitted - we call the current version but don't re-run when it changes
const handleFilterVisibilityChange = (): void => {
setShowFilters(!showFilters);
};
const handleQuickFiltersChange = (query: Query): void => {
const filters = query.builder.queryData[0].filters;
// Nuqs batches these calls into a single URL update
// The useEffect will sync filters to query builder
setUrlFilters(filters || null);
setCurrentPage(1);
logEvent(InfraMonitoringEvents.FilterApplied, {
entity: InfraMonitoringEvents.HostEntity,
page: InfraMonitoringEvents.ListPage,
});
};
const fetchListData = useCallback(
async (filters: K8sBaseFilters, signal?: AbortSignal) => {
filters.orderBy ||= {
columnName: 'cpu',
order: 'desc',
};
return fetchHostListData(filters, signal);
},
[],
);
const fetchEntityData = useCallback(
async (
filters: Parameters<typeof fetchHostEntityData>[0],
signal?: AbortSignal,
) => fetchHostEntityData(filters, signal),
[],
);
const getSelectedItemFilters = useCallback(
(selectedItem: string) =>
hostGetSelectedItemFilters(selectedItem, dotMetricsEnabled),
[dotMetricsEnabled],
);
const getInitialLogTracesFilters = useCallback(
(host: import('api/infraMonitoring/getHostLists').HostData) =>
hostInitialLogTracesFilter(host, dotMetricsEnabled),
[dotMetricsEnabled],
);
const controlListPrefix = !showFilters ? (
<div className={styles.quickFiltersToggleContainer}>
<Button
className="periscope-btn ghost"
type="text"
size="small"
onClick={handleFilterVisibilityChange}
>
<Filter size={14} />
</Button>
</div>
) : undefined;
return (
<>
<div className={styles.infraMonitoringContainer}>
<div className={styles.infraContentRow}>
{showFilters && (
<ResizableBox
handle="right"
defaultWidth={QUICK_FILTERS_DEFAULT_WIDTH}
initialWidth={quickFiltersInitialWidth}
minWidth={QUICK_FILTERS_MIN_WIDTH}
maxWidth={QUICK_FILTERS_MAX_WIDTH}
onResize={persistQuickFiltersWidth}
resetToDefaultOnDoubleClick
withHandle
className={styles.quickFiltersContainer}
handleTestId="quick-filters-resize-handle"
>
<div className={styles.quickFiltersContainerHeader}>
<Typography.Text>Filters</Typography.Text>
<Tooltip title="Collapse Filters">
<ArrowUpToLine
style={{ rotate: '270deg', cursor: 'pointer' }}
onClick={handleFilterVisibilityChange}
size="md"
/>
</Tooltip>
</div>
<QuickFilters
source={QuickFiltersSource.INFRA_MONITORING}
config={getHostsQuickFiltersConfig(dotMetricsEnabled)}
handleFilterVisibilityChange={handleFilterVisibilityChange}
onFilterChange={handleQuickFiltersChange}
/>
</ResizableBox>
)}
<div
className={`${styles.listContainer}${
showFilters ? ` ${styles.listContainerFiltersVisible}` : ''
}`}
>
<K8sBaseList
controlListPrefix={controlListPrefix}
entity={InfraMonitoringEntity.HOSTS}
tableColumns={hostColumnsConfig}
fetchListData={fetchListData}
getRowKey={getHostRowKey}
getItemKey={getHostItemKey}
eventCategory={InfraMonitoringEvents.HostEntity}
/>
</div>
</div>
</div>
<K8sBaseDetails
category={InfraMonitoringEntity.HOSTS}
eventCategory={InfraMonitoringEvents.HostEntity}
getSelectedItemFilters={getSelectedItemFilters}
fetchEntityData={fetchEntityData}
getEntityName={hostGetEntityName}
getInitialLogTracesFilters={getInitialLogTracesFilters}
getInitialEventsFilters={hostInitialEventsFilter}
metadataConfig={hostDetailsMetadataConfig}
entityWidgetInfo={hostWidgetInfo}
getEntityQueryPayload={getHostMetricsQueryPayload}
queryKeyPrefix="hosts"
tabsConfig={{
showEvents: false,
}}
/>
</>
);
}
export default Hosts;

View File

@@ -1,171 +0,0 @@
.infraMonitoringContainer {
display: flex;
height: 100%;
flex-direction: column;
}
.infraContentRow {
display: flex;
flex-direction: row;
height: calc(100vh - 45px);
width: 100%;
position: relative;
overflow-y: auto;
:global(.periscope-btn) {
&.ghost:not(:disabled) {
border: none;
background: transparent;
&:hover {
background: transparent;
color: var(--bg-robin-500) !important;
font-weight: 500;
}
}
}
&::-webkit-scrollbar {
width: 0.1rem;
height: 0.1rem;
}
&::-webkit-scrollbar-track {
background: transparent;
}
&::-webkit-scrollbar-thumb {
background: var(--accent-primary);
}
&::-webkit-scrollbar-thumb:hover {
background: colox-mix(var(--accent-primary), var(--bg-vanilla-100), 20%);
}
}
.quickFiltersContainer {
// Width is owned by ResizableBox (inline style). No overflow here: the panel
// must not scroll (QuickFilters scrolls its own list internally) — an
// overflow on the root would also clip the edge-mounted resize grip.
flex-shrink: 0;
border-right: 1px solid var(--l1-border);
:global(.resizable-box__content) {
display: flex;
flex-direction: column;
}
:global(.quick-filters-container) {
flex: 1;
min-height: 0;
}
:global(.ant-collapse-header) {
border-bottom: 1px solid var(--l1-border);
padding: 12px 8px;
}
:global(.ant-collapse-content-box) {
padding: 0 !important;
padding-block: 0 !important;
:global(.quick-filters .checkbox-filter) {
padding-left: 18px;
}
}
:global(.quick-filters) {
overflow-y: auto;
overflow-x: hidden;
&::-webkit-scrollbar {
width: 0.1rem;
height: 0.1rem;
}
&::-webkit-scrollbar-track {
background: transparent;
}
&::-webkit-scrollbar-thumb {
background: var(--accent-primary);
}
&::-webkit-scrollbar-thumb:hover {
background: colox-mix(var(--accent-primary), var(--bg-vanilla-100), 20%);
}
}
}
.quickFiltersContainerHeader {
padding: 8px;
border-bottom: 1px solid var(--l1-border);
display: flex;
align-items: center;
justify-content: space-between;
}
.listContainer {
flex: 1;
min-width: 0;
max-width: 100%;
display: flex;
flex-direction: column;
align-items: stretch;
> * {
min-width: 0;
max-width: 100%;
box-sizing: border-box;
}
> :global(.ant-table-wrapper) {
width: 100%;
max-width: 100%;
:global(.ant-spin-container) {
display: flex !important;
flex-direction: column;
}
:global(.ant-table),
:global(.ant-table-container) {
max-width: 100%;
}
}
}
.listContainerFiltersVisible {
// Width is driven by flex now that the filters panel is resizable; the list
// fills whatever space the (variable-width) panel leaves.
max-width: 100%;
}
.quickFiltersToggleContainer {
padding: 0 8px;
}
.infraMonitoringTags {
width: fit-content;
font-family: Inter;
font-size: 11px;
font-style: normal;
font-weight: 500;
line-height: 18px;
letter-spacing: 0.44px;
text-transform: uppercase;
border-radius: 50px;
padding: 2px 8px;
}
.tagsActive {
color: var(--bg-forest-500, #25e192);
border: 1px solid rgba(37, 225, 146, 0.2);
background: rgba(37, 225, 146, 0.1);
}
.tagsInactive {
color: var(--bg-slate-50, #62687c);
border: 1px solid rgba(98, 104, 124, 0.2);
background: rgba(98, 104, 124, 0.1);
}

View File

@@ -1,191 +0,0 @@
import React from 'react';
import { Color } from '@signozhq/design-tokens';
import { Badge } from '@signozhq/ui/badge';
import { Progress } from '@signozhq/ui/progress';
import { Typography } from '@signozhq/ui/typography';
import {
getHostLists,
HostData,
HostListPayload,
} from 'api/infraMonitoring/getHostLists';
import {
createFilterItem,
K8sDetailsFilters,
K8sDetailsMetadataConfig,
} from 'container/InfraMonitoringK8sV2/Base/K8sBaseDetails';
import { K8sBaseFilters } from 'container/InfraMonitoringK8sV2/Base/types';
import {
getHostQueryPayload,
hostWidgetInfo,
} from 'container/LogDetailedView/InfraMetrics/constants';
import {
TagFilter,
TagFilterItem,
} from 'types/api/queryBuilder/queryBuilderData';
import { getHostListsQuery } from './utils';
import infraHostsStyles from './InfraMonitoringHosts.module.scss';
export function getProgressColor(percent: number): string {
if (percent >= 90) {
return Color.BG_SAKURA_500;
}
if (percent >= 60) {
return Color.BG_AMBER_500;
}
return Color.BG_FOREST_500;
}
export function getMemoryProgressColor(percent: number): string {
if (percent >= 90) {
return Color.BG_CHERRY_500;
}
if (percent >= 60) {
return Color.BG_AMBER_500;
}
return Color.BG_FOREST_500;
}
export const hostDetailsMetadataConfig: K8sDetailsMetadataConfig<HostData>[] = [
{
label: 'STATUS',
getValue: (h): string => (h.active ? 'ACTIVE' : 'INACTIVE'),
render: (value, h): React.ReactNode => (
<Badge
variant="outline"
className={`${infraHostsStyles.infraMonitoringTags} ${
h.active ? infraHostsStyles.tagsActive : infraHostsStyles.tagsInactive
}`}
>
{value}
</Badge>
),
},
{
label: 'OPERATING SYSTEM',
getValue: (h): string => h.os || '-',
render: (value): React.ReactNode =>
value !== '-' ? (
<Badge variant="outline" className={infraHostsStyles.infraMonitoringTags}>
{value}
</Badge>
) : (
<Typography.Text>-</Typography.Text>
),
},
{
label: 'CPU USAGE',
getValue: (h): number => h.cpu * 100,
render: (value): React.ReactNode => (
<Progress
percent={Number(Number(value).toFixed(1))}
strokeColor={getProgressColor(Number(value))}
showInfo
/>
),
},
{
label: 'MEMORY USAGE',
getValue: (h): number => h.memory * 100,
render: (value): React.ReactNode => (
<Progress
percent={Number(Number(value).toFixed(1))}
strokeColor={getMemoryProgressColor(Number(value))}
showInfo
/>
),
},
];
export function getHostMetricsQueryPayload(
host: HostData,
start: number,
end: number,
dotMetricsEnabled: boolean,
): ReturnType<typeof getHostQueryPayload> {
return getHostQueryPayload(host.hostName, start, end, dotMetricsEnabled);
}
export { hostWidgetInfo };
export function hostGetSelectedItemFilters(
selectedItem: string,
dotMetricsEnabled: boolean,
): TagFilter {
const hostKey = dotMetricsEnabled ? 'host.name' : 'host_name';
return {
op: 'AND',
items: [createFilterItem(hostKey, selectedItem)],
};
}
export function hostInitialLogTracesFilter(
host: HostData,
dotMetricsEnabled: boolean,
): TagFilterItem[] {
const hostKey = dotMetricsEnabled ? 'host.name' : 'host_name';
return [createFilterItem(hostKey, host.hostName || '')];
}
export function hostInitialEventsFilter(_host: HostData): TagFilterItem[] {
return [];
}
export const hostGetEntityName = (host: HostData): string => host.hostName;
export async function fetchHostListData(
filters: K8sBaseFilters,
signal?: AbortSignal,
): Promise<{
data: HostData[];
total: number;
error?: string | null;
rawData?: unknown;
}> {
const baseQuery = getHostListsQuery();
const payload: HostListPayload = {
...baseQuery,
limit: filters.limit,
offset: filters.offset,
filters: filters.filters ?? { items: [], op: 'and' },
orderBy: filters.orderBy,
start: filters.start,
end: filters.end,
groupBy: filters.groupBy ?? [],
};
const response = await getHostLists(payload, signal);
return {
data: response.payload?.data?.records || [],
total: response.payload?.data?.total || 0,
error: response.error,
rawData: response.payload?.data,
};
}
export async function fetchHostEntityData(
filters: K8sDetailsFilters,
signal?: AbortSignal,
): Promise<{ data: HostData | null; error?: string | null }> {
const response = await getHostLists(
{
...getHostListsQuery(),
filters: filters.filters,
start: filters.start,
end: filters.end,
limit: 1,
offset: 0,
groupBy: [],
},
signal,
);
const records = response.payload?.data?.records || [];
return {
data: records.length > 0 ? records[0] : null,
error: response.error,
};
}

View File

@@ -1,69 +0,0 @@
import { useEffect } from 'react';
import * as Sentry from '@sentry/react';
import { initialQueriesMap, PANEL_TYPES } from 'constants/queryBuilder';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import ErrorBoundaryFallback from 'pages/ErrorBoundaryFallback/ErrorBoundaryFallback';
import { DataSource } from 'types/common/queryBuilder';
import Hosts from './Hosts';
function InfraMonitoringHosts(): JSX.Element {
const {
updateAllQueriesOperators,
handleSetConfig,
setSupersetQuery,
setLastUsedQuery,
currentQuery,
resetQuery,
} = useQueryBuilder();
useEffect(() => {
const newQuery = updateAllQueriesOperators(
initialQueriesMap.metrics,
PANEL_TYPES.TIME_SERIES,
DataSource.METRICS,
);
setSupersetQuery(newQuery);
setLastUsedQuery(0);
handleSetConfig(PANEL_TYPES.TIME_SERIES, DataSource.METRICS);
return (): void => {
setLastUsedQuery(0);
};
}, [
updateAllQueriesOperators,
setSupersetQuery,
setLastUsedQuery,
handleSetConfig,
]);
useEffect(() => {
const updatedCurrentQuery = {
...currentQuery,
builder: {
...currentQuery.builder,
queryData: [
{
...currentQuery.builder.queryData[0],
filters: {
items: [],
op: 'AND',
},
},
],
},
};
resetQuery(updatedCurrentQuery);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
return (
<Sentry.ErrorBoundary fallback={<ErrorBoundaryFallback />}>
<Hosts />
</Sentry.ErrorBoundary>
);
}
export default InfraMonitoringHosts;

View File

@@ -1,201 +0,0 @@
import React from 'react';
import { Tooltip } from 'antd';
import { Badge } from '@signozhq/ui/badge';
import { HostData } from 'api/infraMonitoring/getHostLists';
import TanStackTable, { TableColumnDef } from 'components/TanStackTableView';
import { getGroupByEl } from 'container/InfraMonitoringK8sV2/Base/utils';
import {
EntityProgressBar,
ExpandButtonWrapper,
ValidateColumnValueWrapper,
} from 'container/InfraMonitoringK8sV2/components';
import { InfraMonitoringEntity } from 'container/InfraMonitoringK8sV2/constants';
import { useInfraMonitoringGroupBy } from 'container/InfraMonitoringK8sV2/hooks';
import EntityGroupHeader from 'container/InfraMonitoringK8sV2/Base/EntityGroupHeader';
import { HostnameCell } from './utils';
import styles from './table.module.scss';
import { Container, Info } from '@signozhq/icons';
function hostRowSource(host: HostData): { meta: Record<string, string> } {
return {
meta: {
...(host.meta ?? {}),
host_name: host.hostName ?? '',
'host.name': host.hostName ?? '',
os_type: host.os ?? '',
'os.type': host.os ?? '',
},
};
}
export function getHostRowKey(host: HostData): string {
return host.hostName || 'unknown';
}
export function getHostItemKey(host: HostData): string {
return host.hostName ?? '';
}
function HostGroupCell({ row }: { row: HostData }): JSX.Element {
const [groupBy] = useInfraMonitoringGroupBy();
const synthetic = hostRowSource(row);
return getGroupByEl(synthetic, groupBy) as JSX.Element;
}
export const hostColumnsConfig: TableColumnDef<HostData>[] = [
{
id: 'hostGroup',
header: (): React.ReactNode => <EntityGroupHeader title="HOST GROUP" />,
accessorFn: (row): string => row.hostName ?? '',
width: { min: 300 },
enableSort: false,
enableRemove: false,
enableMove: false,
pin: 'left',
visibilityBehavior: 'hidden-on-collapse',
cell: ({ row, isExpanded, toggleExpanded }): React.ReactNode => (
<ExpandButtonWrapper isExpanded={isExpanded} toggleExpanded={toggleExpanded}>
<HostGroupCell row={row} />
</ExpandButtonWrapper>
),
},
{
id: 'hostName',
header: (): React.ReactNode => (
<EntityGroupHeader title="Hostname" icon={<Container size={14} />} />
),
accessorFn: (row): string => row.hostName ?? '',
width: { min: 290 },
enableSort: false,
enableRemove: false,
enableMove: false,
pin: 'left',
visibilityBehavior: 'hidden-on-expand',
cell: ({ value }): React.ReactNode => (
<HostnameCell hostName={value as string} />
),
},
{
id: 'active',
header: (): React.ReactNode => (
<div className={styles.statusHeader}>
Status
<Tooltip title="Sent system metrics in last 10 mins">
<Info size="md" />
</Tooltip>
</div>
),
accessorFn: (row): boolean => row.active,
width: { min: 150, default: 150 },
enableSort: false,
cell: ({ value }): React.ReactNode => {
const active = value as boolean;
return (
<Badge
className={`${styles.statusTag} ${
active ? styles.statusTagActive : styles.statusTagInactive
}`}
>
{active ? 'ACTIVE' : 'INACTIVE'}
</Badge>
);
},
},
{
id: 'cpu',
header: (): React.ReactNode => (
<div className={styles.columnHeaderRight}>CPU Usage</div>
),
accessorFn: (row): number => row.cpu,
width: { min: 220 },
enableSort: true,
cell: ({ value }): React.ReactNode => {
const cpu = value as number;
return (
<div className={styles.progressContainer}>
<ValidateColumnValueWrapper
value={cpu}
entity={InfraMonitoringEntity.HOSTS}
attribute="CPU metric"
>
<EntityProgressBar value={cpu} type="cpu" />
</ValidateColumnValueWrapper>
</div>
);
},
},
{
id: 'memory',
header: (): React.ReactNode => (
<div className={`${styles.columnHeaderRight} ${styles.memoryUsageHeader}`}>
Memory Usage
<Tooltip title="Excluding cache memory">
<Info size="md" />
</Tooltip>
</div>
),
accessorFn: (row): number => row.memory,
width: { min: 220 },
enableSort: true,
cell: ({ value }): React.ReactNode => {
const memory = value as number;
return (
<div className={styles.progressContainer}>
<ValidateColumnValueWrapper
value={memory}
entity={InfraMonitoringEntity.HOSTS}
attribute="memory metric"
>
<EntityProgressBar value={memory} type="memory" />
</ValidateColumnValueWrapper>
</div>
);
},
},
{
id: 'wait',
header: (): React.ReactNode => (
<div className={styles.columnHeaderRight}>IOWait</div>
),
accessorFn: (row): number => row.wait,
width: { min: 100, default: 100 },
enableSort: true,
cell: ({ value }): React.ReactNode => {
const wait = value as number;
return (
<ValidateColumnValueWrapper
value={wait}
entity={InfraMonitoringEntity.HOSTS}
attribute="IOWait metric"
>
<TanStackTable.Text>{`${Number((wait * 100).toFixed(1))}%`}</TanStackTable.Text>
</ValidateColumnValueWrapper>
);
},
},
{
id: 'load15',
header: (): React.ReactNode => (
<div className={styles.columnHeaderRight}>Load Avg</div>
),
accessorFn: (row): number => row.load15,
width: { min: 100, default: 100 },
enableSort: true,
cell: ({ value }): React.ReactNode => {
const load15 = value as number;
return (
<ValidateColumnValueWrapper
value={load15}
entity={InfraMonitoringEntity.HOSTS}
attribute="load average metric"
>
<TanStackTable.Text>{load15}</TanStackTable.Text>
</ValidateColumnValueWrapper>
);
},
},
];

View File

@@ -1,68 +0,0 @@
.entityGroupHeader {
padding-left: var(--spacing-5);
gap: var(--spacing-5);
display: flex;
align-items: center;
}
.hostnameColumnHeader {
display: block;
}
.statusHeader {
display: inline-flex;
align-items: center;
gap: var(--spacing-2);
width: 100%;
}
.columnHeaderRight {
text-align: right;
}
.memoryUsageHeader {
display: inline-flex;
align-items: center;
justify-content: flex-end;
gap: var(--spacing-2);
width: 100%;
}
.statusTag {
width: fit-content;
font-family: Inter, sans-serif;
font-size: 11px;
font-style: normal;
font-weight: 500;
line-height: 18px;
letter-spacing: 0.44px;
text-transform: uppercase;
border-radius: 50px;
padding: 2px 8px;
}
.statusTagActive {
color: var(--Forest-500, #25e192);
border: 1px solid rgba(37, 225, 146, 0.2);
background: rgba(37, 225, 146, 0.1);
}
.statusTagInactive {
color: var(--Slate-50, #62687c);
border: 1px solid rgba(98, 104, 124, 0.2);
background: rgba(98, 104, 124, 0.1);
}
.progressContainer {
display: flex;
align-items: center;
& > div {
width: 100%;
}
}
.progressBar {
flex: 1;
margin-right: 8px;
}

View File

@@ -1,156 +0,0 @@
import React from 'react';
import { Color } from '@signozhq/design-tokens';
import { Tooltip } from 'antd';
import { Typography } from '@signozhq/ui/typography';
import { HostListPayload } from 'api/infraMonitoring/getHostLists';
import {
FiltersType,
IQuickFiltersConfig,
} from 'components/QuickFilters/types';
import { TriangleAlert } from '@signozhq/icons';
import { DataTypes } from 'types/api/queryBuilder/queryAutocompleteResponse';
import { DataSource } from 'types/common/queryBuilder';
const HOSTNAME_DOCS_URL =
'https://signoz.io/docs/infrastructure-monitoring/hostmetrics/#host-name-is-blankempty';
export function HostnameCell({
hostName,
}: {
hostName?: string | null;
}): React.ReactElement {
const isEmpty = !hostName || !hostName.trim();
if (!isEmpty) {
return <div className="hostname-column-value">{hostName}</div>;
}
return (
<div className="hostname-cell-missing">
<Typography.Text color="muted" className="hostname-cell-placeholder">
-
</Typography.Text>
<Tooltip
title={
<div>
Missing host.name metadata.
<br />
<a
href={HOSTNAME_DOCS_URL}
target="_blank"
rel="noopener noreferrer"
onClick={(e): void => e.stopPropagation()}
>
Learn how to configure
</a>
</div>
}
trigger={['hover', 'focus']}
>
<span
className="hostname-cell-warning-icon"
tabIndex={0}
role="img"
aria-label="Missing host.name metadata"
onClick={(e): void => e.stopPropagation()}
onKeyDown={(e): void => {
if (e.key === 'Enter' || e.key === ' ') {
e.stopPropagation();
}
}}
>
<TriangleAlert size={14} color={Color.BG_CHERRY_500} />
</span>
</Tooltip>
</div>
);
}
export const getHostListsQuery = (): HostListPayload => ({
filters: {
items: [],
op: 'and',
},
groupBy: [],
orderBy: { columnName: 'cpu', order: 'desc' },
});
export const HostsQuickFiltersConfig: IQuickFiltersConfig[] = [
{
type: FiltersType.CHECKBOX,
title: 'Host Name',
attributeKey: {
key: 'host_name',
dataType: DataTypes.String,
type: 'resource',
},
aggregateOperator: 'noop',
aggregateAttribute: 'system_cpu_load_average_15m',
dataSource: DataSource.METRICS,
defaultOpen: true,
},
{
type: FiltersType.CHECKBOX,
title: 'OS Type',
attributeKey: {
key: 'os_type',
dataType: DataTypes.String,
type: 'resource',
},
aggregateOperator: 'noop',
aggregateAttribute: 'system_cpu_load_average_15m',
dataSource: DataSource.METRICS,
defaultOpen: true,
},
];
export function getHostsQuickFiltersConfig(
dotMetricsEnabled: boolean,
): IQuickFiltersConfig[] {
const hostNameKey = dotMetricsEnabled ? 'host.name' : 'host_name';
const osTypeKey = dotMetricsEnabled ? 'os.type' : 'os_type';
const metricName = dotMetricsEnabled
? 'system.cpu.load_average.15m'
: 'system_cpu_load_average_15m';
const environmentKey = dotMetricsEnabled
? 'deployment.environment'
: 'deployment_environment';
return [
{
type: FiltersType.CHECKBOX,
title: 'Host Name',
attributeKey: {
key: hostNameKey,
dataType: DataTypes.String,
type: 'resource',
},
aggregateOperator: 'noop',
aggregateAttribute: metricName,
dataSource: DataSource.METRICS,
defaultOpen: true,
},
{
type: FiltersType.CHECKBOX,
title: 'OS Type',
attributeKey: {
key: osTypeKey,
dataType: DataTypes.String,
type: 'resource',
},
aggregateOperator: 'noop',
aggregateAttribute: metricName,
dataSource: DataSource.METRICS,
defaultOpen: true,
},
{
type: FiltersType.CHECKBOX,
title: 'Environment',
attributeKey: {
key: environmentKey,
dataType: DataTypes.String,
type: 'resource',
},
defaultOpen: true,
},
];
}

View File

@@ -1,5 +0,0 @@
.entityGroupHeader {
display: flex;
align-items: center;
gap: var(--spacing-5);
}

View File

@@ -1,21 +0,0 @@
import { Group } from '@signozhq/icons';
import styles from './EntityGroupHeader.module.scss';
interface EntityGroupHeaderProps {
title: string;
icon?: React.ReactNode;
}
function EntityGroupHeader({
title,
icon,
}: EntityGroupHeaderProps): JSX.Element {
return (
<div className={styles.entityGroupHeader}>
{icon || <Group size={14} data-hide-expanded="true" />} {title}
</div>
);
}
export default EntityGroupHeader;

View File

@@ -1,666 +0,0 @@
import React, {
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from 'react';
import { useQuery } from 'react-query';
// eslint-disable-next-line no-restricted-imports
import { Color, Spacing } from '@signozhq/design-tokens';
import { Button, Drawer, Tooltip } from 'antd';
import { ToggleGroupSimple } from '@signozhq/ui/toggle-group';
import { Divider } from '@signozhq/ui/divider';
import { Typography } from '@signozhq/ui/typography';
import logEvent from 'api/common/logEvent';
import { combineInitialAndUserExpression } from 'components/QueryBuilderV2/QueryV2/QuerySearch/utils';
import { convertFiltersToExpression } from 'components/QueryBuilderV2/utils';
import { InfraMonitoringEvents } from 'constants/events';
import { QueryParams } from 'constants/query';
import {
initialQueryBuilderFormValuesMap,
initialQueryState,
} from 'constants/queryBuilder';
import ROUTES from 'constants/routes';
import { DEFAULT_TIME_RANGE } from 'container/TopNav/DateTimeSelectionV2/constants';
import {
CustomTimeType,
Time,
} from 'container/TopNav/DateTimeSelectionV2/types';
import { useIsDarkMode } from 'hooks/useDarkMode';
import { GetQueryResultsProps } from 'lib/dashboard/getQueryResults';
import GetMinMax from 'lib/getMinMax';
import {
BarChart,
ChevronsLeftRight,
Compass,
DraftingCompass,
ScrollText,
X,
} from '@signozhq/icons';
import { isCustomTimeRange, useGlobalTimeStore } from 'store/globalTime';
import { NANO_SECOND_MULTIPLIER } from 'store/globalTime/utils';
import { DataTypes } from 'types/api/queryBuilder/queryAutocompleteResponse';
import {
TagFilter,
TagFilterItem,
} from 'types/api/queryBuilder/queryBuilderData';
import {
LogsAggregatorOperator,
TracesAggregatorOperator,
} from 'types/common/queryBuilder';
import { openInNewTab } from 'utils/navigation';
import { v4 as uuidv4 } from 'uuid';
import { InfraMonitoringEntity, VIEW_TYPES } from '../constants';
import EntityEvents from '../EntityDetailsUtils/EntityEvents';
import EntityLogs from '../EntityDetailsUtils/EntityLogs';
import { K8S_ENTITY_LOGS_EXPRESSION_KEY } from '../EntityDetailsUtils/EntityLogs/hooks';
import EntityMetrics from '../EntityDetailsUtils/EntityMetrics';
import EntityTraces from '../EntityDetailsUtils/EntityTraces';
import { K8S_ENTITY_TRACES_EXPRESSION_KEY } from '../EntityDetailsUtils/EntityTraces/hooks';
import {
useInfraMonitoringEventsFilters,
useInfraMonitoringLogFilters,
useInfraMonitoringSelectedItem,
useInfraMonitoringTracesFilters,
useInfraMonitoringView,
} from '../hooks';
import LoadingContainer from '../LoadingContainer';
import '../EntityDetailsUtils/entityDetails.styles.scss';
import { parseAsString, useQueryState } from 'nuqs';
const TimeRangeOffset = 1000000000;
export interface K8sDetailsMetadataConfig<T> {
label: string;
getValue: (entity: T) => string | number;
render?: (value: string | number, entity: T) => React.ReactNode;
}
export interface K8sDetailsFilters {
filters: TagFilter;
start: number;
end: number;
}
export interface K8sBaseDetailsProps<T> {
category: InfraMonitoringEntity;
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[];
metadataConfig: K8sDetailsMetadataConfig<T>[];
entityWidgetInfo: {
title: string;
yAxisUnit: string;
}[];
getEntityQueryPayload: (
entity: T,
start: number,
end: number,
dotMetricsEnabled: boolean,
) => GetQueryResultsProps[];
queryKeyPrefix: string;
/** When true, only metrics are shown and the Metrics/Logs/Traces/Events tab bar is hidden. */
hideDetailViewTabs?: boolean;
tabsConfig?: {
showMetrics?: boolean;
showLogs?: boolean;
showTraces?: boolean;
showEvents?: boolean;
};
customTabs?: Array<{
key: string;
label: string;
icon: React.ReactNode;
render: (props: {
entity: T;
timeRange: { startTime: number; endTime: number };
selectedInterval: Time;
handleTimeChange: (
interval: Time | CustomTimeType,
dateTimeRange?: [number, number],
) => void;
}) => React.ReactNode;
}>;
}
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
export default function K8sBaseDetails<T>({
category,
eventCategory,
getSelectedItemFilters,
fetchEntityData,
getEntityName,
getInitialLogTracesFilters,
getInitialEventsFilters,
metadataConfig,
entityWidgetInfo,
getEntityQueryPayload,
queryKeyPrefix,
hideDetailViewTabs = false,
tabsConfig,
customTabs,
}: K8sBaseDetailsProps<T>): JSX.Element {
const selectedTime = useGlobalTimeStore((s) => s.selectedTime);
const getMinMaxTime = useGlobalTimeStore((s) => s.getMinMaxTime);
const lastComputedMinMax = useGlobalTimeStore((s) => s.lastComputedMinMax);
const getAutoRefreshQueryKey = useGlobalTimeStore(
(s) => s.getAutoRefreshQueryKey,
);
const isDarkMode = useIsDarkMode();
const [selectedItem, setSelectedItem] = useInfraMonitoringSelectedItem();
const entityQueryKey = useMemo(
() =>
getAutoRefreshQueryKey(
selectedTime,
`${queryKeyPrefix}EntityDetails`,
selectedItem,
),
[queryKeyPrefix, selectedItem, selectedTime, getAutoRefreshQueryKey],
);
const {
data: entityResponse,
isLoading: isEntityLoading,
isError: isEntityError,
error: entityError,
} = 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 hasResponseError = !!entityResponse?.error;
const logsAndTracesInitialExpression = useMemo(() => {
if (!entity) {
return '';
}
const primaryFiltersOnly = {
op: 'AND' as const,
items: getInitialLogTracesFilters(entity),
};
return convertFiltersToExpression(primaryFiltersOnly).expression;
}, [entity, getInitialLogTracesFilters]);
const eventsInitialExpression = useMemo(() => {
if (!entity) {
return '';
}
const primaryFiltersOnly = {
op: 'AND' as const,
items: getInitialEventsFilters(entity),
};
return convertFiltersToExpression(primaryFiltersOnly).expression;
}, [entity, getInitialEventsFilters]);
const handleClose = useCallback((): void => {
setSelectedItem(null);
}, [setSelectedItem]);
const entityName = entity ? getEntityName(entity) : '';
// Content state (previously in K8sBaseDetailsContent)
const tabVisibility = useMemo(
() => ({
showMetrics: true,
showLogs: true,
showTraces: true,
showEvents: true,
...tabsConfig,
}),
[tabsConfig],
);
const { startMs, endMs } = useMemo(
() => ({
startMs: Math.floor(lastComputedMinMax.minTime / NANO_SECOND_MULTIPLIER),
endMs: Math.floor(lastComputedMinMax.maxTime / NANO_SECOND_MULTIPLIER),
}),
[lastComputedMinMax],
);
const [modalTimeRange, setModalTimeRange] = useState(() => ({
startTime: startMs,
endTime: endMs,
}));
// TODO(h4ad): Remove this and use context/zustand
const lastSelectedInterval = useRef<Time | null>(null);
const [selectedInterval, setSelectedInterval] = useState<Time>(
lastSelectedInterval.current
? lastSelectedInterval.current
: isCustomTimeRange(selectedTime)
? DEFAULT_TIME_RANGE
: selectedTime,
);
const [selectedView, setSelectedView] = useInfraMonitoringView();
const effectiveView = hideDetailViewTabs ? VIEW_TYPES.METRICS : selectedView;
const [, setLogFiltersParam] = useInfraMonitoringLogFilters();
const [, setTracesFiltersParam] = useInfraMonitoringTracesFilters();
const [, setEventsFiltersParam] = useInfraMonitoringEventsFilters();
const [userLogsExpression] = useQueryState(
K8S_ENTITY_LOGS_EXPRESSION_KEY,
parseAsString,
);
const [userTracesExpression] = useQueryState(
K8S_ENTITY_TRACES_EXPRESSION_KEY,
parseAsString,
);
useEffect(() => {
if (entity) {
logEvent(InfraMonitoringEvents.PageVisited, {
entity: InfraMonitoringEvents.K8sEntity,
page: InfraMonitoringEvents.DetailedPage,
category: eventCategory,
});
}
}, [entity, eventCategory]);
useEffect(() => {
const currentSelectedInterval = lastSelectedInterval.current || selectedTime;
if (!isCustomTimeRange(currentSelectedInterval)) {
setSelectedInterval(currentSelectedInterval);
const { minTime, maxTime } = getMinMaxTime();
setModalTimeRange({
startTime: Math.floor(minTime / TimeRangeOffset),
endTime: Math.floor(maxTime / TimeRangeOffset),
});
}
}, [getMinMaxTime, selectedTime]);
const handleTabChange = (value: string): void => {
setSelectedView(value);
setLogFiltersParam(null);
setTracesFiltersParam(null);
setEventsFiltersParam(null);
logEvent(InfraMonitoringEvents.TabChanged, {
entity: InfraMonitoringEvents.K8sEntity,
page: InfraMonitoringEvents.DetailedPage,
category: eventCategory,
view: 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 / TimeRangeOffset),
endTime: Math.floor(maxTime / TimeRangeOffset),
});
}
logEvent(InfraMonitoringEvents.TimeUpdated, {
entity: InfraMonitoringEvents.K8sEntity,
page: InfraMonitoringEvents.DetailedPage,
category: eventCategory,
interval,
view: effectiveView,
});
},
[eventCategory, effectiveView],
);
const handleExplorePagesRedirect = (): void => {
const urlQuery = new URLSearchParams();
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: eventCategory,
view: selectedView,
});
if (selectedView === VIEW_TYPES.LOGS) {
const fullExpression = combineInitialAndUserExpression(
logsAndTracesInitialExpression,
userLogsExpression || '',
);
const compositeQuery = {
...initialQueryState,
queryType: 'builder',
builder: {
...initialQueryState.builder,
queryData: [
{
...initialQueryBuilderFormValuesMap.logs,
aggregateOperator: LogsAggregatorOperator.NOOP,
expression: fullExpression,
filter: { expression: fullExpression },
},
],
},
};
urlQuery.set('compositeQuery', JSON.stringify(compositeQuery));
openInNewTab(`${ROUTES.LOGS_EXPLORER}?${urlQuery.toString()}`);
} else if (selectedView === VIEW_TYPES.TRACES) {
const fullExpression = combineInitialAndUserExpression(
logsAndTracesInitialExpression,
userTracesExpression || '',
);
const compositeQuery = {
...initialQueryState,
queryType: 'builder',
builder: {
...initialQueryState.builder,
queryData: [
{
...initialQueryBuilderFormValuesMap.traces,
aggregateOperator: TracesAggregatorOperator.NOOP,
expression: fullExpression,
filter: { expression: fullExpression },
},
],
},
};
urlQuery.set('compositeQuery', JSON.stringify(compositeQuery));
openInNewTab(`${ROUTES.TRACES_EXPLORER}?${urlQuery.toString()}`);
}
};
return (
<Drawer
width="70%"
title={
<>
<Divider type="vertical" />
<Typography.Text className="title">
{entityName ||
((isEntityError || hasResponseError) &&
'Failed to load entity details') ||
(isEntityLoading && 'Loading...') ||
'-'}
</Typography.Text>
</>
}
placement="right"
onClose={handleClose}
open={!!selectedItem}
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 }} />}
>
{isEntityLoading && <LoadingContainer />}
{(isEntityError || hasResponseError) && (
<div className="entity-error-container">
<Typography.Text color="danger">
{entityResponse?.error ||
(entityError instanceof Error
? entityError.message
: 'Failed to load entity details')}
</Typography.Text>
</div>
)}
{entity && !isEntityLoading && !hasResponseError && (
<>
<div className="entity-detail-drawer__entity">
<div className="entity-details-grid">
<div className="labels-row">
{metadataConfig.map((config) => (
<Typography.Text
key={config.label}
color="muted"
className="entity-details-metadata-label"
>
{config.label}
</Typography.Text>
))}
</div>
<div className="values-row">
{metadataConfig.map((config) => {
const value = config.getValue(entity);
const displayValue = String(value);
return (
<Typography.Text
key={config.label}
className="entity-details-metadata-value"
>
{config.render ? (
config.render(value, entity)
) : (
<Tooltip title={displayValue}>{displayValue}</Tooltip>
)}
</Typography.Text>
);
})}
</div>
</div>
</div>
{!hideDetailViewTabs && (
<div className="views-tabs-container">
<ToggleGroupSimple
type="single"
className="views-tabs"
onChange={handleTabChange}
value={selectedView}
items={[
...(tabVisibility.showMetrics
? [
{
value: VIEW_TYPES.METRICS,
label: (
<div className="view-title">
<BarChart size={14} />
Metrics
</div>
),
},
]
: []),
...(tabVisibility.showLogs
? [
{
value: VIEW_TYPES.LOGS,
label: (
<div className="view-title">
<ScrollText size={14} />
Logs
</div>
),
},
]
: []),
...(tabVisibility.showTraces
? [
{
value: VIEW_TYPES.TRACES,
label: (
<div className="view-title">
<DraftingCompass size={14} />
Traces
</div>
),
},
]
: []),
...(tabVisibility.showEvents
? [
{
value: VIEW_TYPES.EVENTS,
label: (
<div className="view-title">
<ChevronsLeftRight size={14} />
Events
</div>
),
},
]
: []),
...(customTabs?.map((tab) => ({
value: tab.key,
label: (
<div className="view-title">
{tab.icon}
{tab.label}
</div>
),
})) ?? []),
]}
/>
{selectedView === VIEW_TYPES.LOGS && (
<Tooltip title="Go to Logs Explorer" placement="left">
<Button
icon={<Compass size={18} />}
className="compass-button"
onClick={handleExplorePagesRedirect}
/>
</Tooltip>
)}
{selectedView === VIEW_TYPES.TRACES && (
<Tooltip title="Go to Traces Explorer" placement="left">
<Button
icon={<Compass size={18} />}
className="compass-button"
onClick={handleExplorePagesRedirect}
/>
</Tooltip>
)}
</div>
)}
{effectiveView === VIEW_TYPES.METRICS && (
<EntityMetrics<T>
entity={entity}
selectedInterval={selectedInterval}
timeRange={modalTimeRange}
handleTimeChange={handleTimeChange}
isModalTimeSelection
entityWidgetInfo={entityWidgetInfo}
getEntityQueryPayload={getEntityQueryPayload}
category={category}
queryKey={`${queryKeyPrefix}Metrics`}
/>
)}
{effectiveView === VIEW_TYPES.LOGS && (
<EntityLogs
timeRange={modalTimeRange}
isModalTimeSelection
handleTimeChange={handleTimeChange}
selectedInterval={selectedInterval}
queryKey={`${queryKeyPrefix}Logs`}
category={category}
initialExpression={logsAndTracesInitialExpression}
/>
)}
{effectiveView === VIEW_TYPES.TRACES && (
<EntityTraces
timeRange={modalTimeRange}
isModalTimeSelection
handleTimeChange={handleTimeChange}
selectedInterval={selectedInterval}
queryKey={`${queryKeyPrefix}Traces`}
category={category}
initialExpression={logsAndTracesInitialExpression}
/>
)}
{effectiveView === VIEW_TYPES.EVENTS && tabVisibility.showEvents && (
<EntityEvents
timeRange={modalTimeRange}
isModalTimeSelection
handleTimeChange={handleTimeChange}
selectedInterval={selectedInterval}
category={category}
queryKey={`${queryKeyPrefix}Events`}
initialExpression={eventsInitialExpression}
/>
)}
{customTabs?.map((tab) =>
selectedView === tab.key ? (
<React.Fragment key={tab.key}>
{tab.render({
entity,
timeRange: modalTimeRange,
selectedInterval,
handleTimeChange,
})}
</React.Fragment>
) : null,
)}
</>
)}
</Drawer>
);
}

View File

@@ -1,40 +0,0 @@
.emptyStateContainer {
display: flex;
flex: 1;
align-items: center;
justify-content: center;
min-height: 400px;
}
.k8SListTable {
padding-left: var(--spacing-2);
--tanstack-table-header-cell-bg: var(--l2-background);
--tanstack-table-header-cell-color: var(--l2-foreground);
--tanstack-table-cell-bg: var(--l2-background);
--tanstack-table-cell-color: var(--l2-foreground);
--tanstack-table-row-hover-bg: var(--l2-background-hover);
--tanstack-table-row-active-bg: var(--l2-background-active);
--tanstack-table-resize-handle-bg: var(--l2-background);
--tanstack-table-resize-handle-hover-bg: var(--l2-border);
--tanstack-table-row-height: 42px;
--tanstack-cell-padding-top-override: 5px;
--tanstack-cell-padding-bottom-override: 5px;
--tanstack-cell-padding-left-override: 5px;
--tanstack-cell-padding-right-override: 5px;
--tanstack-expansion-first-col-padding-left: 30px;
&[data-has-group-by='false'] {
--tanstack-cell-padding-left-first-column: 28px;
}
}
.paginationContainer {
padding-bottom: var(--spacing-8);
padding-right: var(--spacing-8);
ul {
margin: 0;
}
}

View File

@@ -1,297 +0,0 @@
import { useCallback, useEffect, useMemo } from 'react';
import { useQuery } from 'react-query';
import { Typography } from '@signozhq/ui/typography';
import logEvent from 'api/common/logEvent';
import TanStackTable, {
TableColumnDef,
useHiddenColumnIds,
} from 'components/TanStackTableView';
import { InfraMonitoringEvents } from 'constants/events';
import { parseAsString, useQueryState } from 'nuqs';
import { useGlobalTimeStore } from 'store/globalTime';
import { NANO_SECOND_MULTIPLIER } from 'store/globalTime/utils';
import { openInNewTab } from 'utils/navigation';
import {
INFRA_MONITORING_K8S_PARAMS_KEYS,
InfraMonitoringEntity,
} from '../constants';
import {
useInfraMonitoringFiltersK8s,
useInfraMonitoringGroupBy,
useInfraMonitoringOrderBy,
useInfraMonitoringPageListing,
useInfraMonitoringPageSizeListing,
} from '../hooks';
import { K8sEmptyState } from './K8sEmptyState';
import { K8sExpandedRow } from './K8sExpandedRow';
import K8sHeader from './K8sHeader';
import { K8sBaseFilters } from './types';
import { getGroupedByMeta } from './utils';
import styles from './K8sBaseList.module.scss';
import cx from 'classnames';
export type K8sBaseListEmptyStateContext = {
isError: boolean;
error?: string | null;
totalCount: number;
hasFilters: boolean;
isLoading: boolean;
rawData?: unknown;
};
/** Base type constraint for K8s entity data */
export type K8sEntityData = { meta?: Record<string, string> };
export type K8sBaseListProps<T extends K8sEntityData> = {
controlListPrefix?: React.ReactNode;
entity: InfraMonitoringEntity;
tableColumns: TableColumnDef<T>[];
fetchListData: (
filters: K8sBaseFilters,
signal?: AbortSignal,
) => Promise<{
data: T[];
total: number;
error?: string | null;
rawData?: unknown;
}>;
/** Function to get the unique key for a row. */
getRowKey?: (record: T) => string;
/** Function to get the item key used for selection. Defaults to getRowKey if not provided. */
getItemKey?: (record: T) => string;
eventCategory: InfraMonitoringEvents;
renderEmptyState?: (
context: K8sBaseListEmptyStateContext,
) => React.ReactNode | null;
};
export function K8sBaseList<T extends K8sEntityData>({
controlListPrefix,
entity,
tableColumns,
fetchListData,
getRowKey,
getItemKey,
eventCategory,
renderEmptyState,
}: K8sBaseListProps<T>): JSX.Element {
const [queryFilters] = useInfraMonitoringFiltersK8s();
const [currentPage] = useInfraMonitoringPageListing();
const [currentPageSize] = useInfraMonitoringPageSizeListing();
const [groupBy] = useInfraMonitoringGroupBy();
const [orderBy] = useInfraMonitoringOrderBy();
const [selectedItem, setSelectedItem] = useQueryState(
'selectedItem',
parseAsString,
);
const columnStorageKey = `k8s-${entity}-columns`;
const hiddenColumnIds = useHiddenColumnIds(columnStorageKey);
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 getAutoRefreshQueryKey = useGlobalTimeStore(
(s) => s.getAutoRefreshQueryKey,
);
const queryKey = useMemo(() => {
return getAutoRefreshQueryKey(
selectedTime,
'k8sBaseList',
entity,
String(currentPageSize),
String(currentPage),
JSON.stringify(queryFilters),
JSON.stringify(orderBy),
JSON.stringify(groupBy),
);
}, [
getAutoRefreshQueryKey,
selectedTime,
entity,
currentPageSize,
currentPage,
queryFilters,
orderBy,
groupBy,
]);
const { data, isLoading, isError } = useQuery({
queryKey,
queryFn: ({ signal }) => {
const { minTime, maxTime } = getMinMaxTime();
return fetchListData(
{
limit: currentPageSize,
offset: (currentPage - 1) * currentPageSize,
filters: queryFilters || { items: [], op: 'AND' },
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 hasFilters = (queryFilters?.items?.length ?? 0) > 0;
const getGroupKeyFn = useCallback(
(item: T) => getGroupedByMeta(item, groupBy),
[groupBy],
);
useEffect(() => {
logEvent(InfraMonitoringEvents.PageVisited, {
entity: InfraMonitoringEvents.K8sEntity,
page: InfraMonitoringEvents.ListPage,
category: eventCategory,
total: totalCount,
});
}, [eventCategory, totalCount]);
const handleRowClick = useCallback(
(_record: T, itemKey: string): void => {
if (groupBy.length === 0) {
setSelectedItem(itemKey);
}
logEvent(InfraMonitoringEvents.ItemClicked, {
entity: InfraMonitoringEvents.K8sEntity,
page: InfraMonitoringEvents.ListPage,
category: eventCategory,
});
},
[eventCategory, groupBy.length, setSelectedItem],
);
const handleRowClickNewTab = useCallback(
(_record: T, itemKey: string): void => {
if (groupBy.length > 0) {
return;
}
// Build URL with selectedItem param
const url = new URL(window.location.href);
url.searchParams.set('selectedItem', itemKey);
openInNewTab(url.pathname + url.search);
logEvent(InfraMonitoringEvents.ItemClicked, {
entity: InfraMonitoringEvents.K8sEntity,
page: InfraMonitoringEvents.ListPage,
category: eventCategory,
});
},
[eventCategory, groupBy.length],
);
const isGroupedByAttribute = groupBy.length > 0;
// Filter columns for expanded row based on parent's hidden columns
const expandedRowColumns = useMemo(
() => tableColumns.filter((col) => !hiddenColumnIds.includes(col.id)),
[tableColumns, hiddenColumnIds],
);
const renderExpandedRow = useCallback(
(
_record: T,
rowKey: string,
groupMeta?: Record<string, string>,
): JSX.Element => (
<K8sExpandedRow<T>
rowKey={rowKey}
groupMeta={groupMeta}
entity={entity}
tableColumns={expandedRowColumns}
fetchListData={fetchListData}
getRowKey={getRowKey}
getItemKey={getItemKey}
/>
),
[entity, fetchListData, getRowKey, getItemKey, expandedRowColumns],
);
const getRowCanExpand = useCallback(
(): boolean => isGroupedByAttribute,
[isGroupedByAttribute],
);
const showTableLoadingState = isLoading;
const emptyTableMessage: React.ReactNode = renderEmptyState?.({
isError,
error: data?.error,
totalCount,
hasFilters,
isLoading: showTableLoadingState,
rawData: data?.rawData,
}) || (
<K8sEmptyState
isError={isError}
error={data?.error}
isLoading={showTableLoadingState}
rawData={data?.rawData}
/>
);
const showEmptyState = !showTableLoadingState && pageData.length === 0;
return (
<>
<K8sHeader
controlListPrefix={controlListPrefix}
entity={entity}
showAutoRefresh={!selectedItem}
columns={tableColumns}
columnStorageKey={columnStorageKey}
/>
{isError && (
<Typography>{data?.error?.toString() || 'Something went wrong'}</Typography>
)}
{showEmptyState ? (
<div className={styles.emptyStateContainer}>{emptyTableMessage}</div>
) : (
<TanStackTable<T>
data={pageData}
columns={tableColumns}
columnStorageKey={columnStorageKey}
isLoading={showTableLoadingState}
getRowKey={getRowKey}
getItemKey={getItemKey}
groupBy={groupBy}
getGroupKey={getGroupKeyFn}
onRowClick={handleRowClick}
onRowClickNewTab={handleRowClickNewTab}
renderExpandedRow={isGroupedByAttribute ? renderExpandedRow : undefined}
getRowCanExpand={isGroupedByAttribute ? getRowCanExpand : undefined}
className={cx(styles.k8SListTable, expandedRowColumns)}
enableQueryParams={{
page: INFRA_MONITORING_K8S_PARAMS_KEYS.PAGE,
limit: INFRA_MONITORING_K8S_PARAMS_KEYS.PAGE_SIZE,
orderBy: INFRA_MONITORING_K8S_PARAMS_KEYS.ORDER_BY,
expanded: INFRA_MONITORING_K8S_PARAMS_KEYS.EXPANDED,
}}
pagination={{
total: totalCount,
defaultLimit: 10,
defaultPage: 1,
showTotalCount: true,
totalCountLabel: entity.charAt(0).toUpperCase() + entity.slice(1),
}}
paginationClassname={styles.paginationContainer}
/>
)}
</>
);
}

View File

@@ -1,56 +0,0 @@
.container {
height: 30vh;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
}
.content {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: var(--spacing-3);
width: fit-content;
max-width: 500px;
padding: 24px;
text-align: center;
}
.title {
font-weight: 500;
margin: 0;
font-size: var(--periscope-font-size-medium);
}
.message {
max-width: 400px;
}
.noDataMessage {
display: flex;
flex-direction: column;
gap: var(--spacing-1);
}
.emptyStateSvg {
width: 32px;
max-width: 100%;
}
.eyesEmoji {
height: 32px;
width: 32px;
}
.errorIcon {
color: var(--danger-background);
}
.actions {
display: flex;
align-items: center;
gap: 8px;
margin-top: 16px;
}

View File

@@ -1,146 +0,0 @@
import { useCallback } from 'react';
import { Button } from '@signozhq/ui/button';
import { useGetTenantLicense } from 'hooks/useGetTenantLicense';
import history from 'lib/history';
import { LifeBuoy, TriangleAlert } from '@signozhq/icons';
import emptyStateUrl from '@/assets/Icons/emptyState.svg';
import eyesEmojiUrl from '@/assets/Images/eyesEmoji.svg';
import type { K8sBaseListEmptyStateContext } from './K8sBaseList';
import styles from './K8sEmptyState.module.scss';
export interface K8sListResponseMetadata {
sentAnyHostMetricsData?: boolean;
isSendingK8SAgentMetrics?: boolean;
endTimeBeforeRetention?: boolean;
}
type K8sEmptyStateProps = Partial<K8sBaseListEmptyStateContext>;
const handleContactSupport = (isCloudUser: boolean): void => {
if (isCloudUser) {
history.push('/support');
} else {
window.open('https://signoz.io/slack', '_blank');
}
};
export function K8sEmptyState({
isError,
error,
isLoading,
rawData,
}: K8sEmptyStateProps): JSX.Element | null {
const { isCloudUser } = useGetTenantLicense();
const handleSupport = useCallback(() => {
handleContactSupport(isCloudUser);
}, [isCloudUser]);
if (isLoading) {
return null;
}
if (isError || error) {
return (
<div className={styles.container}>
<div className={styles.content}>
<TriangleAlert size={32} className={styles.errorIcon} />
<span className={styles.message}>
{error || 'An error occurred while fetching data.'}
</span>
<p>
Our team is getting on top to resolve this. Please reach out to support if
the issue persists.
</p>
<div className={styles.actions}>
<Button
onClick={handleSupport}
variant="solid"
color="secondary"
prefix={<LifeBuoy size={14} />}
>
Contact Support
</Button>
</div>
</div>
</div>
);
}
const metadata = rawData as K8sListResponseMetadata | undefined;
if (metadata?.sentAnyHostMetricsData === false) {
return (
<div className={styles.container}>
<div className={styles.content}>
<img className={styles.eyesEmoji} src={eyesEmojiUrl} alt="eyes emoji" />
<div className={styles.noDataMessage}>
<h5 className={styles.title}>No host metrics data received yet</h5>
<span className={styles.message}>
Please refer to{' '}
<a
href="https://signoz.io/docs/infrastructure-monitoring/hostmetrics/"
target="_blank"
rel="noreferrer"
>
our documentation
</a>{' '}
to learn how to send host metrics.
</span>
</div>
</div>
</div>
);
}
if (metadata?.isSendingK8SAgentMetrics) {
return (
<div className={styles.container}>
<div className={styles.content}>
<img className={styles.eyesEmoji} src={eyesEmojiUrl} alt="eyes emoji" />
<span className={styles.message}>
To see K8s metrics, upgrade to the latest version of SigNoz k8s-infra
chart. Please contact support if you need help.
</span>
</div>
</div>
);
}
if (metadata?.endTimeBeforeRetention) {
return (
<div className={styles.container}>
<div className={styles.content}>
<img className={styles.eyesEmoji} src={eyesEmojiUrl} alt="eyes emoji" />
<div className={styles.noDataMessage}>
<h5 className={styles.title}>
Queried time range is before earliest K8s metrics
</h5>
<span className={styles.message}>
Your requested end time is earlier than the earliest detected time of K8s
metrics data, please adjust your end time.
</span>
</div>
</div>
</div>
);
}
return (
<div className={styles.container}>
<div className={styles.content}>
<img
src={emptyStateUrl}
alt="empty-state"
className={styles.emptyStateSvg}
/>
<span className={styles.message}>
This query had no results. Edit your query and try again!
</span>
</div>
</div>
);
}

View File

@@ -1,35 +0,0 @@
.expandedTableContainer {
overflow-x: auto;
}
.expandedTable {
--tanstack-table-header-cell-bg: var(--l1-background);
--tanstack-table-header-cell-color: var(--l1-foreground);
--tanstack-table-cell-bg: var(--l1-background);
--tanstack-table-cell-color: var(--l1-foreground);
--tanstack-table-row-hover-bg: var(--l1-background-hover);
--tanstack-table-row-active-bg: var(--l1-background-active);
--tanstack-table-resize-handle-bg: var(--l1-background);
--tanstack-table-resize-handle-hover-bg: var(--l1-border);
--tanstack-table-row-height: 36px;
--tanstack-cell-padding-left-override: 15px;
--tanstack-cell-padding-right-override: 15px;
& [data-hide-expanded='true'] {
display: none;
}
}
.expandedTableFooter {
display: flex;
justify-content: flex-start;
gap: var(--spacing-4);
background-color: var(--l1-background);
}
.viewAllButton {
display: flex;
align-items: center;
margin: var(--spacing-4);
}

View File

@@ -1,238 +0,0 @@
import { useCallback, useEffect, useMemo } from 'react';
import { useQuery } from 'react-query';
import { Button } from '@signozhq/ui/button';
import { Typography } from '@signozhq/ui/typography';
import TanStackTable, {
SortState,
TableColumnDef,
TanStackTableStateProvider,
} from 'components/TanStackTableView';
import { CornerDownRight } from '@signozhq/icons';
import { useQueryState } from 'nuqs';
import { useGlobalTimeStore } from 'store/globalTime';
import { NANO_SECOND_MULTIPLIER } from 'store/globalTime/utils';
import { IBuilderQuery } from 'types/api/queryBuilder/queryBuilderData';
import { parseAsJsonNoValidate } from 'utils/nuqsParsers';
import { InfraMonitoringEntity } from '../constants';
import {
useInfraMonitoringFiltersK8s,
useInfraMonitoringGroupBy,
useInfraMonitoringOrderBy,
useInfraMonitoringPageListing,
useInfraMonitoringSelectedItem,
} from '../hooks';
import { K8sBaseFilters } from './types';
import styles from './K8sExpandedRow.module.scss';
const EXPANDED_ROW_LIMIT = 10;
export type K8sExpandedRowProps<T> = {
/** Pre-computed row key from parent table (includes group prefix + duplicate handling) */
rowKey: string;
/** Group metadata for building filters */
groupMeta?: Record<string, string>;
entity: InfraMonitoringEntity;
tableColumns: TableColumnDef<T>[];
fetchListData: (
filters: K8sBaseFilters,
signal?: AbortSignal,
) => Promise<{
data: T[];
total: number;
error?: string | null;
rawData?: unknown;
}>;
/** Function to get the unique key for a row. */
getRowKey?: (record: T) => string;
/** Function to get the item key used for selection. Defaults to getRowKey if not provided. */
getItemKey?: (record: T) => string;
};
export function K8sExpandedRow<T>({
rowKey,
groupMeta,
entity,
tableColumns,
fetchListData,
getRowKey,
getItemKey,
}: K8sExpandedRowProps<T>): JSX.Element {
const [, setGroupBy] = useInfraMonitoringGroupBy();
const [, setCurrentPage] = useInfraMonitoringPageListing();
const [queryFilters, setFilters] = useInfraMonitoringFiltersK8s();
const [, setSelectedItem] = useInfraMonitoringSelectedItem();
const [, setMainOrderBy] = useInfraMonitoringOrderBy();
const orderByParamKey = useMemo(
() => `orderBy_${rowKey.replace(/[^a-zA-Z0-9]/g, '_')}`,
[rowKey],
);
const [orderBy, setOrderBy] = useQueryState(
orderByParamKey,
parseAsJsonNoValidate<SortState | null>()
.withDefault(null as never)
.withOptions({
history: 'push',
}),
);
useEffect(() => {
return (): void => {
void setOrderBy(null);
};
}, [setOrderBy]);
const storageKey = `k8s-${entity}-columns-expanded`;
const createFiltersForRecord = useCallback((): NonNullable<
IBuilderQuery['filters']
> => {
const baseFilters: IBuilderQuery['filters'] = {
items: [...(queryFilters?.items || [])],
op: 'and',
};
const metaKeys = groupMeta ?? {};
for (const key of Object.keys(metaKeys)) {
const value = metaKeys[key];
// Skip empty values to avoid creating invalid filters
if (value === '' || value === undefined || value === null) {
continue;
}
baseFilters.items.push({
key: {
key,
type: 'resource',
},
op: '=',
value,
id: key,
});
}
return baseFilters;
}, [queryFilters?.items, groupMeta]);
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 getAutoRefreshQueryKey = useGlobalTimeStore(
(s) => s.getAutoRefreshQueryKey,
);
const queryKey = useMemo(() => {
return getAutoRefreshQueryKey(
selectedTime,
entity,
'k8sExpandedRow',
JSON.stringify(groupMeta),
rowKey,
JSON.stringify(queryFilters),
JSON.stringify(orderBy),
);
}, [
getAutoRefreshQueryKey,
selectedTime,
entity,
groupMeta,
rowKey,
queryFilters,
orderBy,
]);
const { data, isLoading, isError } = useQuery({
queryKey,
queryFn: async ({ signal }) => {
const { minTime, maxTime } = getMinMaxTime();
return await fetchListData(
{
limit: EXPANDED_ROW_LIMIT,
offset: 0,
filters: createFiltersForRecord(),
start: Math.floor(minTime / NANO_SECOND_MULTIPLIER),
end: Math.floor(maxTime / NANO_SECOND_MULTIPLIER),
orderBy: orderBy || undefined,
groupBy: undefined,
},
signal,
);
},
staleTime: 1000 * 60 * 30,
refetchInterval: isRefreshEnabled ? refreshInterval : false,
});
const expandedData = data?.data ?? [];
const handleRowClick = useCallback(
(_row: T, itemKey: string): void => {
setSelectedItem(itemKey);
},
[setSelectedItem],
);
const handleViewAllClick = (): void => {
const filters = createFiltersForRecord();
setGroupBy([]);
setCurrentPage(1);
setFilters(filters);
if (orderBy) {
setMainOrderBy(orderBy);
}
};
const total = data?.total ?? 0;
const hasMoreItems = total > EXPANDED_ROW_LIMIT;
const footerContent = hasMoreItems ? (
<Button
type="button"
color="secondary"
variant="outlined"
className={styles.viewAllButton}
onClick={handleViewAllClick}
prefix={<CornerDownRight size={14} />}
>
View All
</Button>
) : null;
return (
<div
className={styles.expandedTableContainer}
data-testid="expanded-table-container"
>
{isError && (
<Typography>{data?.error?.toString() || 'Something went wrong'}</Typography>
)}
<div data-testid="expanded-table">
<TanStackTableStateProvider>
<TanStackTable<T>
data={expandedData}
columns={tableColumns}
columnStorageKey={storageKey}
isLoading={isLoading}
getRowKey={getRowKey}
getItemKey={getItemKey}
onRowClick={handleRowClick}
enableQueryParams={{
orderBy: orderByParamKey,
}}
tableScrollerProps={{
className: styles.expandedTable,
}}
disableVirtualScroll
cellTypographySize="medium"
/>
</TanStackTableStateProvider>
{!isLoading && expandedData.length > 0 && (
<div className={styles.expandedTableFooter}>{footerContent}</div>
)}
</div>
</div>
);
}

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,142 +0,0 @@
import { useMemo } from 'react';
import { Button } from '@signozhq/ui/button';
import { DrawerWrapper } from '@signozhq/ui/drawer';
import {
hideColumn,
showColumn,
TableColumnDef,
useHiddenColumnIds,
} from 'components/TanStackTableView';
import styles from './K8sFiltersSidePanel.module.scss';
type ColumnPickerItem = {
id: string;
label: string;
canBeHidden: boolean;
visibilityBehavior:
| 'hidden-on-expand'
| 'hidden-on-collapse'
| 'always-visible';
};
/**
* Converts TableColumnDef to column picker item format
*/
function toColumnPickerItems<T>(
columns: TableColumnDef<T>[],
): ColumnPickerItem[] {
return columns.map((col) => ({
id: col.id,
label: typeof col.header === 'string' ? col.header : col.id,
canBeHidden: col.canBeHidden !== false && col.enableRemove !== false,
visibilityBehavior: col.visibilityBehavior ?? 'always-visible',
}));
}
function K8sFiltersSidePanel<TData>({
open,
onClose,
columns,
storageKey,
}: {
open: boolean;
onClose: () => void;
columns: TableColumnDef<TData>[];
storageKey: string;
}): JSX.Element {
const columnPickerItems = useMemo(
() => toColumnPickerItems(columns),
[columns],
);
const hiddenColumnIds = useHiddenColumnIds(storageKey);
const addedColumns = useMemo(
() =>
columnPickerItems.filter(
(column) =>
!hiddenColumnIds.includes(column.id) &&
column.visibilityBehavior !== 'hidden-on-collapse',
),
[columnPickerItems, hiddenColumnIds],
);
const hiddenColumns = useMemo(
() =>
columnPickerItems.filter((column) => hiddenColumnIds.includes(column.id)),
[columnPickerItems, hiddenColumnIds],
);
const handleRemoveColumn = (columnId: string): void => {
hideColumn(storageKey, columnId);
};
const handleAddColumn = (columnId: string): void => {
showColumn(storageKey, columnId);
};
const drawerContent = (
<>
<div className={styles.columnsTitle}>Added Columns (Click to remove)</div>
<div className={styles.columnsList}>
{addedColumns.map((column) => (
<div className={styles.columnItem} key={column.id}>
<Button
variant="ghost"
color="none"
className={styles.columnItem}
disabled={!column.canBeHidden}
data-testid={`remove-column-${column.id}`}
onClick={(): void => handleRemoveColumn(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}>
{hiddenColumns.map((column) => (
<div className={styles.columnItem} key={column.id}>
<Button
variant="ghost"
color="none"
className={styles.columnItem}
data-can-be-added="true"
data-testid={`add-column-${column.id}`}
onClick={(): void => handleAddColumn(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,17 +0,0 @@
import { getGroupByEl } from './utils';
import { useInfraMonitoringGroupBy } from '../hooks';
interface K8sEntityWithMeta {
meta?: Record<string, string>;
}
function K8sGroupCell<T extends K8sEntityWithMeta>({
row,
}: {
row: T;
}): JSX.Element {
const [groupBy] = useInfraMonitoringGroupBy();
return getGroupByEl(row, groupBy) as JSX.Element;
}
export default K8sGroupCell;

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(--border) !important;
background-color: var(--l2-background) !important;
input {
font-size: 12px;
}
:global([data-slot='badge'] .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(--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,232 +0,0 @@
import React, { useCallback, useMemo, useState } from 'react';
import { Button } from '@signozhq/ui/button';
import { Select } from 'antd';
import logEvent from 'api/common/logEvent';
import { TableColumnDef } from 'components/TanStackTableView';
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 '@signozhq/icons';
import { useAppContext } from 'providers/App/App';
import { IBuilderQuery } from 'types/api/queryBuilder/queryBuilderData';
import { DataSource } from 'types/common/queryBuilder';
import {
GetK8sEntityToAggregateAttribute,
InfraMonitoringEntity,
} from '../constants';
import {
useInfraMonitoringFiltersK8s,
useInfraMonitoringGroupBy,
useInfraMonitoringPageListing,
} from '../hooks';
import K8sFiltersSidePanel from './K8sFiltersSidePanel';
import styles from './K8sHeader.module.scss';
interface K8sHeaderProps<TData> {
controlListPrefix?: React.ReactNode;
entity: InfraMonitoringEntity;
showAutoRefresh: boolean;
columns: TableColumnDef<TData>[];
columnStorageKey: string;
}
function K8sHeader<TData>({
controlListPrefix,
entity,
showAutoRefresh,
columns,
columnStorageKey,
}: K8sHeaderProps<TData>): 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] = useInfraMonitoringPageListing();
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"
data-testid="k8s-list-filters-button"
onClick={(): void => setIsFiltersSidePanelOpen(true)}
>
<SlidersHorizontal size={14} />
</Button>
</div>
<K8sFiltersSidePanel
open={isFiltersSidePanelOpen}
columns={columns}
storageKey={columnStorageKey}
onClose={onClickOutside}
/>
</div>
);
}
export default K8sHeader;

View File

@@ -1,912 +0,0 @@
import React from 'react';
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 { MemoryRouter as MemoryRouterV5 } from 'react-router-dom-v5-compat';
import { VirtuosoMockContext } from 'react-virtuoso';
import { TooltipProvider } from '@signozhq/ui/tooltip';
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { InfraMonitoringEvents } from 'constants/events';
import {
NuqsTestingAdapter,
OnUrlUpdateFunction,
UrlUpdateEvent,
} from 'nuqs/adapters/testing';
import { AppProvider } from 'providers/App/App';
import TimezoneProvider from 'providers/Timezone';
import store from 'store';
import { openInNewTab } from 'utils/navigation';
import { TableColumnDef } from 'components/TanStackTableView';
import { InfraMonitoringEntity } from '../../constants';
import { K8sBaseList, K8sBaseListProps, K8sEntityData } from '../K8sBaseList';
jest.mock('utils/navigation', () => ({
...jest.requireActual('utils/navigation'),
openInNewTab: jest.fn(),
}));
const openInNewTabMock = openInNewTab as jest.Mock;
// Mock Date.now to prevent flaky tests due to time-dependent values
const MOCK_NOW = 1700000000000; // Fixed timestamp
jest.spyOn(Date, 'now').mockReturnValue(MOCK_NOW);
// Mock DrawerWrapper to avoid CSS issues with jsdom
// SyntaxError: 'div#radix-:rbv,,._dialog__content_qf8bf_22 :focus' is not a valid selector
jest.mock('@signozhq/ui/drawer', () => {
const actual = jest.requireActual('@signozhq/ui/drawer');
return {
...actual,
DrawerWrapper: ({
open,
children,
title,
}: {
open: boolean;
children: React.ReactNode;
title: string;
onOpenChange?: (isOpen: boolean) => void;
}): JSX.Element | null =>
open ? (
<div data-testid="drawer-wrapper" data-title={title}>
{children}
</div>
) : null,
};
});
// Test data types that satisfy K8sEntityData constraint
type TestItemWithTitle = {
id: string;
title: string;
meta?: Record<string, string>;
};
type TestItem = { id: string; meta?: Record<string, string> };
type TestItemWithName = {
id: string;
name: string;
desc: string;
meta?: Record<string, string>;
};
type TestItemWithGroup = {
id: string;
name: string;
group: string;
meta?: Record<string, string>;
};
// Helper to create TanStack columns for tests
function createTestColumnsWithTitle(): TableColumnDef<TestItemWithTitle>[] {
return [
{
id: 'id',
header: (): React.ReactNode => 'Id',
accessorFn: (row): string => row.id,
cell: ({ value }): React.ReactNode => <>{value}</>,
enableSort: true,
},
{
id: 'title',
header: (): React.ReactNode => 'Title',
accessorFn: (row): string => row.title,
cell: ({ value }): React.ReactNode => <>{value}</>,
},
];
}
function createTestColumns(): TableColumnDef<TestItem>[] {
return [
{
id: 'id',
header: (): React.ReactNode => 'Id',
accessorFn: (row): string => row.id,
cell: ({ value }): React.ReactNode => <>{value}</>,
},
];
}
function createTestColumnsWithName(): TableColumnDef<TestItemWithName>[] {
return [
{
id: 'id',
header: (): React.ReactNode => 'Id',
accessorFn: (row): string => row.id,
cell: ({ value }): React.ReactNode => <>{value}</>,
},
{
id: 'name',
header: (): React.ReactNode => 'Name',
accessorFn: (row): string => row.name,
cell: ({ value }): React.ReactNode => <>{value}</>,
},
{
id: 'desc',
header: (): React.ReactNode => 'Description',
accessorFn: (row): string => row.desc,
cell: ({ value }): React.ReactNode => <>{value}</>,
},
];
}
function createTestColumnsWithGroup(): TableColumnDef<TestItemWithGroup>[] {
return [
{
id: 'id',
header: (): React.ReactNode => 'Id',
accessorFn: (row): string => row.id,
cell: ({ value }): React.ReactNode => <>{value}</>,
},
{
id: 'name',
header: (): React.ReactNode => 'Name',
accessorFn: (row): string => row.name,
cell: ({ value }): React.ReactNode => <>{value}</>,
},
{
id: 'group',
header: (): React.ReactNode => 'Group',
accessorFn: (row): string => row.group,
cell: ({ value }): React.ReactNode => <>{value}</>,
},
];
}
// eslint-disable-next-line @typescript-eslint/explicit-function-return-type
function renderComponent<T extends K8sEntityData>({
queryParams,
onUrlUpdate,
...props
}: K8sBaseListProps<T> & {
queryParams?: Record<string, string>;
onUrlUpdate?: OnUrlUpdateFunction;
}) {
const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: false,
},
},
});
return render(
<MemoryRouter>
<MemoryRouterV5>
<TimezoneProvider>
<QueryClientProvider client={queryClient}>
<AppProvider>
<Provider store={store}>
<NuqsTestingAdapter
searchParams={queryParams}
onUrlUpdate={onUrlUpdate}
>
<VirtuosoMockContext.Provider
value={{ viewportHeight: 800, itemHeight: 50 }}
>
<TooltipProvider>
<K8sBaseList {...props} />
</TooltipProvider>
</VirtuosoMockContext.Provider>
</NuqsTestingAdapter>
</Provider>
</AppProvider>
</QueryClientProvider>
</TimezoneProvider>
</MemoryRouterV5>
</MemoryRouter>,
);
}
describe('K8sBaseList', () => {
describe('with items in the list', () => {
const itemId = Math.random().toString(36).slice(7);
const itemId2 = Math.random().toString(36).slice(7);
const onUrlUpdateMock = jest.fn<void, [UrlUpdateEvent]>();
const fetchListDataMock = jest.fn<
ReturnType<K8sBaseListProps<TestItemWithTitle>['fetchListData']>,
Parameters<K8sBaseListProps<TestItemWithTitle>['fetchListData']>
>();
beforeEach(() => {
onUrlUpdateMock.mockClear();
fetchListDataMock.mockClear();
openInNewTabMock.mockClear();
fetchListDataMock.mockResolvedValue({
data: [
{ id: `PodId:${itemId}`, title: `PodTitle:${itemId}` },
{ id: `PodId:${itemId2}`, title: `PodTitle:${itemId2}` },
],
total: 25,
error: null,
});
renderComponent<TestItemWithTitle>({
onUrlUpdate: onUrlUpdateMock,
entity: InfraMonitoringEntity.PODS,
eventCategory: InfraMonitoringEvents.Pod,
fetchListData: fetchListDataMock,
tableColumns: createTestColumnsWithTitle(),
getRowKey: (row): string => row.id,
getItemKey: (row): string => row.id,
});
});
it('should render all the items in the list', async () => {
await waitFor(async () => {
await expect(
screen.findByText(`PodId:${itemId}`),
).resolves.toBeInTheDocument();
await expect(
screen.findByText(`PodTitle:${itemId}`),
).resolves.toBeInTheDocument();
await expect(
screen.findByText(`PodId:${itemId2}`),
).resolves.toBeInTheDocument();
await expect(
screen.findByText(`PodTitle:${itemId2}`),
).resolves.toBeInTheDocument();
});
});
it('should call fetchListData with default filters', async () => {
await waitFor(() => {
expect(fetchListDataMock).toHaveBeenCalled();
});
const [filters] = fetchListDataMock.mock.calls[0];
expect(filters.limit).toBe(10);
expect(filters.offset).toBe(0);
expect(filters.filters).toStrictEqual({ items: [], op: 'AND' });
expect(filters.groupBy).toBeUndefined();
expect(filters.orderBy).toBeUndefined();
});
it('should click to open the row details and update selectedItem in URL', async () => {
const user = userEvent.setup();
const firstRowEl = await screen.findByText(`PodId:${itemId}`);
await user.click(firstRowEl);
await waitFor(() => {
const selectedItem = onUrlUpdateMock.mock.calls
.map((call) => call[0].searchParams.get('selectedItem'))
.filter(Boolean)
.pop();
expect(selectedItem).toBe(`PodId:${itemId}`);
});
});
it('should update orderBy in URL when clicking sortable column header', async () => {
const user = userEvent.setup();
await waitFor(() => {
expect(screen.getByText(`PodId:${itemId}`)).toBeInTheDocument();
});
// TanStackTable renders a sort button with title attribute
const sortButton = screen.getByTitle('Id');
await user.click(sortButton);
await waitFor(() => {
const lastOrderBy = onUrlUpdateMock.mock.calls
.map((call) => call[0].searchParams.get('orderBy'))
.filter(Boolean)
.pop();
expect(lastOrderBy).toBeDefined();
const parsed = JSON.parse(lastOrderBy as string);
expect(parsed.columnName).toBe('id');
expect(parsed.order).toBe('asc');
});
});
it('should toggle sort order in URL on subsequent header clicks', async () => {
await waitFor(() => {
expect(screen.getByText(`PodId:${itemId}`)).toBeInTheDocument();
});
// Track orderBy calls
const getOrderByCalls = (): string[] =>
onUrlUpdateMock.mock.calls
.map((call) => call[0].searchParams.get('orderBy'))
.filter(Boolean) as string[];
// First click - should set ascending
const sortButton = screen.getByTitle('Id');
expect(sortButton).toHaveAttribute('data-sort', 'none');
fireEvent.click(sortButton);
// Wait for URL to show ascending
await waitFor(() => {
const calls = getOrderByCalls();
expect(calls.length).toBeGreaterThan(0);
const parsed = JSON.parse(calls[calls.length - 1]);
expect(parsed.order).toBe('asc');
});
// Wait for button to have ascending state
await waitFor(() => {
expect(screen.getByTitle('Id')).toHaveAttribute('data-sort', 'ascending');
});
const callsAfterFirstClick = getOrderByCalls().length;
// Verify only one button exists with title 'Id'
const allIdButtons = screen.getAllByTitle('Id');
expect(allIdButtons).toHaveLength(1);
// Second click - should set descending
const ascendingButton = screen.getByTitle('Id');
expect(ascendingButton).toHaveAttribute('data-sort', 'ascending');
fireEvent.click(ascendingButton);
// Wait for URL to show descending (must be a new call)
await waitFor(() => {
const calls = getOrderByCalls();
expect(calls.length).toBeGreaterThan(callsAfterFirstClick);
const parsed = JSON.parse(calls[calls.length - 1]);
expect(parsed.order).toBe('desc');
});
// Verify DOM updated
await waitFor(() => {
expect(screen.getByTitle('Id')).toHaveAttribute('data-sort', 'descending');
});
});
it('should update page in URL when clicking pagination', async () => {
const user = userEvent.setup();
await waitFor(() => {
expect(screen.getByText(`PodId:${itemId}`)).toBeInTheDocument();
});
// Find pagination navigation and page 2 button
const nav = screen.getByRole('navigation');
const page2Button = Array.from(nav.querySelectorAll('button')).find(
(btn) => btn.textContent?.trim() === '2',
);
if (!page2Button) {
throw new Error('Page 2 button not found in pagination');
}
await user.click(page2Button);
await waitFor(() => {
const lastPage = onUrlUpdateMock.mock.calls
.map((call) => call[0].searchParams.get('page'))
.filter(Boolean)
.pop();
expect(lastPage).toBe('2');
});
});
it('should open row in new tab when ctrl+click on row', async () => {
await waitFor(() => {
expect(screen.getByText(`PodId:${itemId}`)).toBeInTheDocument();
});
const firstRow = screen.getByText(`PodId:${itemId}`);
// Ctrl+click to open in new tab
fireEvent.click(firstRow, { ctrlKey: true });
await waitFor(() => {
expect(openInNewTabMock).toHaveBeenCalledTimes(1);
expect(openInNewTabMock).toHaveBeenCalledWith(
expect.stringContaining(`selectedItem=PodId%3A${itemId}`),
);
});
});
it('should open row in new tab when meta+click (cmd on Mac) on row', async () => {
await waitFor(() => {
expect(screen.getByText(`PodId:${itemId}`)).toBeInTheDocument();
});
const firstRow = screen.getByText(`PodId:${itemId}`);
// Meta+click (cmd on Mac) to open in new tab
fireEvent.click(firstRow, { metaKey: true });
await waitFor(() => {
expect(openInNewTabMock).toHaveBeenCalledTimes(1);
expect(openInNewTabMock).toHaveBeenCalledWith(
expect.stringContaining(`selectedItem=PodId%3A${itemId}`),
);
});
});
});
describe('with URL params (orderBy, groupBy, pagination)', () => {
const onUrlUpdateMock = jest.fn<void, [UrlUpdateEvent]>();
const fetchListDataMock = jest.fn<
ReturnType<K8sBaseListProps<TestItem>['fetchListData']>,
Parameters<K8sBaseListProps<TestItem>['fetchListData']>
>();
const groupByValue = [
{ key: 'k8s.namespace.name', dataType: 'string', type: 'resource' },
];
beforeEach(() => {
onUrlUpdateMock.mockClear();
fetchListDataMock.mockClear();
fetchListDataMock.mockResolvedValue({
data: [
{ id: 'namespace-default', meta: { 'k8s.namespace.name': 'default' } },
],
total: 50,
error: null,
});
renderComponent<TestItem>({
onUrlUpdate: onUrlUpdateMock,
entity: InfraMonitoringEntity.PODS,
eventCategory: InfraMonitoringEvents.Pod,
fetchListData: fetchListDataMock,
queryParams: {
orderBy: JSON.stringify({ columnName: 'cpu', order: 'desc' }),
groupBy: JSON.stringify(groupByValue),
page: '3',
},
tableColumns: createTestColumns(),
getRowKey: (row): string => row.id,
getItemKey: (row): string => row.id,
});
});
it('should call fetchListData with orderBy/groupBy/offset/limit from URL', async () => {
await waitFor(() => {
expect(fetchListDataMock).toHaveBeenCalled();
});
const [filters] = fetchListDataMock.mock.calls[0];
expect(filters.orderBy).toStrictEqual({ columnName: 'cpu', order: 'desc' });
expect(filters.groupBy).toStrictEqual(groupByValue);
expect(filters.offset).toBe(20); // (3 - 1) * 10 = 20
expect(filters.limit).toBe(10);
});
it('should render expand icons when groupBy is set', async () => {
await waitFor(() => {
expect(screen.getByText('namespace-default')).toBeInTheDocument();
});
const expandButtons = screen.getAllByRole('button');
expect(expandButtons.length).toBeGreaterThan(0);
});
it('should render data with groupBy params', async () => {
await waitFor(() => {
expect(screen.getByText('namespace-default')).toBeInTheDocument();
});
// Verify the call was made with correct groupBy
const callWithGroupBy = fetchListDataMock.mock.calls.find(
(c) => c[0].groupBy && c[0].groupBy.length > 0,
);
expect(callWithGroupBy).toBeDefined();
expect(callWithGroupBy?.[0].groupBy).toStrictEqual(groupByValue);
});
});
describe('with empty data', () => {
const fetchListDataMock = jest.fn<
ReturnType<K8sBaseListProps<TestItem>['fetchListData']>,
Parameters<K8sBaseListProps<TestItem>['fetchListData']>
>();
beforeEach(() => {
fetchListDataMock.mockClear();
fetchListDataMock.mockResolvedValue({
data: [],
total: 0,
error: null,
rawData: {
sentAnyHostMetricsData: true,
isSendingK8SAgentMetrics: false,
},
});
renderComponent<TestItem>({
entity: InfraMonitoringEntity.PODS,
eventCategory: InfraMonitoringEvents.Pod,
fetchListData: fetchListDataMock,
tableColumns: createTestColumns(),
getRowKey: (row): string => row.id,
getItemKey: (row): string => row.id,
});
});
it('should display empty state when no data is returned', async () => {
await waitFor(() => {
expect(screen.getByText(/This query had no results/i)).toBeInTheDocument();
});
});
it('should still call fetchListData', async () => {
await waitFor(() => {
expect(fetchListDataMock).toHaveBeenCalled();
});
});
});
describe('with error response', () => {
const fetchListDataMock = jest.fn<
ReturnType<K8sBaseListProps<TestItem>['fetchListData']>,
Parameters<K8sBaseListProps<TestItem>['fetchListData']>
>();
beforeEach(() => {
fetchListDataMock.mockClear();
fetchListDataMock.mockResolvedValue({
data: [],
total: 0,
error: 'Failed to fetch pods',
});
renderComponent<TestItem>({
entity: InfraMonitoringEntity.PODS,
eventCategory: InfraMonitoringEvents.Pod,
fetchListData: fetchListDataMock,
tableColumns: createTestColumns(),
getRowKey: (row): string => row.id,
getItemKey: (row): string => row.id,
});
});
it('should call fetchListData even when error occurs', async () => {
await waitFor(() => {
expect(fetchListDataMock).toHaveBeenCalled();
});
});
it('should display error message when data.error is set', async () => {
await waitFor(() => {
expect(screen.getByText(/Failed to fetch pods/i)).toBeInTheDocument();
});
});
});
describe('with no metrics data (sentAnyHostMetricsData=false)', () => {
const fetchListDataMock = jest.fn<
ReturnType<K8sBaseListProps<TestItem>['fetchListData']>,
Parameters<K8sBaseListProps<TestItem>['fetchListData']>
>();
beforeEach(() => {
fetchListDataMock.mockClear();
fetchListDataMock.mockResolvedValue({
data: [],
total: 0,
error: null,
rawData: {
sentAnyHostMetricsData: false,
isSendingK8SAgentMetrics: false,
},
});
renderComponent<TestItem>({
entity: InfraMonitoringEntity.PODS,
eventCategory: InfraMonitoringEvents.Pod,
fetchListData: fetchListDataMock,
tableColumns: createTestColumns(),
getRowKey: (row): string => row.id,
getItemKey: (row): string => row.id,
});
});
it('should display no metrics data message', async () => {
await waitFor(() => {
expect(
screen.getByText(/No host metrics data received yet/i),
).toBeInTheDocument();
});
});
it('should display link to documentation', async () => {
await waitFor(() => {
const link = screen.getByRole('link', { name: /our documentation/i });
expect(link).toBeInTheDocument();
expect(link).toHaveAttribute(
'href',
'https://signoz.io/docs/infrastructure-monitoring/hostmetrics/',
);
});
});
});
describe('with incorrect K8s agent metrics (isSendingK8SAgentMetrics=true)', () => {
const fetchListDataMock = jest.fn<
ReturnType<K8sBaseListProps<TestItem>['fetchListData']>,
Parameters<K8sBaseListProps<TestItem>['fetchListData']>
>();
beforeEach(() => {
fetchListDataMock.mockClear();
fetchListDataMock.mockResolvedValue({
data: [],
total: 0,
error: null,
rawData: {
sentAnyHostMetricsData: true,
isSendingK8SAgentMetrics: true,
},
});
renderComponent<TestItem>({
entity: InfraMonitoringEntity.PODS,
eventCategory: InfraMonitoringEvents.Pod,
fetchListData: fetchListDataMock,
tableColumns: createTestColumns(),
getRowKey: (row): string => row.id,
getItemKey: (row): string => row.id,
});
});
it('should display upgrade message', async () => {
await waitFor(() => {
expect(
screen.getByText(/upgrade to the latest version of SigNoz k8s-infra/i),
).toBeInTheDocument();
});
});
});
describe('with end time before retention (endTimeBeforeRetention=true)', () => {
const fetchListDataMock = jest.fn<
ReturnType<K8sBaseListProps<TestItem>['fetchListData']>,
Parameters<K8sBaseListProps<TestItem>['fetchListData']>
>();
beforeEach(() => {
fetchListDataMock.mockClear();
fetchListDataMock.mockResolvedValue({
data: [],
total: 0,
error: null,
rawData: {
sentAnyHostMetricsData: true,
isSendingK8SAgentMetrics: false,
endTimeBeforeRetention: true,
},
});
renderComponent<TestItem>({
entity: InfraMonitoringEntity.PODS,
eventCategory: InfraMonitoringEvents.Pod,
fetchListData: fetchListDataMock,
tableColumns: createTestColumns(),
getRowKey: (row): string => row.id,
getItemKey: (row): string => row.id,
});
});
it('should display time range before retention message', async () => {
await waitFor(() => {
expect(
screen.getByText(/Queried time range is before earliest K8s metrics/i),
).toBeInTheDocument();
expect(
screen.getByText(/please adjust your end time/i),
).toBeInTheDocument();
});
});
});
describe('column visibility based on TanStack columns', () => {
const fetchListDataMock = jest.fn<
ReturnType<K8sBaseListProps<TestItemWithName>['fetchListData']>,
Parameters<K8sBaseListProps<TestItemWithName>['fetchListData']>
>();
beforeEach(() => {
fetchListDataMock.mockClear();
fetchListDataMock.mockResolvedValue({
data: [{ id: 'item-1', name: 'Item 1', desc: 'Description 1' }],
total: 1,
error: null,
});
});
it('should show all columns defined in tableColumns', async () => {
renderComponent<TestItemWithName>({
entity: InfraMonitoringEntity.PODS,
eventCategory: InfraMonitoringEvents.Pod,
fetchListData: fetchListDataMock,
tableColumns: createTestColumnsWithName(),
getRowKey: (row): string => row.id,
getItemKey: (row): string => row.id,
});
await waitFor(() => {
expect(screen.getByText('item-1')).toBeInTheDocument();
});
// All columns should be visible
expect(
screen.getByRole('columnheader', { name: /id/i }),
).toBeInTheDocument();
expect(
screen.getByRole('columnheader', { name: /name/i }),
).toBeInTheDocument();
});
});
describe('column behavior with groupBy (expanded/collapsed)', () => {
const fetchListDataMock = jest.fn<
ReturnType<K8sBaseListProps<TestItemWithGroup>['fetchListData']>,
Parameters<K8sBaseListProps<TestItemWithGroup>['fetchListData']>
>();
beforeEach(() => {
fetchListDataMock.mockClear();
fetchListDataMock.mockResolvedValue({
data: [
{
id: 'item-1',
name: 'Item 1',
group: 'Group A',
meta: { 'k8s.namespace.name': 'default' },
},
],
total: 1,
error: null,
});
});
it('should show columns when NOT grouped', async () => {
renderComponent<TestItemWithGroup>({
entity: InfraMonitoringEntity.PODS,
eventCategory: InfraMonitoringEvents.Pod,
fetchListData: fetchListDataMock,
queryParams: {},
tableColumns: createTestColumnsWithGroup(),
getRowKey: (row): string => row.id,
getItemKey: (row): string => row.id,
});
await waitFor(() => {
expect(screen.getByText('item-1')).toBeInTheDocument();
});
// Columns should be visible
expect(
screen.getByRole('columnheader', { name: /id/i }),
).toBeInTheDocument();
});
it('should show columns when grouped', async () => {
const groupByValue = [
{ key: 'k8s.namespace.name', dataType: 'string', type: 'resource' },
];
renderComponent<TestItemWithGroup>({
entity: InfraMonitoringEntity.PODS,
eventCategory: InfraMonitoringEvents.Pod,
fetchListData: fetchListDataMock,
queryParams: {
groupBy: JSON.stringify(groupByValue),
},
tableColumns: createTestColumnsWithGroup(),
getRowKey: (row): string => row.id,
getItemKey: (row): string => row.id,
});
await waitFor(() => {
expect(screen.getByText('item-1')).toBeInTheDocument();
});
// Id should be visible
expect(
screen.getByRole('columnheader', { name: /id/i }),
).toBeInTheDocument();
});
});
describe('column visibility in expanded row (nested table)', () => {
const fetchListDataMock = jest.fn<
ReturnType<K8sBaseListProps<TestItem>['fetchListData']>,
Parameters<K8sBaseListProps<TestItem>['fetchListData']>
>();
const groupByValue = [
{ key: 'k8s.namespace.name', dataType: 'string', type: 'resource' },
];
beforeEach(() => {
fetchListDataMock.mockClear();
fetchListDataMock.mockResolvedValue({
data: [
{ id: 'namespace-default', meta: { 'k8s.namespace.name': 'default' } },
],
total: 50,
error: null,
});
renderComponent<TestItem>({
entity: InfraMonitoringEntity.PODS,
eventCategory: InfraMonitoringEvents.Pod,
fetchListData: fetchListDataMock,
queryParams: {
groupBy: JSON.stringify(groupByValue),
},
tableColumns: createTestColumns(),
getRowKey: (row): string => row.id,
getItemKey: (row): string => row.id,
});
});
it('should render table with groupBy params and enable expansion', async () => {
await waitFor(() => {
expect(screen.getByText('namespace-default')).toBeInTheDocument();
});
// Verify fetch was called with groupBy
const callWithGroupBy = fetchListDataMock.mock.calls.find(
(c) => c[0].groupBy && c[0].groupBy.length > 0,
);
expect(callWithGroupBy).toBeDefined();
});
});
describe('TanStack table column rendering', () => {
const fetchListDataMock = jest.fn<
ReturnType<K8sBaseListProps<TestItemWithName>['fetchListData']>,
Parameters<K8sBaseListProps<TestItemWithName>['fetchListData']>
>();
beforeEach(() => {
fetchListDataMock.mockClear();
fetchListDataMock.mockResolvedValue({
data: [
{ id: 'item-1', name: 'Item 1', desc: 'Description 1' },
{ id: 'item-2', name: 'Item 2', desc: 'Description 2' },
],
total: 2,
error: null,
});
});
it('should render all defined columns', async () => {
renderComponent<TestItemWithName>({
entity: InfraMonitoringEntity.PODS,
eventCategory: InfraMonitoringEvents.Pod,
fetchListData: fetchListDataMock,
tableColumns: createTestColumnsWithName(),
getRowKey: (row): string => row.id,
getItemKey: (row): string => row.id,
});
await waitFor(() => {
expect(screen.getByText('item-1')).toBeInTheDocument();
});
// All columns should be visible
expect(
screen.getByRole('columnheader', { name: /id/i }),
).toBeInTheDocument();
expect(
screen.getByRole('columnheader', { name: /^name$/i }),
).toBeInTheDocument();
});
it('should render data in cells correctly', async () => {
renderComponent<TestItemWithName>({
entity: InfraMonitoringEntity.PODS,
eventCategory: InfraMonitoringEvents.Pod,
fetchListData: fetchListDataMock,
tableColumns: createTestColumnsWithName(),
getRowKey: (row): string => row.id,
getItemKey: (row): string => row.id,
});
await waitFor(() => {
expect(screen.getByText('item-1')).toBeInTheDocument();
expect(screen.getByText('Item 1')).toBeInTheDocument();
expect(screen.getByText('item-2')).toBeInTheDocument();
expect(screen.getByText('Item 2')).toBeInTheDocument();
});
});
});
});

View File

@@ -1,26 +0,0 @@
import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
import { TagFilter } from 'types/api/queryBuilder/queryBuilderData';
import { OrderBySchemaType } from '../schemas';
export type K8sBaseFilters = {
filters: TagFilter;
groupBy?: BaseAutocompleteData[];
offset?: number;
limit?: number;
start: number;
end: number;
orderBy?: OrderBySchemaType;
};
/**
* Type for table row data with required key fields.
* Used when rendering raw data in the table.
*/
export type K8sTableRowData<T> = T & {
key: string;
id: string;
itemKey: string;
/** Metadata about which attributes were used for grouping */
groupedByMeta?: Record<string, string>;
};

View File

@@ -1,114 +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,100 +0,0 @@
import { Badge } from '@signozhq/ui/badge';
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> = {
'os.type': 'os_type',
'host.name': 'host_name',
'deployment.environment': 'deployment_environment',
'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',
'k8s.persistentvolumeclaim.name': 'k8s_persistentvolumeclaim_name',
};
export function getGroupedByMeta<T extends { meta?: Record<string, string> }>(
itemData: T,
groupBy: BaseAutocompleteData[],
): Record<string, string> {
const result: Record<string, string> = {};
const meta = itemData.meta ?? {};
groupBy.forEach((group) => {
const rawKey = group.key as string;
const metaKey = (dotToUnder[rawKey] ?? rawKey) as keyof typeof meta;
result[rawKey] = (meta[metaKey] || meta[rawKey]) ?? '';
});
return result;
}
export function getRowKey<T extends { meta?: Record<string, string> }>(
itemData: T,
getItemIdentifier: () => string,
groupBy: BaseAutocompleteData[],
): string {
const nodeIdentifier = getItemIdentifier();
const meta = itemData.meta ?? {};
if (groupBy.length === 0) {
return nodeIdentifier || JSON.stringify(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(meta);
}
export function getGroupByEl<T extends { meta?: Record<string, string> }>(
itemData: T,
groupBy: IBuilderQuery['groupBy'],
): React.ReactNode {
const groupByValues: string[] = [];
const meta = itemData.meta ?? {};
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 meta;
const value = meta[metaKey] || meta[rawKey] || '<no-value>';
groupByValues.push(value);
});
return (
<div className={styles.itemDataGroup}>
{groupByValues.map((value, index) => (
<Badge
// oxlint-disable-next-line react/no-array-index-key
key={`${index}-${value}`}
color="secondary"
className={styles.itemDataGroupTagItem}
>
{value === '' ? '<no-value>' : value}
</Badge>
))}
</div>
);
}

View File

@@ -1,117 +0,0 @@
import React, { useCallback } from 'react';
import { InfraMonitoringEvents } from 'constants/events';
import { FeatureKeys } from 'constants/features';
import { useAppContext } from 'providers/App/App';
import K8sBaseDetails, { K8sDetailsFilters } from '../Base/K8sBaseDetails';
import { K8sBaseList } from '../Base/K8sBaseList';
import { K8sBaseFilters } from '../Base/types';
import { InfraMonitoringEntity } from '../constants';
import { getK8sClustersList, K8sClusterData } from './api';
import {
clusterWidgetInfo,
getClusterMetricsQueryPayload,
k8sClusterDetailsMetadataConfig,
k8sClusterGetEntityName,
k8sClusterGetSelectedItemFilters,
k8sClusterInitialEventsFilter,
k8sClusterInitialLogTracesFilter,
} from './constants';
import {
getK8sClusterItemKey,
getK8sClusterRowKey,
k8sClustersColumnsConfig,
} from './table.config';
function K8sClustersList({
controlListPrefix,
}: {
controlListPrefix?: React.ReactNode;
}): JSX.Element {
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 getK8sClustersList(
filters,
signal,
undefined,
dotMetricsEnabled,
);
return {
data: response.payload?.data.records || [],
total: response.payload?.data.total || 0,
error: response.error,
rawData: response.payload?.data,
};
},
[dotMetricsEnabled],
);
const fetchEntityData = useCallback(
async (
filters: K8sDetailsFilters,
signal?: AbortSignal,
): Promise<{ data: K8sClusterData | null; error?: string | null }> => {
const response = await getK8sClustersList(
{
filters: filters.filters,
start: filters.start,
end: filters.end,
limit: 1,
offset: 0,
},
signal,
undefined,
dotMetricsEnabled,
);
const records = response.payload?.data.records || [];
return {
data: records.length > 0 ? records[0] : null,
error: response.error,
};
},
[dotMetricsEnabled],
);
return (
<>
<K8sBaseList<K8sClusterData>
controlListPrefix={controlListPrefix}
entity={InfraMonitoringEntity.CLUSTERS}
tableColumns={k8sClustersColumnsConfig}
fetchListData={fetchListData}
getRowKey={getK8sClusterRowKey}
getItemKey={getK8sClusterItemKey}
eventCategory={InfraMonitoringEvents.Cluster}
/>
<K8sBaseDetails<K8sClusterData>
category={InfraMonitoringEntity.CLUSTERS}
eventCategory={InfraMonitoringEvents.Cluster}
getSelectedItemFilters={k8sClusterGetSelectedItemFilters}
fetchEntityData={fetchEntityData}
getEntityName={k8sClusterGetEntityName}
getInitialLogTracesFilters={k8sClusterInitialLogTracesFilter}
getInitialEventsFilters={k8sClusterInitialEventsFilter}
metadataConfig={k8sClusterDetailsMetadataConfig}
entityWidgetInfo={clusterWidgetInfo}
getEntityQueryPayload={getClusterMetricsQueryPayload}
queryKeyPrefix="cluster"
/>
</>
);
}
export default K8sClustersList;

View File

@@ -1,125 +0,0 @@
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/types';
export interface K8sClustersListPayload {
filters: TagFilter;
groupBy?: BaseAutocompleteData[];
offset?: number;
limit?: number;
orderBy?: {
columnName: string;
order: 'asc' | 'desc';
};
}
export interface K8sClusterData {
clusterUID: string;
cpuUsage: number;
cpuAllocatable: number;
memoryUsage: number;
memoryAllocatable: number;
meta: {
k8s_cluster_name: string;
k8s_cluster_uid: string;
};
}
export interface K8sClustersListResponse {
status: string;
data: {
type: string;
records: K8sClusterData[];
groups: null;
total: number;
sentAnyHostMetricsData: boolean;
isSendingK8SAgentMetrics: boolean;
};
}
// TODO(H4ad): Erase this whole file when migrating to openapi
export const clustersMetaMap = [
{ dot: 'k8s.cluster.name', under: 'k8s_cluster_name' },
{ dot: 'k8s.cluster.uid', under: 'k8s_cluster_uid' },
] as const;
export function mapClustersMeta(
raw: Record<string, unknown>,
): K8sClusterData['meta'] {
const out: Record<string, unknown> = { ...raw };
clustersMetaMap.forEach(({ dot, under }) => {
if (dot in raw) {
const v = raw[dot];
out[under] = typeof v === 'string' ? v : raw[under];
}
});
return out as K8sClusterData['meta'];
}
export const getK8sClustersList = async (
props: K8sBaseFilters,
signal?: AbortSignal,
headers?: Record<string, string>,
dotMetricsEnabled = false,
): Promise<SuccessResponse<K8sClustersListResponse> | ErrorResponse> => {
try {
const requestProps = dotMetricsEnabled
? {
...props,
filters: {
...props.filters,
items: props.filters.items.reduce<typeof props.filters.items>(
(acc, item) => {
if (item.value === undefined) {
return acc;
}
if (
item.key &&
typeof item.key === 'object' &&
'key' in item.key &&
typeof item.key.key === 'string'
) {
const mappedKey = UnderscoreToDotMap[item.key.key] ?? item.key.key;
acc.push({
...item,
key: { ...item.key, key: mappedKey },
});
} else {
acc.push(item);
}
return acc;
},
[] as typeof props.filters.items,
),
},
}
: props;
const response = await axios.post('/clusters/list', requestProps, {
signal,
headers,
});
const payload: K8sClustersListResponse = response.data;
payload.data.records = payload.data.records.map((record) => ({
...record,
meta: mapClustersMeta(record.meta as Record<string, unknown>),
}));
return {
statusCode: 200,
error: null,
message: 'Success',
payload,
params: requestProps,
};
} catch (error) {
return ErrorResponseHandler(error as AxiosError);
}
};

View File

@@ -1,156 +0,0 @@
import { Tooltip } from 'antd';
import { TableColumnDef } from 'components/TanStackTableView';
import TanStackTable from 'components/TanStackTableView';
import { ExpandButtonWrapper } from 'container/InfraMonitoringK8sV2/components';
import EntityGroupHeader from '../Base/EntityGroupHeader';
import K8sGroupCell from '../Base/K8sGroupCell';
import { formatBytes } from '../commonUtils';
import { ValidateColumnValueWrapper } from '../components';
import { InfraMonitoringEntity } from '../constants';
import { K8sClusterData, K8sClustersListPayload } from './api';
import { Boxes } from '@signozhq/icons';
export function getK8sClusterRowKey(cluster: K8sClusterData): string {
return (
cluster.clusterUID ||
cluster.meta.k8s_cluster_uid ||
cluster.meta.k8s_cluster_name
);
}
export function getK8sClusterItemKey(cluster: K8sClusterData): string {
return cluster.meta.k8s_cluster_name;
}
export const getK8sClustersListQuery = (): K8sClustersListPayload => ({
filters: {
items: [],
op: 'and',
},
orderBy: { columnName: 'cpu', order: 'desc' },
});
export const k8sClustersColumnsConfig: TableColumnDef<K8sClusterData>[] = [
{
id: 'clusterGroup',
header: (): React.ReactNode => <EntityGroupHeader title="CLUSTER GROUP" />,
accessorFn: (row): string => row.meta.k8s_cluster_name || '',
width: { min: 300 },
enableSort: false,
enableRemove: false,
enableMove: false,
pin: 'left',
visibilityBehavior: 'hidden-on-collapse',
cell: ({ isExpanded, toggleExpanded, row }): JSX.Element | null => {
return (
<ExpandButtonWrapper
isExpanded={isExpanded}
toggleExpanded={toggleExpanded}
>
<K8sGroupCell row={row} />
</ExpandButtonWrapper>
);
},
},
{
id: 'clusterName',
header: (): React.ReactNode => (
<EntityGroupHeader
title="Cluster Name"
icon={<Boxes data-hide-expanded="true" size={14} />}
/>
),
accessorFn: (row): string => row.meta.k8s_cluster_name || '',
width: { min: 290 },
enableSort: false,
enableRemove: false,
enableMove: false,
pin: 'left',
visibilityBehavior: 'hidden-on-expand',
cell: ({ value }): React.ReactNode => {
const clusterName = value as string;
return (
<Tooltip title={clusterName}>
<TanStackTable.Text>{clusterName}</TanStackTable.Text>
</Tooltip>
);
},
},
{
id: 'cpu',
header: 'CPU Usage (cores)',
accessorFn: (row): number => row.cpuUsage,
width: { min: 220 },
enableSort: true,
cell: ({ value }): React.ReactNode => {
const cpu = value as number;
return (
<ValidateColumnValueWrapper
value={cpu}
entity={InfraMonitoringEntity.CLUSTERS}
attribute="CPU metric"
>
<TanStackTable.Text>{cpu}</TanStackTable.Text>
</ValidateColumnValueWrapper>
);
},
},
{
id: 'cpu_allocatable',
header: 'CPU Alloc (cores)',
accessorFn: (row): number => row.cpuAllocatable,
width: { min: 220 },
enableSort: true,
cell: ({ value }): React.ReactNode => {
const cpuAllocatable = value as number;
return (
<ValidateColumnValueWrapper
value={cpuAllocatable}
entity={InfraMonitoringEntity.CLUSTERS}
attribute="CPU allocatable metric"
>
<TanStackTable.Text>{cpuAllocatable}</TanStackTable.Text>
</ValidateColumnValueWrapper>
);
},
},
{
id: 'memory',
header: 'Memory Usage (WSS)',
accessorFn: (row): number => row.memoryUsage,
width: { min: 220 },
enableSort: true,
cell: ({ value }): React.ReactNode => {
const memory = value as number;
return (
<ValidateColumnValueWrapper
value={memory}
entity={InfraMonitoringEntity.CLUSTERS}
attribute="memory metric"
>
<TanStackTable.Text>{formatBytes(memory)}</TanStackTable.Text>
</ValidateColumnValueWrapper>
);
},
},
{
id: 'memory_allocatable',
header: 'Memory Allocatable',
accessorFn: (row): number => row.memoryAllocatable,
width: { min: 220 },
enableSort: true,
cell: ({ value }): React.ReactNode => {
const memoryAllocatable = value as number;
return (
<ValidateColumnValueWrapper
value={memoryAllocatable}
entity={InfraMonitoringEntity.CLUSTERS}
attribute="memory allocatable metric"
>
<TanStackTable.Text>{formatBytes(memoryAllocatable)}</TanStackTable.Text>
</ValidateColumnValueWrapper>
);
},
},
];

View File

@@ -1,117 +0,0 @@
import React, { useCallback } from 'react';
import { InfraMonitoringEvents } from 'constants/events';
import { FeatureKeys } from 'constants/features';
import { useAppContext } from 'providers/App/App';
import K8sBaseDetails, { K8sDetailsFilters } from '../Base/K8sBaseDetails';
import { K8sBaseList } from '../Base/K8sBaseList';
import { K8sBaseFilters } from '../Base/types';
import { InfraMonitoringEntity } from '../constants';
import { getK8sDaemonSetsList, K8sDaemonSetsData } from './api';
import {
daemonSetWidgetInfo,
getDaemonSetMetricsQueryPayload,
k8sDaemonSetDetailsMetadataConfig,
k8sDaemonSetGetEntityName,
k8sDaemonSetGetSelectedItemFilters,
k8sDaemonSetInitialEventsFilter,
k8sDaemonSetInitialLogTracesFilter,
} from './constants';
import {
getK8sDaemonSetItemKey,
getK8sDaemonSetRowKey,
k8sDaemonSetsColumnsConfig,
} from './table.config';
function K8sDaemonSetsList({
controlListPrefix,
}: {
controlListPrefix?: React.ReactNode;
}): JSX.Element {
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 getK8sDaemonSetsList(
filters,
signal,
undefined,
dotMetricsEnabled,
);
return {
data: response.payload?.data.records || [],
total: response.payload?.data.total || 0,
error: response.error,
rawData: response.payload?.data,
};
},
[dotMetricsEnabled],
);
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,
},
signal,
undefined,
dotMetricsEnabled,
);
const records = response.payload?.data.records || [];
return {
data: records.length > 0 ? records[0] : null,
error: response.error,
};
},
[dotMetricsEnabled],
);
return (
<>
<K8sBaseList<K8sDaemonSetsData>
controlListPrefix={controlListPrefix}
entity={InfraMonitoringEntity.DAEMONSETS}
tableColumns={k8sDaemonSetsColumnsConfig}
fetchListData={fetchListData}
getRowKey={getK8sDaemonSetRowKey}
getItemKey={getK8sDaemonSetItemKey}
eventCategory={InfraMonitoringEvents.DaemonSet}
/>
<K8sBaseDetails<K8sDaemonSetsData>
category={InfraMonitoringEntity.DAEMONSETS}
eventCategory={InfraMonitoringEvents.DaemonSet}
getSelectedItemFilters={k8sDaemonSetGetSelectedItemFilters}
fetchEntityData={fetchEntityData}
getEntityName={k8sDaemonSetGetEntityName}
getInitialLogTracesFilters={k8sDaemonSetInitialLogTracesFilter}
getInitialEventsFilters={k8sDaemonSetInitialEventsFilter}
metadataConfig={k8sDaemonSetDetailsMetadataConfig}
entityWidgetInfo={daemonSetWidgetInfo}
getEntityQueryPayload={getDaemonSetMetricsQueryPayload}
queryKeyPrefix="daemonset"
/>
</>
);
}
export default K8sDaemonSetsList;

View File

@@ -1,119 +0,0 @@
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 { K8sBaseFilters } from '../Base/types';
export interface K8sDaemonSetsData {
daemonSetName: string;
cpuUsage: number;
memoryUsage: number;
cpuRequest: number;
memoryRequest: number;
cpuLimit: number;
memoryLimit: number;
restarts: number;
desiredNodes: number;
availableNodes: number;
meta: {
k8s_cluster_name: string;
k8s_daemonset_name: string;
k8s_namespace_name: string;
};
}
export interface K8sDaemonSetsListResponse {
status: string;
data: {
type: string;
records: K8sDaemonSetsData[];
groups: null;
total: number;
sentAnyHostMetricsData: boolean;
isSendingK8SAgentMetrics: boolean;
};
}
// TODO(H4ad): Erase this whole file when migrating to openapi
export const daemonSetsMetaMap = [
{ dot: 'k8s.namespace.name', under: 'k8s_namespace_name' },
{ dot: 'k8s.daemonset.name', under: 'k8s_daemonset_name' },
{ dot: 'k8s.cluster.name', under: 'k8s_cluster_name' },
] as const;
export function mapDaemonSetsMeta(
raw: Record<string, unknown>,
): K8sDaemonSetsData['meta'] {
const out: Record<string, unknown> = { ...raw };
daemonSetsMetaMap.forEach(({ dot, under }) => {
if (dot in raw) {
const v = raw[dot];
out[under] = typeof v === 'string' ? v : raw[under];
}
});
return out as K8sDaemonSetsData['meta'];
}
export const getK8sDaemonSetsList = async (
props: K8sBaseFilters,
signal?: AbortSignal,
headers?: Record<string, string>,
dotMetricsEnabled = false,
): Promise<SuccessResponse<K8sDaemonSetsListResponse> | ErrorResponse> => {
try {
const requestProps = dotMetricsEnabled
? {
...props,
filters: {
...props.filters,
items: props.filters.items.reduce<typeof props.filters.items>(
(acc, item) => {
if (item.value === undefined) {
return acc;
}
if (
item.key &&
typeof item.key === 'object' &&
'key' in item.key &&
typeof item.key.key === 'string'
) {
const mappedKey = UnderscoreToDotMap[item.key.key] ?? item.key.key;
acc.push({
...item,
key: { ...item.key, key: mappedKey },
});
} else {
acc.push(item);
}
return acc;
},
[] as typeof props.filters.items,
),
},
}
: props;
const response = await axios.post('/daemonsets/list', requestProps, {
signal,
headers,
});
const payload: K8sDaemonSetsListResponse = response.data;
payload.data.records = payload.data.records.map((record) => ({
...record,
meta: mapDaemonSetsMeta(record.meta as Record<string, unknown>),
}));
return {
statusCode: 200,
error: null,
message: 'Success',
payload,
params: requestProps,
};
} catch (error) {
return ErrorResponseHandler(error as AxiosError);
}
};

View File

@@ -1,697 +0,0 @@
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 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',
yAxisUnit: '',
},
{
title: 'Memory usage, request, limits',
yAxisUnit: 'bytes',
},
{
title: 'Network IO',
yAxisUnit: 'binBps',
},
{
title: 'Network errors count',
yAxisUnit: '',
},
];
export const getDaemonSetMetricsQueryPayload = (
daemonSet: K8sDaemonSetsData,
start: number,
end: number,
dotMetricsEnabled: boolean,
): GetQueryResultsProps[] => {
const k8sPodCpuUtilizationKey = dotMetricsEnabled
? 'k8s.pod.cpu.usage'
: 'k8s_pod_cpu_usage';
const k8sContainerCpuRequestKey = dotMetricsEnabled
? 'k8s.container.cpu_request'
: 'k8s_container_cpu_request';
const k8sContainerCpuLimitKey = dotMetricsEnabled
? 'k8s.container.cpu_limit'
: 'k8s_container_cpu_limit';
const k8sPodMemoryUsageKey = dotMetricsEnabled
? 'k8s.pod.memory.usage'
: 'k8s_pod_memory_usage';
const k8sContainerMemoryRequestKey = dotMetricsEnabled
? 'k8s.container.memory_request'
: 'k8s_container_memory_request';
const k8sContainerMemoryLimitKey = dotMetricsEnabled
? 'k8s.container.memory_limit'
: 'k8s_container_memory_limit';
const k8sPodNetworkIoKey = dotMetricsEnabled
? 'k8s.pod.network.io'
: 'k8s_pod_network_io';
const k8sPodNetworkErrorsKey = dotMetricsEnabled
? 'k8s.pod.network.errors'
: 'k8s_pod_network_errors';
const k8sDaemonSetNameKey = dotMetricsEnabled
? 'k8s.daemonset.name'
: 'k8s_daemonset_name';
const k8sPodNameKey = dotMetricsEnabled ? 'k8s.pod.name' : 'k8s_pod_name';
const k8sNamespaceNameKey = dotMetricsEnabled
? 'k8s.namespace.name'
: 'k8s_namespace_name';
return [
{
selectedTime: 'GLOBAL_TIME',
graphType: PANEL_TYPES.TIME_SERIES,
query: {
builder: {
queryData: [
{
aggregateAttribute: {
dataType: DataTypes.Float64,
id: 'k8s_pod_cpu_usage--float64--Gauge--true',
key: k8sPodCpuUtilizationKey,
type: 'Gauge',
},
aggregateOperator: 'avg',
dataSource: DataSource.METRICS,
disabled: false,
expression: 'A',
filters: {
items: [
{
id: '745a486f',
key: {
dataType: DataTypes.String,
id: 'k8s_daemonset_name--string--tag--false',
key: k8sDaemonSetNameKey,
type: 'tag',
},
op: '=',
value: daemonSet.meta.k8s_daemonset_name,
},
{
id: '47b3adae',
key: {
dataType: DataTypes.String,
id: 'k8s_namespace_name--string--tag--false',
key: k8sNamespaceNameKey,
type: 'tag',
},
op: '=',
value: daemonSet.meta.k8s_namespace_name,
},
],
op: 'AND',
},
functions: [],
groupBy: [],
having: [],
legend: 'usage',
limit: null,
orderBy: [],
queryName: 'A',
reduceTo: ReduceOperators.AVG,
spaceAggregation: 'sum',
stepInterval: 60,
timeAggregation: 'avg',
},
{
aggregateAttribute: {
dataType: DataTypes.Float64,
id: 'k8s_container_cpu_request--float64--Gauge--true',
key: k8sContainerCpuRequestKey,
type: 'Gauge',
},
aggregateOperator: 'avg',
dataSource: DataSource.METRICS,
disabled: false,
expression: 'B',
filters: {
items: [
{
id: '148dffa7',
key: {
dataType: DataTypes.String,
id: 'k8s_pod_name--string--tag--false',
key: k8sPodNameKey,
type: 'tag',
},
op: 'contains',
value: daemonSet.meta.k8s_daemonset_name,
},
{
id: '47b3adae',
key: {
dataType: DataTypes.String,
id: 'k8s_namespace_name--string--tag--false',
key: k8sNamespaceNameKey,
type: 'tag',
},
op: '=',
value: daemonSet.meta.k8s_namespace_name,
},
],
op: 'AND',
},
functions: [],
groupBy: [],
having: [],
legend: 'requests',
limit: null,
orderBy: [],
queryName: 'B',
reduceTo: ReduceOperators.AVG,
spaceAggregation: 'sum',
stepInterval: 60,
timeAggregation: 'avg',
},
{
aggregateAttribute: {
dataType: DataTypes.Float64,
id: 'k8s_container_cpu_limit--float64--Gauge--true',
key: k8sContainerCpuLimitKey,
type: 'Gauge',
},
aggregateOperator: 'avg',
dataSource: DataSource.METRICS,
disabled: false,
expression: 'C',
filters: {
items: [
{
id: 'd420a02b',
key: {
dataType: DataTypes.String,
id: 'k8s_pod_name--string--tag--false',
key: k8sPodNameKey,
type: 'tag',
},
op: 'contains',
value: daemonSet.meta.k8s_daemonset_name,
},
{
id: '47b3adae',
key: {
dataType: DataTypes.String,
id: 'k8s_namespace_name--string--tag--false',
key: k8sNamespaceNameKey,
type: 'tag',
},
op: '=',
value: daemonSet.meta.k8s_namespace_name,
},
],
op: 'AND',
},
functions: [],
groupBy: [],
having: [],
legend: 'limits',
limit: null,
orderBy: [],
queryName: 'C',
reduceTo: ReduceOperators.AVG,
spaceAggregation: 'sum',
stepInterval: 60,
timeAggregation: 'avg',
},
],
queryFormulas: [],
queryTraceOperator: [],
},
clickhouse_sql: [
{
disabled: false,
legend: '',
name: 'A',
query: '',
},
],
id: v4(),
promql: [
{
disabled: false,
legend: '',
name: 'A',
query: '',
},
],
queryType: EQueryType.QUERY_BUILDER,
},
variables: {},
formatForWeb: false,
start,
end,
},
{
selectedTime: 'GLOBAL_TIME',
graphType: PANEL_TYPES.TIME_SERIES,
query: {
builder: {
queryData: [
{
aggregateAttribute: {
dataType: DataTypes.Float64,
id: 'k8s_pod_memory_usage--float64--Gauge--true',
key: k8sPodMemoryUsageKey,
type: 'Gauge',
},
aggregateOperator: 'avg',
dataSource: DataSource.METRICS,
disabled: false,
expression: 'A',
filters: {
items: [
{
id: '6d3283ce',
key: {
dataType: DataTypes.String,
id: 'k8s_daemonset_name--string--tag--false',
key: k8sDaemonSetNameKey,
type: 'tag',
},
op: '=',
value: daemonSet.meta.k8s_daemonset_name,
},
{
id: '47b3adae',
key: {
dataType: DataTypes.String,
id: 'k8s_namespace_name--string--tag--false',
key: k8sNamespaceNameKey,
type: 'tag',
},
op: '=',
value: daemonSet.meta.k8s_namespace_name,
},
],
op: 'AND',
},
functions: [],
groupBy: [],
having: [],
legend: 'usage',
limit: null,
orderBy: [],
queryName: 'A',
reduceTo: ReduceOperators.AVG,
spaceAggregation: 'sum',
stepInterval: 60,
timeAggregation: 'avg',
},
{
aggregateAttribute: {
dataType: DataTypes.Float64,
id: 'k8s_container_memory_request--float64--Gauge--true',
key: k8sContainerMemoryRequestKey,
type: 'Gauge',
},
aggregateOperator: 'avg',
dataSource: DataSource.METRICS,
disabled: false,
expression: 'B',
filters: {
items: [
{
id: 'a334f5c2',
key: {
dataType: DataTypes.String,
id: 'k8s_pod_name--string--tag--false',
key: k8sPodNameKey,
type: 'tag',
},
op: 'contains',
value: daemonSet.meta.k8s_daemonset_name,
},
{
id: '47b3adae',
key: {
dataType: DataTypes.String,
id: 'k8s_namespace_name--string--tag--false',
key: k8sNamespaceNameKey,
type: 'tag',
},
op: '=',
value: daemonSet.meta.k8s_namespace_name,
},
],
op: 'AND',
},
functions: [],
groupBy: [],
having: [],
legend: 'requests',
limit: null,
orderBy: [],
queryName: 'B',
reduceTo: ReduceOperators.AVG,
spaceAggregation: 'sum',
stepInterval: 60,
timeAggregation: 'avg',
},
{
aggregateAttribute: {
dataType: DataTypes.Float64,
id: 'k8s_container_memory_limit--float64--Gauge--true',
key: k8sContainerMemoryLimitKey,
type: 'Gauge',
},
aggregateOperator: 'avg',
dataSource: DataSource.METRICS,
disabled: false,
expression: 'C',
filters: {
items: [
{
id: 'fde3c631',
key: {
dataType: DataTypes.String,
id: 'k8s_pod_name--string--tag--false',
key: k8sPodNameKey,
type: 'tag',
},
op: 'contains',
value: daemonSet.meta.k8s_daemonset_name,
},
{
id: '47b3adae',
key: {
dataType: DataTypes.String,
id: 'k8s_namespace_name--string--tag--false',
key: k8sNamespaceNameKey,
type: 'tag',
},
op: '=',
value: daemonSet.meta.k8s_namespace_name,
},
],
op: 'AND',
},
functions: [],
groupBy: [],
having: [],
legend: 'limits',
limit: null,
orderBy: [],
queryName: 'C',
reduceTo: ReduceOperators.AVG,
spaceAggregation: 'sum',
stepInterval: 60,
timeAggregation: 'avg',
},
],
queryFormulas: [],
queryTraceOperator: [],
},
clickhouse_sql: [
{
disabled: false,
legend: '',
name: 'A',
query: '',
},
],
id: v4(),
promql: [
{
disabled: false,
legend: '',
name: 'A',
query: '',
},
],
queryType: EQueryType.QUERY_BUILDER,
},
variables: {},
formatForWeb: false,
start,
end,
},
{
selectedTime: 'GLOBAL_TIME',
graphType: PANEL_TYPES.TIME_SERIES,
query: {
builder: {
queryData: [
{
aggregateAttribute: {
dataType: DataTypes.Float64,
id: 'k8s_pod_network_io--float64--Sum--true',
key: k8sPodNetworkIoKey,
type: 'Sum',
},
aggregateOperator: 'rate',
dataSource: DataSource.METRICS,
disabled: false,
expression: 'A',
filters: {
items: [
{
id: 'ccbdbd6a',
key: {
dataType: DataTypes.String,
id: 'k8s_daemonset_name--string--tag--false',
key: k8sDaemonSetNameKey,
type: 'tag',
},
op: '=',
value: daemonSet.meta.k8s_daemonset_name,
},
{
id: '47b3adae',
key: {
dataType: DataTypes.String,
id: 'k8s_namespace_name--string--tag--false',
key: k8sNamespaceNameKey,
type: 'tag',
},
op: '=',
value: daemonSet.meta.k8s_namespace_name,
},
],
op: 'AND',
},
functions: [],
groupBy: [
{
dataType: DataTypes.String,
id: 'direction--string--tag--false',
key: 'direction',
type: 'tag',
},
{
dataType: DataTypes.String,
id: 'interface--string--tag--false',
key: 'interface',
type: 'tag',
},
],
having: [],
legend: '{{direction}} :: {{interface}}',
limit: null,
orderBy: [],
queryName: 'A',
reduceTo: ReduceOperators.AVG,
spaceAggregation: 'sum',
stepInterval: 60,
timeAggregation: 'rate',
},
],
queryFormulas: [],
queryTraceOperator: [],
},
clickhouse_sql: [
{
disabled: false,
legend: '',
name: 'A',
query: '',
},
],
id: v4(),
promql: [
{
disabled: false,
legend: '',
name: 'A',
query: '',
},
],
queryType: EQueryType.QUERY_BUILDER,
},
variables: {},
formatForWeb: false,
start,
end,
},
{
selectedTime: 'GLOBAL_TIME',
graphType: PANEL_TYPES.TIME_SERIES,
query: {
builder: {
queryData: [
{
aggregateAttribute: {
dataType: DataTypes.Float64,
id: 'k8s_pod_network_errors--float64--Sum--true',
key: k8sPodNetworkErrorsKey,
type: 'Sum',
},
aggregateOperator: 'increase',
dataSource: DataSource.METRICS,
disabled: false,
expression: 'A',
filters: {
items: [
{
id: '581a85fb',
key: {
dataType: DataTypes.String,
id: 'k8s_daemonset_name--string--tag--false',
key: k8sDaemonSetNameKey,
type: 'tag',
},
op: '=',
value: daemonSet.meta.k8s_daemonset_name,
},
{
id: '47b3adae',
key: {
dataType: DataTypes.String,
id: 'k8s_namespace_name--string--tag--false',
key: k8sNamespaceNameKey,
type: 'tag',
},
op: '=',
value: daemonSet.meta.k8s_namespace_name,
},
],
op: 'AND',
},
functions: [],
groupBy: [
{
dataType: DataTypes.String,
id: 'direction--string--tag--false',
key: 'direction',
type: 'tag',
},
{
dataType: DataTypes.String,
id: 'interface--string--tag--false',
key: 'interface',
type: 'tag',
},
],
having: [],
legend: '{{direction}} :: {{interface}}',
limit: null,
orderBy: [],
queryName: 'A',
reduceTo: ReduceOperators.AVG,
spaceAggregation: 'sum',
stepInterval: 60,
timeAggregation: 'increase',
},
],
queryFormulas: [],
queryTraceOperator: [],
},
clickhouse_sql: [
{
disabled: false,
legend: '',
name: 'A',
query: '',
},
],
id: v4(),
promql: [
{
disabled: false,
legend: '',
name: 'A',
query: '',
},
],
queryType: EQueryType.QUERY_BUILDER,
},
variables: {},
formatForWeb: false,
start,
end,
},
];
};

View File

@@ -1,241 +0,0 @@
import { Tooltip } from 'antd';
import { TableColumnDef } from 'components/TanStackTableView';
import TanStackTable from 'components/TanStackTableView';
import { ExpandButtonWrapper } from 'container/InfraMonitoringK8sV2/components';
import EntityGroupHeader from '../Base/EntityGroupHeader';
import K8sGroupCell from '../Base/K8sGroupCell';
import { formatBytes } from '../commonUtils';
import { EntityProgressBar, ValidateColumnValueWrapper } from '../components';
import { InfraMonitoringEntity } from '../constants';
import { K8sDaemonSetsData } from './api';
import { Group } from '@signozhq/icons';
export function getK8sDaemonSetRowKey(daemonSet: K8sDaemonSetsData): string {
return (
daemonSet.daemonSetName ||
daemonSet.meta.k8s_daemonset_name ||
`${daemonSet.meta.k8s_namespace_name}-${daemonSet.meta.k8s_daemonset_name}`
);
}
export function getK8sDaemonSetItemKey(daemonSet: K8sDaemonSetsData): string {
return daemonSet.meta.k8s_daemonset_name;
}
export const k8sDaemonSetsColumnsConfig: TableColumnDef<K8sDaemonSetsData>[] = [
{
id: 'daemonSetGroup',
header: (): React.ReactNode => <EntityGroupHeader title="DAEMONSET GROUP" />,
accessorFn: (row): string => row.meta.k8s_daemonset_name || '',
width: { min: 300 },
enableSort: false,
enableRemove: false,
enableMove: false,
pin: 'left',
visibilityBehavior: 'hidden-on-collapse',
cell: ({ isExpanded, toggleExpanded, row }): JSX.Element | null => {
return (
<ExpandButtonWrapper
isExpanded={isExpanded}
toggleExpanded={toggleExpanded}
>
<K8sGroupCell row={row} />
</ExpandButtonWrapper>
);
},
},
{
id: 'daemonsetName',
header: (): React.ReactNode => (
<EntityGroupHeader
title="DaemonSet Name"
icon={<Group data-hide-expanded="true" size={14} />}
/>
),
accessorFn: (row): string => row.meta.k8s_daemonset_name || '',
width: { min: 290 },
enableSort: false,
enableRemove: false,
enableMove: false,
pin: 'left',
visibilityBehavior: 'hidden-on-expand',
cell: ({ value }): React.ReactNode => {
const daemonsetName = value as string;
return (
<Tooltip title={daemonsetName}>
<TanStackTable.Text>{daemonsetName}</TanStackTable.Text>
</Tooltip>
);
},
},
{
id: 'namespaceName',
header: 'Namespace Name',
accessorFn: (row): string => row.meta.k8s_namespace_name || '',
width: { default: 100 },
enableSort: false,
cell: ({ value }): React.ReactNode => {
const namespaceName = value as string;
return (
<Tooltip title={namespaceName}>
<TanStackTable.Text>{namespaceName}</TanStackTable.Text>
</Tooltip>
);
},
},
{
id: 'available_nodes',
header: 'Available',
accessorFn: (row): number => row.availableNodes,
width: { min: 140 },
enableSort: true,
cell: ({ value }): React.ReactNode => {
const availableNodes = value as number;
return (
<ValidateColumnValueWrapper
value={availableNodes}
entity={InfraMonitoringEntity.DAEMONSETS}
attribute="available node"
>
<TanStackTable.Text>{availableNodes}</TanStackTable.Text>
</ValidateColumnValueWrapper>
);
},
},
{
id: 'desired_nodes',
header: 'Desired',
accessorFn: (row): number => row.desiredNodes,
width: { min: 140 },
enableSort: true,
cell: ({ value }): React.ReactNode => {
const desiredNodes = value as number;
return (
<ValidateColumnValueWrapper
value={desiredNodes}
entity={InfraMonitoringEntity.DAEMONSETS}
attribute="desired node"
>
<TanStackTable.Text>{desiredNodes}</TanStackTable.Text>
</ValidateColumnValueWrapper>
);
},
},
{
id: 'cpu_request',
header: 'CPU Req Usage (%)',
accessorFn: (row): number => row.cpuRequest,
width: { min: 200, default: 200 },
enableSort: true,
cell: ({ value }): React.ReactNode => {
const cpuRequest = value as number;
return (
<ValidateColumnValueWrapper
value={cpuRequest}
entity={InfraMonitoringEntity.DAEMONSETS}
attribute="CPU Request"
>
<EntityProgressBar value={cpuRequest} type="request" />
</ValidateColumnValueWrapper>
);
},
},
{
id: 'cpu_limit',
header: 'CPU Limit Usage (%)',
accessorFn: (row): number => row.cpuLimit,
width: { min: 200, default: 200 },
enableSort: true,
cell: ({ value }): React.ReactNode => {
const cpuLimit = value as number;
return (
<ValidateColumnValueWrapper
value={cpuLimit}
entity={InfraMonitoringEntity.DAEMONSETS}
attribute="CPU Limit"
>
<EntityProgressBar value={cpuLimit} type="limit" />
</ValidateColumnValueWrapper>
);
},
},
{
id: 'cpu',
header: 'CPU Usage (cores)',
accessorFn: (row): number => row.cpuUsage,
width: { min: 190 },
enableSort: true,
defaultVisibility: false,
cell: ({ value }): React.ReactNode => {
const cpu = value as number;
return (
<ValidateColumnValueWrapper
value={cpu}
entity={InfraMonitoringEntity.DAEMONSETS}
attribute="CPU metric"
>
<TanStackTable.Text>{cpu}</TanStackTable.Text>
</ValidateColumnValueWrapper>
);
},
},
{
id: 'memory_request',
header: 'Mem Req Usage (%)',
accessorFn: (row): number => row.memoryRequest,
width: { min: 190 },
enableSort: true,
defaultVisibility: false,
cell: ({ value }): React.ReactNode => {
const memoryRequest = value as number;
return (
<ValidateColumnValueWrapper
value={memoryRequest}
entity={InfraMonitoringEntity.DAEMONSETS}
attribute="Memory Request"
>
<EntityProgressBar value={memoryRequest} type="request" />
</ValidateColumnValueWrapper>
);
},
},
{
id: 'memory_limit',
header: 'Mem Limit Usage (%)',
accessorFn: (row): number => row.memoryLimit,
width: { min: 180 },
enableSort: true,
cell: ({ value }): React.ReactNode => {
const memoryLimit = value as number;
return (
<ValidateColumnValueWrapper
value={memoryLimit}
entity={InfraMonitoringEntity.DAEMONSETS}
attribute="Memory Limit"
>
<EntityProgressBar value={memoryLimit} type="limit" />
</ValidateColumnValueWrapper>
);
},
},
{
id: 'memory',
header: 'Mem Usage (WSS)',
accessorFn: (row): number => row.memoryUsage,
width: { min: 160 },
enableSort: true,
cell: ({ value }): React.ReactNode => {
const memory = value as number;
return (
<ValidateColumnValueWrapper
value={memory}
entity={InfraMonitoringEntity.DAEMONSETS}
attribute="memory metric"
>
<TanStackTable.Text>{formatBytes(memory)}</TanStackTable.Text>
</ValidateColumnValueWrapper>
);
},
},
];

View File

@@ -1,117 +0,0 @@
import React, { useCallback } from 'react';
import { InfraMonitoringEvents } from 'constants/events';
import { FeatureKeys } from 'constants/features';
import { useAppContext } from 'providers/App/App';
import K8sBaseDetails, { K8sDetailsFilters } from '../Base/K8sBaseDetails';
import { K8sBaseList } from '../Base/K8sBaseList';
import { K8sBaseFilters } from '../Base/types';
import { InfraMonitoringEntity } from '../constants';
import { getK8sDeploymentsList, K8sDeploymentsData } from './api';
import {
deploymentWidgetInfo,
getDeploymentMetricsQueryPayload,
k8sDeploymentDetailsMetadataConfig,
k8sDeploymentGetEntityName,
k8sDeploymentGetSelectedItemFilters,
k8sDeploymentInitialEventsFilter,
k8sDeploymentInitialLogTracesFilter,
} from './constants';
import {
getK8sDeploymentItemKey,
getK8sDeploymentRowKey,
k8sDeploymentsColumnsConfig,
} from './table.config';
function K8sDeploymentsList({
controlListPrefix,
}: {
controlListPrefix?: React.ReactNode;
}): JSX.Element {
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 getK8sDeploymentsList(
filters,
signal,
undefined,
dotMetricsEnabled,
);
return {
data: response.payload?.data.records || [],
total: response.payload?.data.total || 0,
error: response.error,
rawData: response.payload?.data,
};
},
[dotMetricsEnabled],
);
const fetchEntityData = useCallback(
async (
filters: K8sDetailsFilters,
signal?: AbortSignal,
): Promise<{ data: K8sDeploymentsData | null; error?: string | null }> => {
const response = await getK8sDeploymentsList(
{
filters: filters.filters,
start: filters.start,
end: filters.end,
limit: 1,
offset: 0,
},
signal,
undefined,
dotMetricsEnabled,
);
const records = response.payload?.data.records || [];
return {
data: records.length > 0 ? records[0] : null,
error: response.error,
};
},
[dotMetricsEnabled],
);
return (
<>
<K8sBaseList<K8sDeploymentsData>
controlListPrefix={controlListPrefix}
entity={InfraMonitoringEntity.DEPLOYMENTS}
tableColumns={k8sDeploymentsColumnsConfig}
fetchListData={fetchListData}
getRowKey={getK8sDeploymentRowKey}
getItemKey={getK8sDeploymentItemKey}
eventCategory={InfraMonitoringEvents.Deployment}
/>
<K8sBaseDetails<K8sDeploymentsData>
category={InfraMonitoringEntity.DEPLOYMENTS}
eventCategory={InfraMonitoringEvents.Deployment}
getSelectedItemFilters={k8sDeploymentGetSelectedItemFilters}
fetchEntityData={fetchEntityData}
getEntityName={k8sDeploymentGetEntityName}
getInitialLogTracesFilters={k8sDeploymentInitialLogTracesFilter}
getInitialEventsFilters={k8sDeploymentInitialEventsFilter}
metadataConfig={k8sDeploymentDetailsMetadataConfig}
entityWidgetInfo={deploymentWidgetInfo}
getEntityQueryPayload={getDeploymentMetricsQueryPayload}
queryKeyPrefix="deployment"
/>
</>
);
}
export default K8sDeploymentsList;

View File

@@ -1,132 +0,0 @@
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/types';
export interface K8sDeploymentsListPayload {
filters: TagFilter;
groupBy?: BaseAutocompleteData[];
offset?: number;
limit?: number;
orderBy?: {
columnName: string;
order: 'asc' | 'desc';
};
}
export interface K8sDeploymentsData {
deploymentName: string;
cpuUsage: number;
memoryUsage: number;
desiredPods: number;
availablePods: number;
cpuRequest: number;
memoryRequest: number;
cpuLimit: number;
memoryLimit: number;
restarts: number;
meta: {
k8s_cluster_name: string;
k8s_deployment_name: string;
k8s_namespace_name: string;
};
}
export interface K8sDeploymentsListResponse {
status: string;
data: {
type: string;
records: K8sDeploymentsData[];
groups: null;
total: number;
sentAnyHostMetricsData: boolean;
isSendingK8SAgentMetrics: boolean;
};
}
// TODO(H4ad): Erase this whole file when migrating to openapi
export const deploymentsMetaMap = [
{ dot: 'k8s.cluster.name', under: 'k8s_cluster_name' },
{ dot: 'k8s.deployment.name', under: 'k8s_deployment_name' },
{ dot: 'k8s.namespace.name', under: 'k8s_namespace_name' },
] as const;
export function mapDeploymentsMeta(
raw: Record<string, unknown>,
): K8sDeploymentsData['meta'] {
const out: Record<string, unknown> = { ...raw };
deploymentsMetaMap.forEach(({ dot, under }) => {
if (dot in raw) {
const v = raw[dot];
out[under] = typeof v === 'string' ? v : raw[under];
}
});
return out as K8sDeploymentsData['meta'];
}
export const getK8sDeploymentsList = async (
props: K8sBaseFilters,
signal?: AbortSignal,
headers?: Record<string, string>,
dotMetricsEnabled = false,
): Promise<SuccessResponse<K8sDeploymentsListResponse> | ErrorResponse> => {
try {
const requestProps = dotMetricsEnabled
? {
...props,
filters: {
...props.filters,
items: props.filters.items.reduce<typeof props.filters.items>(
(acc, item) => {
if (item.value === undefined) {
return acc;
}
if (
item.key &&
typeof item.key === 'object' &&
'key' in item.key &&
typeof item.key.key === 'string'
) {
const mappedKey = UnderscoreToDotMap[item.key.key] ?? item.key.key;
acc.push({
...item,
key: { ...item.key, key: mappedKey },
});
} else {
acc.push(item);
}
return acc;
},
[] as typeof props.filters.items,
),
},
}
: props;
const response = await axios.post('/deployments/list', requestProps, {
signal,
headers,
});
const payload: K8sDeploymentsListResponse = response.data;
payload.data.records = payload.data.records.map((record) => ({
...record,
meta: mapDeploymentsMeta(record.meta as Record<string, unknown>),
}));
return {
statusCode: 200,
error: null,
message: 'Success',
payload,
params: requestProps,
};
} catch (error) {
return ErrorResponseHandler(error as AxiosError);
}
};

View File

@@ -1,608 +0,0 @@
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 { K8sDeploymentsData } from './api';
export const k8sDeploymentGetSelectedItemFilters = (
selectedItemId: string,
): TagFilter => ({
op: 'AND',
items: [
{
id: 'k8s_deployment_name',
key: {
key: 'k8s_deployment_name',
type: null,
},
op: '=',
value: selectedItemId,
},
],
});
export const k8sDeploymentDetailsMetadataConfig: K8sDetailsMetadataConfig<K8sDeploymentsData>[] =
[
{
label: 'Deployment Name',
getValue: (p): string => p.meta.k8s_deployment_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 k8sDeploymentInitialEventsFilter = (
item: K8sDeploymentsData,
): ReturnType<typeof createFilterItem>[] => [
createFilterItem(QUERY_KEYS.K8S_OBJECT_KIND, 'Deployment'),
createFilterItem(QUERY_KEYS.K8S_OBJECT_NAME, item.meta.k8s_deployment_name),
];
export const k8sDeploymentInitialLogTracesFilter = (
item: K8sDeploymentsData,
): ReturnType<typeof createFilterItem>[] => [
createFilterItem(
QUERY_KEYS.K8S_DEPLOYMENT_NAME,
item.meta.k8s_deployment_name,
),
createFilterItem(QUERY_KEYS.K8S_NAMESPACE_NAME, item.meta.k8s_namespace_name),
];
export const k8sDeploymentGetEntityName = (item: K8sDeploymentsData): string =>
item.meta.k8s_deployment_name;
export const deploymentWidgetInfo = [
{
title: 'CPU usage, request, limits',
yAxisUnit: '',
},
{
title: 'Memory usage, request, limits',
yAxisUnit: 'bytes',
},
{
title: 'Network IO',
yAxisUnit: 'binBps',
},
{
title: 'Network error count',
yAxisUnit: '',
},
];
export const getDeploymentMetricsQueryPayload = (
deployment: K8sDeploymentsData,
start: number,
end: number,
dotMetricsEnabled: boolean,
): GetQueryResultsProps[] => {
const k8sPodCpuUtilizationKey = dotMetricsEnabled
? 'k8s.pod.cpu.usage'
: 'k8s_pod_cpu_usage';
const k8sContainerCpuRequestKey = dotMetricsEnabled
? 'k8s.container.cpu_request'
: 'k8s_container_cpu_request';
const k8sContainerCpuLimitKey = dotMetricsEnabled
? 'k8s.container.cpu_limit'
: 'k8s_container_cpu_limit';
const k8sPodMemoryUsageKey = dotMetricsEnabled
? 'k8s.pod.memory.usage'
: 'k8s_pod_memory_usage';
const k8sContainerMemoryRequestKey = dotMetricsEnabled
? 'k8s.container.memory_request'
: 'k8s_container_memory_request';
const k8sContainerMemoryLimitKey = dotMetricsEnabled
? 'k8s.container.memory_limit'
: 'k8s_container_memory_limit';
const k8sPodNetworkIoKey = dotMetricsEnabled
? 'k8s.pod.network.io'
: 'k8s_pod_network_io';
const k8sPodNetworkErrorsKey = dotMetricsEnabled
? 'k8s.pod.network.errors'
: 'k8s_pod_network_errors';
const k8sDeploymentNameKey = dotMetricsEnabled
? 'k8s.deployment.name'
: 'k8s_deployment_name';
const k8sPodNameKey = dotMetricsEnabled ? 'k8s.pod.name' : 'k8s_pod_name';
return [
{
selectedTime: 'GLOBAL_TIME',
graphType: PANEL_TYPES.TIME_SERIES,
query: {
builder: {
queryData: [
{
aggregateAttribute: {
dataType: DataTypes.Float64,
id: 'k8s_pod_cpu_usage--float64--Gauge--true',
key: k8sPodCpuUtilizationKey,
type: 'Gauge',
},
aggregateOperator: 'avg',
dataSource: DataSource.METRICS,
disabled: false,
expression: 'A',
filters: {
items: [
{
id: 'aec60cba',
key: {
dataType: DataTypes.String,
id: 'k8s_deployment_name--string--tag--false',
key: k8sDeploymentNameKey,
type: 'tag',
},
op: '=',
value: deployment.meta.k8s_deployment_name,
},
],
op: 'AND',
},
functions: [],
groupBy: [],
having: [],
legend: 'usage',
limit: null,
orderBy: [],
queryName: 'A',
reduceTo: ReduceOperators.AVG,
spaceAggregation: 'sum',
stepInterval: 60,
timeAggregation: 'avg',
},
{
aggregateAttribute: {
dataType: DataTypes.Float64,
id: 'k8s_container_cpu_request--float64--Gauge--true',
key: k8sContainerCpuRequestKey,
type: 'Gauge',
},
aggregateOperator: 'avg',
dataSource: DataSource.METRICS,
disabled: false,
expression: 'B',
filters: {
items: [
{
id: 'd047ec14',
key: {
dataType: DataTypes.String,
id: 'k8s_pod_name--string--tag--false',
key: k8sPodNameKey,
type: 'tag',
},
op: 'contains',
value: deployment.meta.k8s_deployment_name,
},
],
op: 'AND',
},
functions: [],
groupBy: [],
having: [],
legend: 'requests',
limit: null,
orderBy: [],
queryName: 'B',
reduceTo: ReduceOperators.AVG,
spaceAggregation: 'sum',
stepInterval: 60,
timeAggregation: 'avg',
},
{
aggregateAttribute: {
dataType: DataTypes.Float64,
id: 'k8s_container_cpu_limit--float64--Gauge--true',
key: k8sContainerCpuLimitKey,
type: 'Gauge',
},
aggregateOperator: 'avg',
dataSource: DataSource.METRICS,
disabled: false,
expression: 'C',
filters: {
items: [
{
id: '750b7856',
key: {
dataType: DataTypes.String,
id: 'k8s_pod_name--string--tag--false',
key: k8sPodNameKey,
type: 'tag',
},
op: 'contains',
value: deployment.meta.k8s_deployment_name,
},
],
op: 'AND',
},
functions: [],
groupBy: [],
having: [],
legend: 'limits',
limit: null,
orderBy: [],
queryName: 'C',
reduceTo: ReduceOperators.AVG,
spaceAggregation: 'sum',
stepInterval: 60,
timeAggregation: 'avg',
},
],
queryFormulas: [],
queryTraceOperator: [],
},
clickhouse_sql: [
{
disabled: false,
legend: '',
name: 'A',
query: '',
},
],
id: v4(),
promql: [
{
disabled: false,
legend: '',
name: 'A',
query: '',
},
],
queryType: EQueryType.QUERY_BUILDER,
},
variables: {},
formatForWeb: false,
start,
end,
},
{
selectedTime: 'GLOBAL_TIME',
graphType: PANEL_TYPES.TIME_SERIES,
query: {
builder: {
queryData: [
{
aggregateAttribute: {
dataType: DataTypes.Float64,
id: 'k8s_pod_memory_usage--float64--Gauge--true',
key: k8sPodMemoryUsageKey,
type: 'Gauge',
},
aggregateOperator: 'avg',
dataSource: DataSource.METRICS,
disabled: false,
expression: 'A',
filters: {
items: [
{
id: '768c2f47',
key: {
dataType: DataTypes.String,
id: 'k8s_deployment_name--string--tag--false',
key: k8sDeploymentNameKey,
type: 'tag',
},
op: '=',
value: deployment.meta.k8s_deployment_name,
},
],
op: 'AND',
},
functions: [],
groupBy: [],
having: [],
legend: 'usage',
limit: null,
orderBy: [],
queryName: 'A',
reduceTo: ReduceOperators.AVG,
spaceAggregation: 'sum',
stepInterval: 60,
timeAggregation: 'avg',
},
{
aggregateAttribute: {
dataType: DataTypes.Float64,
id: 'k8s_container_memory_request--float64--Gauge--true',
key: k8sContainerMemoryRequestKey,
type: 'Gauge',
},
aggregateOperator: 'avg',
dataSource: DataSource.METRICS,
disabled: false,
expression: 'B',
filters: {
items: [
{
id: '1a96fa81',
key: {
dataType: DataTypes.String,
id: 'k8s_pod_name--string--tag--false',
key: k8sPodNameKey,
type: 'tag',
},
op: 'contains',
value: deployment.meta.k8s_deployment_name,
},
],
op: 'AND',
},
functions: [],
groupBy: [],
having: [],
legend: 'requests',
limit: null,
orderBy: [],
queryName: 'B',
reduceTo: ReduceOperators.AVG,
spaceAggregation: 'sum',
stepInterval: 60,
timeAggregation: 'avg',
},
{
aggregateAttribute: {
dataType: DataTypes.Float64,
id: 'k8s_container_memory_limit--float64--Gauge--true',
key: k8sContainerMemoryLimitKey,
type: 'Gauge',
},
aggregateOperator: 'avg',
dataSource: DataSource.METRICS,
disabled: false,
expression: 'C',
filters: {
items: [
{
id: 'e69a2b7e',
key: {
dataType: DataTypes.String,
id: 'k8s_pod_name--string--tag--false',
key: k8sPodNameKey,
type: 'tag',
},
op: 'contains',
value: deployment.meta.k8s_deployment_name,
},
],
op: 'AND',
},
functions: [],
groupBy: [],
having: [],
legend: 'limits',
limit: null,
orderBy: [],
queryName: 'C',
reduceTo: ReduceOperators.AVG,
spaceAggregation: 'sum',
stepInterval: 60,
timeAggregation: 'avg',
},
],
queryFormulas: [],
queryTraceOperator: [],
},
clickhouse_sql: [
{
disabled: false,
legend: '',
name: 'A',
query: '',
},
],
id: v4(),
promql: [
{
disabled: false,
legend: '',
name: 'A',
query: '',
},
],
queryType: EQueryType.QUERY_BUILDER,
},
variables: {},
formatForWeb: false,
start,
end,
},
{
selectedTime: 'GLOBAL_TIME',
graphType: PANEL_TYPES.TIME_SERIES,
query: {
builder: {
queryData: [
{
aggregateAttribute: {
dataType: DataTypes.Float64,
id: 'k8s_pod_network_io--float64--Sum--true',
key: k8sPodNetworkIoKey,
type: 'Sum',
},
aggregateOperator: 'rate',
dataSource: DataSource.METRICS,
disabled: false,
expression: 'A',
filters: {
items: [
{
id: '8b550f6d',
key: {
dataType: DataTypes.String,
id: 'k8s_deployment_name--string--tag--false',
key: k8sDeploymentNameKey,
type: 'tag',
},
op: '=',
value: deployment.meta.k8s_deployment_name,
},
],
op: 'AND',
},
functions: [],
groupBy: [
{
dataType: DataTypes.String,
id: 'direction--string--tag--false',
key: 'direction',
type: 'tag',
},
{
dataType: DataTypes.String,
id: 'interface--string--tag--false',
key: 'interface',
type: 'tag',
},
],
having: [],
legend: '{{direction}} :: {{interface}}',
limit: null,
orderBy: [],
queryName: 'A',
reduceTo: ReduceOperators.AVG,
spaceAggregation: 'sum',
stepInterval: 60,
timeAggregation: 'rate',
},
],
queryFormulas: [],
queryTraceOperator: [],
},
clickhouse_sql: [
{
disabled: false,
legend: '',
name: 'A',
query: '',
},
],
id: v4(),
promql: [
{
disabled: false,
legend: '',
name: 'A',
query: '',
},
],
queryType: EQueryType.QUERY_BUILDER,
},
variables: {},
formatForWeb: false,
start,
end,
},
{
selectedTime: 'GLOBAL_TIME',
graphType: PANEL_TYPES.TIME_SERIES,
query: {
builder: {
queryData: [
{
aggregateAttribute: {
dataType: DataTypes.Float64,
id: 'k8s_pod_network_errors--float64--Sum--true',
key: k8sPodNetworkErrorsKey,
type: 'Sum',
},
aggregateOperator: 'increase',
dataSource: DataSource.METRICS,
disabled: false,
expression: 'A',
filters: {
items: [
{
id: 'e16c1e4a',
key: {
dataType: DataTypes.String,
id: 'k8s_deployment_name--string--tag--false',
key: k8sDeploymentNameKey,
type: 'tag',
},
op: '=',
value: deployment.meta.k8s_deployment_name,
},
],
op: 'AND',
},
functions: [],
groupBy: [
{
dataType: DataTypes.String,
id: 'direction--string--tag--false',
key: 'direction',
type: 'tag',
},
{
dataType: DataTypes.String,
id: 'interface--string--tag--false',
key: 'interface',
type: 'tag',
},
],
having: [],
legend: '{{direction}} :: {{interface}}',
limit: null,
orderBy: [],
queryName: 'A',
reduceTo: ReduceOperators.AVG,
spaceAggregation: 'sum',
stepInterval: 60,
timeAggregation: 'increase',
},
],
queryFormulas: [],
queryTraceOperator: [],
},
clickhouse_sql: [
{
disabled: false,
legend: '',
name: 'A',
query: '',
},
],
id: v4(),
promql: [
{
disabled: false,
legend: '',
name: 'A',
query: '',
},
],
queryType: EQueryType.QUERY_BUILDER,
},
variables: {},
formatForWeb: false,
start,
end,
},
];
};

View File

@@ -1,246 +0,0 @@
import { Tooltip } from 'antd';
import { TableColumnDef } from 'components/TanStackTableView';
import TanStackTable from 'components/TanStackTableView';
import { ExpandButtonWrapper } from 'container/InfraMonitoringK8sV2/components';
import EntityGroupHeader from '../Base/EntityGroupHeader';
import K8sGroupCell from '../Base/K8sGroupCell';
import { formatBytes } from '../commonUtils';
import { EntityProgressBar, ValidateColumnValueWrapper } from '../components';
import { InfraMonitoringEntity } from '../constants';
import { K8sDeploymentsData } from './api';
import { Computer } from '@signozhq/icons';
export function getK8sDeploymentRowKey(deployment: K8sDeploymentsData): string {
return deployment.meta.k8s_deployment_name || deployment.deploymentName;
}
export function getK8sDeploymentItemKey(
deployment: K8sDeploymentsData,
): string {
return deployment.meta.k8s_deployment_name;
}
export const k8sDeploymentsColumnsConfig: TableColumnDef<K8sDeploymentsData>[] =
[
{
id: 'deploymentGroup',
header: (): React.ReactNode => (
<EntityGroupHeader title="DEPLOYMENT GROUP" />
),
accessorFn: (row): string => row.meta.k8s_deployment_name || '',
width: { min: 220 },
enableSort: false,
enableRemove: false,
enableMove: false,
pin: 'left',
visibilityBehavior: 'hidden-on-collapse',
cell: ({ isExpanded, toggleExpanded, row }): JSX.Element | null => {
return (
<ExpandButtonWrapper
isExpanded={isExpanded}
toggleExpanded={toggleExpanded}
>
<K8sGroupCell row={row} />
</ExpandButtonWrapper>
);
},
},
{
id: 'deploymentName',
header: (): React.ReactNode => (
<EntityGroupHeader
title="Deployment Name"
icon={<Computer data-hide-expanded="true" size={14} />}
/>
),
accessorFn: (row): string => row.meta.k8s_deployment_name || '',
width: { min: 210 },
enableSort: false,
enableRemove: false,
enableMove: false,
pin: 'left',
visibilityBehavior: 'hidden-on-expand',
cell: ({ value }): React.ReactNode => {
const deploymentName = value as string;
return (
<Tooltip title={deploymentName}>
<TanStackTable.Text>{deploymentName}</TanStackTable.Text>
</Tooltip>
);
},
},
{
id: 'namespaceName',
header: 'Namespace Name',
accessorFn: (row): string => row.meta.k8s_namespace_name || '',
width: { default: 220 },
enableSort: false,
enableResize: true,
cell: ({ value }): React.ReactNode => (
<TanStackTable.Text>{value as string}</TanStackTable.Text>
),
},
{
id: 'available_pods',
header: 'Available',
accessorFn: (row): number => row.availablePods,
width: { min: 100 },
enableSort: false,
enableResize: true,
defaultVisibility: false,
cell: ({ value }): React.ReactNode => {
const availablePods = value as number;
return (
<ValidateColumnValueWrapper
value={availablePods}
entity={InfraMonitoringEntity.DEPLOYMENTS}
attribute="available pod"
>
<TanStackTable.Text>{availablePods}</TanStackTable.Text>
</ValidateColumnValueWrapper>
);
},
},
{
id: 'desired_pods',
header: 'Desired',
accessorFn: (row): number => row.desiredPods,
width: { min: 80 },
enableSort: false,
enableResize: true,
defaultVisibility: false,
cell: ({ value }): React.ReactNode => {
const desiredPods = value as number;
return (
<ValidateColumnValueWrapper
value={desiredPods}
entity={InfraMonitoringEntity.DEPLOYMENTS}
attribute="desired pod"
>
<TanStackTable.Text>{desiredPods}</TanStackTable.Text>
</ValidateColumnValueWrapper>
);
},
},
{
id: 'cpu_request',
header: 'CPU Req Usage (%)',
accessorFn: (row): number => row.cpuRequest,
width: { min: 210 },
enableSort: true,
enableResize: true,
cell: ({ value }): React.ReactNode => {
const cpuRequest = value as number;
return (
<ValidateColumnValueWrapper
value={cpuRequest}
entity={InfraMonitoringEntity.DEPLOYMENTS}
attribute="CPU Request"
>
<EntityProgressBar value={cpuRequest} type="request" />
</ValidateColumnValueWrapper>
);
},
},
{
id: 'cpu_limit',
header: 'CPU Limit Usage (%)',
accessorFn: (row): number => row.cpuLimit,
width: { min: 210 },
enableSort: true,
enableResize: true,
cell: ({ value }): React.ReactNode => {
const cpuLimit = value as number;
return (
<ValidateColumnValueWrapper
value={cpuLimit}
entity={InfraMonitoringEntity.DEPLOYMENTS}
attribute="CPU Limit"
>
<EntityProgressBar value={cpuLimit} type="limit" />
</ValidateColumnValueWrapper>
);
},
},
{
id: 'cpu',
header: 'CPU Usage (cores)',
accessorFn: (row): number => row.cpuUsage,
width: { min: 210 },
enableSort: true,
enableResize: true,
cell: ({ value }): React.ReactNode => {
const cpu = value as number;
return (
<ValidateColumnValueWrapper
value={cpu}
entity={InfraMonitoringEntity.DEPLOYMENTS}
attribute="CPU metric"
>
<TanStackTable.Text>{cpu}</TanStackTable.Text>
</ValidateColumnValueWrapper>
);
},
},
{
id: 'memory_request',
header: 'Mem Req Usage (%)',
accessorFn: (row): number => row.memoryRequest,
width: { min: 210 },
enableSort: true,
enableResize: true,
cell: ({ value }): React.ReactNode => {
const memoryRequest = value as number;
return (
<ValidateColumnValueWrapper
value={memoryRequest}
entity={InfraMonitoringEntity.DEPLOYMENTS}
attribute="Memory Request"
>
<EntityProgressBar value={memoryRequest} type="request" />
</ValidateColumnValueWrapper>
);
},
},
{
id: 'memory_limit',
header: 'Mem Limit Usage (%)',
accessorFn: (row): number => row.memoryLimit,
width: { min: 210 },
enableSort: true,
enableResize: true,
cell: ({ value }): React.ReactNode => {
const memoryLimit = value as number;
return (
<ValidateColumnValueWrapper
value={memoryLimit}
entity={InfraMonitoringEntity.DEPLOYMENTS}
attribute="Memory Limit"
>
<EntityProgressBar value={memoryLimit} type="limit" />
</ValidateColumnValueWrapper>
);
},
},
{
id: 'memory',
header: 'Mem Usage (WSS)',
accessorFn: (row): number => row.memoryUsage,
width: { min: 140 },
enableSort: true,
enableResize: true,
cell: ({ value }): React.ReactNode => {
const memory = value as number;
return (
<ValidateColumnValueWrapper
value={memory}
entity={InfraMonitoringEntity.DEPLOYMENTS}
attribute="memory metric"
>
<TanStackTable.Text>{formatBytes(memory)}</TanStackTable.Text>
</ValidateColumnValueWrapper>
);
},
},
];

View File

@@ -1,32 +0,0 @@
.container {
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
height: 240px;
}
.content {
display: flex;
flex-direction: column;
gap: 4px;
color: var(--muted-foreground);
font-size: 14px;
font-weight: 400;
line-height: 18px;
letter-spacing: -0.07px;
}
.icon {
height: 32px;
width: 32px;
}
.title {
font-weight: 600;
}
.subtitle {
font-weight: 400;
color: var(--muted-foreground);
}

View File

@@ -1,32 +0,0 @@
import { Typography } from '@signozhq/ui/typography';
import emptyStateUrl from '@/assets/Icons/emptyState.svg';
import styles from './EntityEmptyState.module.scss';
interface EntityEmptyStateProps {
hasFilters: boolean;
}
export default function EntityEmptyState({
hasFilters,
}: EntityEmptyStateProps): JSX.Element {
return (
<div className={styles.container}>
<div className={styles.content}>
<img src={emptyStateUrl} alt="empty-state" className={styles.icon} />
{hasFilters ? (
<Typography.Text>
<span className={styles.title}>This query had no results. </span>
Edit your query and try again!
</Typography.Text>
) : (
<Typography.Text>
<span className={styles.title}>No data yet. </span>
When we receive data, it will show up here.
</Typography.Text>
)}
</div>
</div>
);
}

View File

@@ -1,40 +0,0 @@
.container {
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
height: 240px;
}
.content {
display: flex;
flex-direction: column;
gap: 4px;
color: var(--muted-foreground);
font-size: 14px;
font-weight: 400;
line-height: 18px;
letter-spacing: -0.07px;
}
.icon {
height: 32px;
width: 32px;
}
.title {
font-weight: 600;
}
.contactSupport {
display: flex;
align-items: center;
margin-top: 8px;
gap: 4px;
cursor: pointer;
}
.contactSupportText {
color: var(--text-robin-400);
font-weight: 500;
}

View File

@@ -1,50 +0,0 @@
import { Typography } from '@signozhq/ui/typography';
import { useGetTenantLicense } from 'hooks/useGetTenantLicense';
import history from 'lib/history';
import { ArrowRight } from '@signozhq/icons';
import { openInNewTab } from 'utils/navigation';
import awwSnapUrl from '@/assets/Icons/awwSnap.svg';
import styles from './EntityError.module.scss';
export default function EntityError(): JSX.Element {
const { isCloudUser: isCloudUserVal } = useGetTenantLicense();
const handleContactSupport = (): void => {
if (isCloudUserVal) {
history.push('/support');
} else {
openInNewTab('https://signoz.io/slack');
}
};
return (
<div className={styles.container}>
<div className={styles.content}>
<img src={awwSnapUrl} alt="error" className={styles.icon} />
<Typography.Text>
<span className={styles.title}>Aw snap :/ </span>
Something went wrong. Please try again or contact support.
</Typography.Text>
<div
className={styles.contactSupport}
onClick={handleContactSupport}
role="button"
tabIndex={0}
onKeyDown={(e): void => {
if (e.key === 'Enter') {
handleContactSupport();
}
}}
>
<Typography.Link className={styles.contactSupportText}>
Contact Support
</Typography.Link>
<ArrowRight size={14} />
</div>
</div>
</div>
);
}

View File

@@ -1,101 +0,0 @@
.container {
margin-top: 1rem;
}
.filterContainer {
display: flex;
flex-direction: column;
gap: 8px;
padding: var(--spacing-6);
border-radius: var(--radius);
border: 1px solid var(--l1-border);
:global(.ant-select-selector) {
border-radius: 2px;
border: 1px solid var(--l1-border) !important;
background-color: var(--l3-background) !important;
input {
font-size: 12px;
}
:global([data-slot='badge'] .ant-typography) {
font-size: 12px;
}
}
}
.filterContainerTime {
display: flex;
justify-content: flex-end;
align-items: center;
gap: 8px;
}
.filterQuerySearch {
flex: 1;
}
.controls {
width: 100%;
display: flex;
justify-content: flex-end;
}
.eventsTable {
margin-top: var(--spacing-8);
:global(.ant-table) {
:global(.ant-table-thead) > tr > th {
padding: 12px;
font-weight: 500;
font-size: 11px;
line-height: 18px;
background: var(--card);
border-bottom: none;
color: var(--l2-foreground);
font-family: Inter;
font-style: normal;
font-weight: 600;
letter-spacing: 0.44px;
text-transform: uppercase;
&::before {
background-color: transparent;
}
}
:global(.ant-table-cell) {
padding: 12px;
font-size: 13px;
line-height: 20px;
color: var(--l1-foreground);
background: var(--card);
border-bottom: none;
}
:global(.ant-table-tbody) > tr:hover > td {
background: color-mix(in srgb, var(--l1-foreground) 4%, transparent);
}
:global(.ant-table-tbody) > tr > td {
border-bottom: none;
}
:global(.ant-table-thead)
> tr
> th:not(:last-child):not(:global(.ant-table-selection-column)):not(
:global(.ant-table-row-expand-icon-cell)
):not([colspan])::before {
background-color: transparent;
}
:global(.ant-empty-normal) {
visibility: hidden;
}
}
}
.expandIcon {
cursor: pointer;
}

View File

@@ -1,334 +0,0 @@
import { useCallback, useEffect, useMemo } from 'react';
import { Table, TableColumnsType } from 'antd';
import logEvent from 'api/common/logEvent';
import {
QuerySearchV2Provider,
useExpression,
useInitialExpression,
useInputExpression,
useQuerySearchInitialExpressionProp,
useQuerySearchOnChange,
useQuerySearchOnRun,
useUserExpression,
} from 'components/QueryBuilderV2';
import QuerySearch from 'components/QueryBuilderV2/QueryV2/QuerySearch/QuerySearch';
import {
combineInitialAndUserExpression,
getUserExpressionFromCombined,
} from 'components/QueryBuilderV2/QueryV2/QuerySearch/utils';
import { InfraMonitoringEvents } from 'constants/events';
import Controls from 'container/Controls';
import { InfraMonitoringEntity } from 'container/InfraMonitoringK8sV2/constants';
import LoadingContainer from 'container/InfraMonitoringK8sV2/LoadingContainer';
import RunQueryBtn from 'container/QueryBuilder/components/RunQueryBtn/RunQueryBtn';
import DateTimeSelectionV2 from 'container/TopNav/DateTimeSelectionV2';
import {
CustomTimeType,
Time,
} from 'container/TopNav/DateTimeSelectionV2/types';
import { ChevronDown, ChevronRight } from '@signozhq/icons';
import { useQueryState } from 'nuqs';
import { DataSource } from 'types/common/queryBuilder';
import { parseAsJsonNoValidate } from 'utils/nuqsParsers';
import { validateQuery } from 'utils/queryValidationUtils';
import EntityEmptyState from '../EntityEmptyState/EntityEmptyState';
import EntityError from '../EntityError/EntityError';
import { EventContents } from './EventsContent';
import EventsNotConfigured from './EventsNotConfigured';
import { K8S_ENTITY_EVENTS_EXPRESSION_KEY, useEntityEvents } from './hooks';
import { getEntityEventsQueryPayload, isEventsKeyNotFoundError } from './utils';
import styles from './EntityEvents.module.scss';
import { useTimezone } from 'providers/Timezone';
interface EventDataType {
key: string;
timestamp: string;
body: string;
id: string;
severity: string;
attributes_string?: Record<string, string>;
resources_string?: Record<string, string>;
}
interface Props {
timeRange: {
startTime: number;
endTime: number;
};
isModalTimeSelection: boolean;
handleTimeChange: (
interval: Time | CustomTimeType,
dateTimeRange?: [number, number],
) => void;
selectedInterval: Time;
queryKey: string;
category: InfraMonitoringEntity;
initialExpression: string;
}
const PAGE_SIZE_OPTIONS = [10, 20, 50];
const handleExpandRow = (record: EventDataType): JSX.Element => (
<EventContents
data={{ ...record.attributes_string, ...record.resources_string }}
/>
);
function EntityEventsContent({
timeRange,
isModalTimeSelection,
handleTimeChange,
selectedInterval,
queryKey,
category,
}: Omit<Props, 'initialExpression'>): JSX.Element {
const expression = useExpression();
const inputExpression = useInputExpression();
const userExpression = useUserExpression();
const initialExpression = useInitialExpression();
const querySearchOnChange = useQuerySearchOnChange();
const querySearchOnRun = useQuerySearchOnRun();
const querySearchInitialExpressionProp = useQuerySearchInitialExpressionProp();
const [pagination, setPagination] = useQueryState(
'eventsPagination',
parseAsJsonNoValidate<{ offset: number; limit: number }>(),
);
const pageSize = pagination?.limit || PAGE_SIZE_OPTIONS[0];
const offset = pagination?.offset || 0;
const {
events,
isLoading,
isFetching,
isError,
error,
currentCount,
hasMore,
refetch,
cancel,
} = useEntityEvents({
queryKey,
timeRange,
expression,
offset,
pageSize,
});
const handleRunQuery = useCallback(
(updatedExpression?: string): void => {
const newUserExpression = updatedExpression
? getUserExpressionFromCombined(initialExpression, updatedExpression)
: inputExpression;
const validation = validateQuery(
initialExpression
? combineInitialAndUserExpression(initialExpression, newUserExpression)
: newUserExpression || '',
);
if (validation.isValid) {
querySearchOnRun(newUserExpression || '');
logEvent(InfraMonitoringEvents.FilterApplied, {
entity: InfraMonitoringEvents.K8sEntity,
page: InfraMonitoringEvents.DetailedPage,
category,
view: InfraMonitoringEvents.EventsView,
});
refetch();
}
},
[inputExpression, initialExpression, refetch, querySearchOnRun, category],
);
const queryData = useMemo(
() =>
getEntityEventsQueryPayload({
start: timeRange.startTime,
end: timeRange.endTime,
expression: userExpression || '',
}).queryData,
[timeRange.startTime, timeRange.endTime, userExpression],
);
const formattedEvents = useMemo<EventDataType[]>(
() =>
events.map((event) => ({
key: event.data.id,
id: event.data.id,
timestamp: event.timestamp,
body: event.data.body,
severity: event.data.severity_text,
attributes_string: event.data.attributes_string,
resources_string: event.data.resources_string,
})),
[events],
);
const { formatTimezoneAdjustedTimestamp } = useTimezone();
const columns: TableColumnsType<EventDataType> = useMemo(
() => [
{ title: 'Severity', dataIndex: 'severity', key: 'severity', width: 100 },
{
title: 'Timestamp',
dataIndex: 'timestamp',
width: 240,
ellipsis: true,
key: 'timestamp',
render: (value: string | number): string =>
formatTimezoneAdjustedTimestamp(
typeof value === 'string' ? value : value / 1e6,
),
},
{ title: 'Body', dataIndex: 'body', key: 'body' },
],
[formatTimezoneAdjustedTimestamp],
);
const handleExpandRowIcon = ({
expanded,
onExpand,
record,
}: {
expanded: boolean;
onExpand: (
record: EventDataType,
e: React.MouseEvent<HTMLElement, MouseEvent>,
) => void;
record: EventDataType;
}): JSX.Element => {
const handleClick = (e: React.MouseEvent<SVGSVGElement>): void => {
onExpand(record, e as unknown as React.MouseEvent<HTMLElement, MouseEvent>);
};
return expanded ? (
<ChevronDown className={styles.expandIcon} size={14} onClick={handleClick} />
) : (
<ChevronRight
className={styles.expandIcon}
size={14}
onClick={handleClick}
/>
);
};
useEffect(() => {
return (): void => {
void setPagination(null);
};
}, [setPagination]);
const isDataEmpty =
!isLoading && !isFetching && !isError && formattedEvents.length === 0;
const hasAdditionalFilters = !!userExpression?.trim();
return (
<div className={styles.container}>
<div className={styles.filterContainer}>
<div className={styles.filterContainerTime}>
<DateTimeSelectionV2
showAutoRefresh
showRefreshText={false}
hideShareModal
isModalTimeSelection={isModalTimeSelection}
onTimeChange={handleTimeChange}
defaultRelativeTime="5m"
modalSelectedInterval={selectedInterval}
modalInitialStartTime={timeRange.startTime * 1000}
modalInitialEndTime={timeRange.endTime * 1000}
/>
<RunQueryBtn
isLoadingQueries={isFetching}
onStageRunQuery={(): void => handleRunQuery()}
handleCancelQuery={cancel}
/>
</div>
<div className={styles.filterQuerySearch}>
<QuerySearch
onChange={querySearchOnChange}
queryData={queryData}
dataSource={DataSource.LOGS}
onRun={handleRunQuery}
initialExpression={querySearchInitialExpressionProp}
/>
</div>
</div>
{isLoading && formattedEvents.length === 0 && <LoadingContainer />}
{isDataEmpty && <EntityEmptyState hasFilters={hasAdditionalFilters} />}
{isError && !isLoading && isEventsKeyNotFoundError(error) && (
<EventsNotConfigured />
)}
{isError && !isLoading && !isEventsKeyNotFoundError(error) && (
<EntityError />
)}
{!isLoading && !isError && formattedEvents.length > 0 && (
<div className={styles.eventsTable}>
<div className={styles.controls}>
<Controls
totalCount={hasMore ? currentCount + 1 : currentCount}
countPerPage={pageSize}
offset={offset}
perPageOptions={PAGE_SIZE_OPTIONS}
isLoading={isFetching}
handleNavigatePrevious={(): void => {
void setPagination({
offset: Math.max(0, offset - pageSize),
limit: pageSize,
});
}}
handleNavigateNext={(): void => {
void setPagination({
offset: offset + pageSize,
limit: pageSize,
});
}}
handleCountItemsPerPageChange={(value): void => {
void setPagination({
offset: 0,
limit: value,
});
}}
/>
</div>
<Table<EventDataType>
loading={isFetching && formattedEvents.length === 0}
columns={columns}
expandable={{
expandedRowRender: handleExpandRow,
rowExpandable: (record): boolean => record.body !== 'Not Expandable',
expandIcon: handleExpandRowIcon,
}}
dataSource={formattedEvents}
pagination={false}
rowKey={(record): string => record.id}
/>
</div>
)}
</div>
);
}
function EntityEvents({ initialExpression, ...rest }: Props): JSX.Element {
return (
<QuerySearchV2Provider
queryParamKey={K8S_ENTITY_EVENTS_EXPRESSION_KEY}
initialExpression={initialExpression}
persistOnUnmount
>
<EntityEventsContent {...rest} />
</QuerySearchV2Provider>
);
}
export default EntityEvents;

View File

@@ -1,74 +0,0 @@
.eventContentContainer {
padding: var(--spacing-6);
:global(.ant-table) {
background: var(--l1-background);
:global(.ant-table-row:hover) {
:global(.ant-table-cell .value-field .action-btn) {
display: flex;
position: absolute;
top: 50%;
right: 16px;
transform: translateY(-50%);
gap: 4px;
}
}
:global(.ant-table-cell) {
border: 1px solid var(--l2-border) !important;
}
:global(.attribute-name .ant-btn:hover) {
background-color: transparent !important;
}
:global(.attribute-pin .log-attribute-pin) {
padding: 8px;
display: flex;
justify-content: center;
align-items: center;
}
:global(.attribute-pin .pin-attribute-icon) {
border: none;
}
:global(.attribute-pin .pin-attribute-icon.pinned svg) {
fill: var(--bg-robin-500);
}
:global(.value-field-container) {
background: var(--l3-background);
}
:global(.attribute-pin.value-field-container) {
background: var(--l1-background);
}
:global(.value-field-container .value-field) {
font-family: 'Geist Mono';
position: relative;
}
:global(.value-field-container .action-btn) {
display: none;
width: max-content;
position: absolute;
right: 0;
}
:global(.value-field-container .action-btn .filter-btn) {
display: flex;
align-items: center;
border: none;
box-shadow: none;
border-radius: 2px;
background: var(--l3-background);
padding: 2px 3px;
gap: 3px;
height: 18px;
width: 20px;
}
}
}

View File

@@ -1,52 +0,0 @@
import { useMemo } from 'react';
import type { ColumnsType } from 'antd/lib/table';
import { ResizeTable } from 'components/ResizeTable';
import FieldRenderer from 'container/LogDetailedView/FieldRenderer';
import { DataType } from 'container/LogDetailedView/TableView';
import styles from './EventsContent.module.scss';
export function EventContents({
data,
}: {
data: Record<string, string> | undefined;
}): JSX.Element {
const tableData = useMemo(
() =>
data ? Object.keys(data).map((key) => ({ key, value: data[key] })) : [],
[data],
);
const columns: ColumnsType<DataType> = [
{
title: 'Key',
dataIndex: 'key',
key: 'key',
width: 50,
align: 'left',
className: 'attribute-pin value-field-container',
render: (field: string): JSX.Element => <FieldRenderer field={field} />,
},
{
title: 'Value',
dataIndex: 'value',
key: 'value',
width: 50,
align: 'left',
ellipsis: true,
className: 'attribute-name',
render: (field: string): JSX.Element => <FieldRenderer field={field} />,
},
];
return (
<ResizeTable
columns={columns}
tableLayout="fixed"
dataSource={tableData}
pagination={false}
showHeader={false}
className={styles.eventContentContainer}
/>
);
}

View File

@@ -1,40 +0,0 @@
.container {
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
height: 240px;
}
.content {
display: flex;
flex-direction: column;
gap: 4px;
color: var(--muted-foreground);
font-size: 14px;
font-weight: 400;
line-height: 18px;
letter-spacing: -0.07px;
}
.icon {
height: 32px;
width: 32px;
}
.title {
font-weight: 600;
}
.learnMore {
display: flex;
align-items: center;
margin-top: 8px;
gap: 4px;
cursor: pointer;
}
.learnMoreText {
color: var(--text-robin-400);
font-weight: 500;
}

View File

@@ -1,46 +0,0 @@
import { Typography } from '@signozhq/ui/typography';
import { ArrowRight } from '@signozhq/icons';
import { openInNewTab } from 'utils/navigation';
import emptyStateUrl from '@/assets/Icons/emptyState.svg';
import styles from './EventsNotConfigured.module.scss';
const K8S_EVENTS_DOCS_URL =
'https://signoz.io/docs/infrastructure-monitoring/k8s-metrics/';
export default function EventsNotConfigured(): JSX.Element {
const handleLearnMore = (): void => {
openInNewTab(K8S_EVENTS_DOCS_URL);
};
return (
<div className={styles.container}>
<div className={styles.content}>
<img src={emptyStateUrl} alt="not-configured" className={styles.icon} />
<Typography.Text>
<span className={styles.title}>No Kubernetes events received yet. </span>
To view events, enable the k8s events receiver in your OpenTelemetry
Collector.
</Typography.Text>
<div
className={styles.learnMore}
onClick={handleLearnMore}
role="button"
tabIndex={0}
onKeyDown={(e): void => {
if (e.key === 'Enter') {
handleLearnMore();
}
}}
>
<Typography.Link className={styles.learnMoreText}>
Learn how to configure
</Typography.Link>
<ArrowRight size={14} />
</div>
</div>
</div>
);
}

View File

@@ -1,100 +0,0 @@
import { mockQueryRangeV5WithEventsResponse } from '__tests__/query_range_v5.util';
import { InfraMonitoringEntity } from 'container/InfraMonitoringK8sV2/constants';
import { NuqsTestingAdapter } from 'nuqs/adapters/testing';
import { act, render, screen, waitFor } from 'tests/test-utils';
import { QueryRangePayloadV5 } from 'types/api/v5/queryRange';
import EntityEvents from '../EntityEvents';
import { K8S_ENTITY_EVENTS_EXPRESSION_KEY } from '../hooks';
function verifyEntityEventsV5Request({
payload,
expectedOffset,
initialTimeRange,
}: {
payload: QueryRangePayloadV5;
expectedOffset: number;
initialTimeRange?: { start: number; end: number };
}): void {
const spec = payload.compositeQuery.queries[0]?.spec as {
offset?: number;
order?: Array<{ key: { name: string }; direction: string }>;
};
expect(spec.offset).toBe(expectedOffset);
if (initialTimeRange) {
expect(payload.start).toBe(initialTimeRange.start);
expect(payload.end).toBe(initialTimeRange.end);
}
const orderKeys = spec.order?.map((o) => o.key.name) ?? [];
expect(orderKeys).toContain('timestamp');
}
jest.mock('container/TopNav/DateTimeSelectionV2/index.tsx', () => ({
__esModule: true,
default: ({
onTimeChange,
}: {
onTimeChange?: (interval: string, dateTimeRange?: [number, number]) => void;
}): JSX.Element => (
<button
type="button"
data-testid="mock-datetime-selection"
onClick={(): void => {
onTimeChange?.('5m');
}}
>
Select Time
</button>
),
}));
describe('EntityEvents', () => {
let capturedQueryRangePayloads: QueryRangePayloadV5[] = [];
beforeEach(() => {
capturedQueryRangePayloads = [];
mockQueryRangeV5WithEventsResponse({
onReceiveRequest: async (req) => {
const body = (await req.json()) as QueryRangePayloadV5;
capturedQueryRangePayloads.push(body);
return {};
},
});
});
it('should use V5 API for fetching events', async () => {
act(() => {
render(
<NuqsTestingAdapter
searchParams={`${K8S_ENTITY_EVENTS_EXPRESSION_KEY}=k8s.pod.name+%3D+%22x%22`}
>
<EntityEvents
timeRange={{ startTime: 1, endTime: 2 }}
isModalTimeSelection={false}
handleTimeChange={jest.fn()}
selectedInterval="5m"
queryKey="test"
category={InfraMonitoringEntity.PODS}
initialExpression='k8s.pod.name = "x"'
/>
</NuqsTestingAdapter>,
);
});
await waitFor(() => {
expect(
screen.queryByText('pending_data_placeholder'),
).not.toBeInTheDocument();
});
await waitFor(() => {
expect(capturedQueryRangePayloads).toHaveLength(1);
});
const firstPayload = capturedQueryRangePayloads[0];
verifyEntityEventsV5Request({
payload: firstPayload,
expectedOffset: 0,
});
});
});

View File

@@ -1,122 +0,0 @@
import { useCallback, useMemo } from 'react';
import { useQuery, useQueryClient } from 'react-query';
import { ENTITY_VERSION_V5 } from 'constants/app';
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
import { GetMetricQueryRange } from 'lib/dashboard/getQueryResults';
import { getEntityEventsQueryPayload } from './utils';
export const K8S_ENTITY_EVENTS_EXPRESSION_KEY = 'k8sEntityEventsExpression';
export interface EventRowData {
id: string;
body: string;
severity_text: string;
attributes_string?: Record<string, string>;
resources_string?: Record<string, string>;
}
export interface EventRow {
timestamp: string;
data: EventRowData;
}
export function useEntityEvents({
queryKey,
timeRange,
expression,
offset = 0,
pageSize = 10,
}: {
queryKey: string;
timeRange: { startTime: number; endTime: number };
expression: string;
offset?: number;
pageSize?: number;
}): {
events: EventRow[];
isLoading: boolean;
isFetching: boolean;
isError: boolean;
error?: unknown;
currentCount: number;
hasMore: boolean;
refetch: () => void;
cancel: () => void;
reactQueryKey: unknown[];
} {
const reactQueryKey = useMemo(
() => [
// TODO: remove AUTO_REFRESH_QUERY when migrating to date time selection v3
REACT_QUERY_KEY.AUTO_REFRESH_QUERY,
queryKey,
timeRange.startTime,
timeRange.endTime,
expression,
offset,
pageSize,
],
[
queryKey,
timeRange.startTime,
timeRange.endTime,
expression,
offset,
pageSize,
],
);
const { data, isLoading, isFetching, isError, error, refetch } = useQuery({
queryKey: reactQueryKey,
queryFn: async ({ signal }) => {
const { query } = getEntityEventsQueryPayload({
start: timeRange.startTime,
end: timeRange.endTime,
expression,
offset,
pageSize,
});
return GetMetricQueryRange(query, ENTITY_VERSION_V5, undefined, signal);
},
enabled: !!expression?.trim(),
});
const result = data?.payload?.data?.newResult?.data?.result?.[0];
const events = useMemo<EventRow[]>(() => {
const list = result?.list;
if (!list) {
return [];
}
return list.map((item) => ({
data: item.data as EventRowData,
timestamp: item.timestamp,
}));
}, [result?.list]);
const currentCount = result?.list?.length || 0;
const hasMore = !!result?.nextCursor || currentCount >= pageSize;
const queryClient = useQueryClient();
const cancel = useCallback(() => {
void queryClient.cancelQueries({
queryKey: reactQueryKey,
});
}, [queryClient, reactQueryKey]);
return {
events,
isLoading,
isFetching,
isError,
error,
currentCount,
hasMore,
refetch,
cancel,
reactQueryKey,
};
}

View File

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

View File

@@ -1,130 +0,0 @@
import {
initialQueryBuilderFormValuesMap,
PANEL_TYPES,
} from 'constants/queryBuilder';
import { GetQueryResultsProps } from 'lib/dashboard/getQueryResults';
import { DataTypes } from 'types/api/queryBuilder/queryAutocompleteResponse';
import { IBuilderQuery } from 'types/api/queryBuilder/queryBuilderData';
import { EQueryType } from 'types/common/dashboard';
import {
DataSource,
LogsAggregatorOperator,
ReduceOperators,
} from 'types/common/queryBuilder';
import APIError from 'types/api/error';
import { v4 as uuidv4 } from 'uuid';
const K8S_EVENT_KEYS = ['k8s.object.kind', 'k8s.object.name'];
export function isEventsKeyNotFoundError(error: unknown): boolean {
if (!(error instanceof APIError)) {
return false;
}
const errorDetails = error.getErrorDetails();
if (errorDetails.error.code !== 'invalid_input') {
return false;
}
const errors = errorDetails.error.errors || [];
return errors.some((err) =>
K8S_EVENT_KEYS.some((key) =>
err.message?.includes(`key \`${key}\` not found`),
),
);
}
export interface EntityEventsQueryParams {
start: number;
end: number;
expression: string;
offset?: number;
pageSize?: number;
}
function buildEntityEventsQueryData(
expression: string,
{
offset,
pageSize,
}: {
offset: number;
pageSize: number;
},
): IBuilderQuery {
return {
...initialQueryBuilderFormValuesMap.logs,
queryName: 'A',
dataSource: DataSource.LOGS,
aggregateOperator: LogsAggregatorOperator.NOOP,
aggregateAttribute: {
id: '------false',
dataType: DataTypes.String,
key: '',
type: '',
},
timeAggregation: 'rate',
spaceAggregation: 'sum',
functions: [],
aggregations: [],
filter: { expression },
expression,
having: {
expression: '',
},
disabled: false,
stepInterval: 60,
limit: null,
orderBy: [
{
columnName: 'timestamp',
order: 'desc',
},
{
columnName: 'id',
order: 'desc',
},
],
groupBy: [],
legend: '',
reduceTo: ReduceOperators.AVG,
offset,
pageSize,
};
}
const DEFAULT_PAGE_SIZE = 10;
export function getEntityEventsQueryPayload({
start,
end,
expression,
offset = 0,
pageSize = DEFAULT_PAGE_SIZE,
}: EntityEventsQueryParams): {
query: GetQueryResultsProps;
queryData: IBuilderQuery;
} {
const queryData = buildEntityEventsQueryData(expression, { offset, pageSize });
return {
query: {
graphType: PANEL_TYPES.LIST,
selectedTime: 'GLOBAL_TIME',
query: {
clickhouse_sql: [],
promql: [],
builder: {
queryData: [queryData],
queryFormulas: [],
queryTraceOperator: [],
},
id: uuidv4(),
queryType: EQueryType.QUERY_BUILDER,
},
start,
end,
},
queryData,
};
}

View File

@@ -1,109 +0,0 @@
.container {
margin-top: 1rem;
}
.filterContainer {
display: flex;
flex-direction: column;
gap: 8px;
padding: var(--spacing-6);
border-radius: var(--radius);
border: 1px solid var(--l1-border);
:global(.ant-select-selector) {
border-radius: 2px;
border: 1px solid var(--l1-border) !important;
background-color: var(--l3-background) !important;
input {
font-size: 12px;
}
:global([data-slot='badge'] .ant-typography) {
font-size: 12px;
}
}
}
.filterContainerTime {
display: flex;
justify-content: flex-end;
align-items: center;
gap: 8px;
}
.filterQuerySearch {
flex: 1;
}
.logs {
border: 1px solid var(--border);
margin-top: 1rem;
:global(.virtuoso-list) {
overflow-y: hidden !important;
&::-webkit-scrollbar {
width: 0.3rem;
height: 0.3rem;
}
&::-webkit-scrollbar-track {
background: transparent;
}
&::-webkit-scrollbar-thumb {
background: var(--primary);
}
&::-webkit-scrollbar-thumb:hover {
background: var(--primary);
}
:global(.ant-row) {
width: fit-content;
}
}
.skeletonContainer {
height: 100%;
padding: 16px;
}
}
.listContainer {
flex: 1;
height: calc(100vh - 312px) !important;
display: flex;
height: 100%;
:global(.raw-log-content) {
width: 100%;
text-wrap: inherit;
word-wrap: break-word;
}
}
.listCard {
width: 100%;
margin-top: 12px;
:global(.ant-card-body) {
padding: 0;
height: 100%;
width: 100%;
}
}
.logsLoadingSkeleton {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 8px;
padding: 8px 0;
:global(.ant-skeleton-input-sm) {
height: 18px;
}
}

View File

@@ -1,309 +0,0 @@
import { useCallback, useMemo, useRef } from 'react';
import { Virtuoso, VirtuosoHandle } from 'react-virtuoso';
import { Card } from 'antd';
import logEvent from 'api/common/logEvent';
import LogDetail from 'components/LogDetail';
import RawLogView from 'components/Logs/RawLogView';
import OverlayScrollbar from 'components/OverlayScrollbar/OverlayScrollbar';
import {
QuerySearchV2Provider,
useExpression,
useInitialExpression,
useInputExpression,
useQuerySearchInitialExpressionProp,
useQuerySearchOnChange,
useQuerySearchOnRun,
useUserExpression,
} from 'components/QueryBuilderV2';
import QuerySearch from 'components/QueryBuilderV2/QueryV2/QuerySearch/QuerySearch';
import {
combineInitialAndUserExpression,
getUserExpressionFromCombined,
} from 'components/QueryBuilderV2/QueryV2/QuerySearch/utils';
import { InfraMonitoringEvents } from 'constants/events';
import { InfraMonitoringEntity } from 'container/InfraMonitoringK8sV2/constants';
import { LogsLoading } from 'container/LogsLoading/LogsLoading';
import { FontSize } from 'container/OptionsMenu/types';
import RunQueryBtn from 'container/QueryBuilder/components/RunQueryBtn/RunQueryBtn';
import DateTimeSelectionV2 from 'container/TopNav/DateTimeSelectionV2';
import {
CustomTimeType,
Time,
} from 'container/TopNav/DateTimeSelectionV2/types';
import { getOldLogsOperatorFromNew } from 'hooks/logs/useActiveLog';
import useLogDetailHandlers from 'hooks/logs/useLogDetailHandlers';
import useScrollToLog from 'hooks/logs/useScrollToLog';
import { generateFilterQuery } from 'lib/logs/generateFilterQuery';
import { ILog } from 'types/api/logs/log';
import { DataSource } from 'types/common/queryBuilder';
import { validateQuery } from 'utils/queryValidationUtils';
import EntityEmptyState from '../EntityEmptyState/EntityEmptyState';
import EntityError from '../EntityError/EntityError';
import { isKeyNotFoundError } from '../utils';
import { K8S_ENTITY_LOGS_EXPRESSION_KEY, useInfiniteEntityLogs } from './hooks';
import { getEntityLogsQueryPayload } from './utils';
import styles from './EntityLogs.module.scss';
interface Props {
timeRange: {
startTime: number;
endTime: number;
};
isModalTimeSelection: boolean;
handleTimeChange: (
interval: Time | CustomTimeType,
dateTimeRange?: [number, number],
) => void;
selectedInterval: Time;
queryKey: string;
category: InfraMonitoringEntity;
initialExpression: string;
}
function EntityLogsContent({
timeRange,
isModalTimeSelection,
handleTimeChange,
selectedInterval,
queryKey,
category,
}: Omit<Props, 'initialExpression'>): JSX.Element {
const virtuosoRef = useRef<VirtuosoHandle>(null);
const expression = useExpression();
const inputExpression = useInputExpression();
const userExpression = useUserExpression();
const initialExpression = useInitialExpression();
const querySearchOnChange = useQuerySearchOnChange();
const querySearchOnRun = useQuerySearchOnRun();
const querySearchInitialExpressionProp = useQuerySearchInitialExpressionProp();
const { activeLog, selectedTab, handleSetActiveLog, handleCloseLogDetail } =
useLogDetailHandlers();
const onAddToQuery = useCallback(
(fieldKey: string, fieldValue: string, operator: string): void => {
handleCloseLogDetail();
const partExpression = generateFilterQuery({
fieldKey,
fieldValue,
type: getOldLogsOperatorFromNew(operator),
});
const currentUser = userExpression;
const newUser = currentUser.trim()
? `${currentUser} AND ${partExpression}`
: partExpression;
querySearchOnRun(newUser);
},
[userExpression, querySearchOnRun, handleCloseLogDetail],
);
const {
logs,
loadMoreLogs,
hasNextPage,
isFetchingNextPage,
isLoading,
isFetching,
isError,
error,
refetch,
cancel,
} = useInfiniteEntityLogs({
queryKey,
timeRange,
expression,
});
const handleRunQuery = useCallback(
(updatedExpression?: string): void => {
const newUserExpression = updatedExpression
? getUserExpressionFromCombined(initialExpression, updatedExpression)
: inputExpression;
const validation = validateQuery(
initialExpression
? combineInitialAndUserExpression(initialExpression, newUserExpression)
: newUserExpression || '',
);
if (validation.isValid) {
querySearchOnRun(newUserExpression);
logEvent(InfraMonitoringEvents.FilterApplied, {
entity: InfraMonitoringEvents.K8sEntity,
page: InfraMonitoringEvents.DetailedPage,
category,
view: InfraMonitoringEvents.LogsView,
});
refetch();
}
},
[inputExpression, initialExpression, refetch, querySearchOnRun, category],
);
const queryData = useMemo(
() =>
getEntityLogsQueryPayload({
start: timeRange.startTime,
end: timeRange.endTime,
expression: userExpression,
}).queryData,
[timeRange.startTime, timeRange.endTime, userExpression],
);
const handleScrollToLog = useScrollToLog({
logs,
virtuosoRef,
});
const getItemContent = useCallback(
(_: number, logToRender: ILog): JSX.Element => {
return (
<div key={logToRender.id}>
<RawLogView
isTextOverflowEllipsisDisabled
data={logToRender}
linesPerRow={5}
fontSize={FontSize.MEDIUM}
selectedFields={[
{
dataType: 'string',
type: '',
name: 'body',
},
{
dataType: 'string',
type: '',
name: 'timestamp',
},
]}
onSetActiveLog={handleSetActiveLog}
onClearActiveLog={handleCloseLogDetail}
isActiveLog={activeLog?.id === logToRender.id}
/>
</div>
);
},
[activeLog, handleSetActiveLog, handleCloseLogDetail],
);
const renderFooter = useCallback(
(): JSX.Element | null => (
<>
{isFetchingNextPage ? (
<div className={styles.logsLoadingSkeleton}> Loading more logs ... </div>
) : !hasNextPage && logs.length > 0 ? (
<div className={styles.logsLoadingSkeleton}> *** End *** </div>
) : null}
</>
),
[isFetchingNextPage, hasNextPage, logs.length],
);
const renderContent = useMemo(
() => (
<Card bordered={false} className={styles.listCard}>
<OverlayScrollbar isVirtuoso>
<Virtuoso
key="entity-logs-virtuoso"
ref={virtuosoRef}
data={logs}
endReached={loadMoreLogs}
totalCount={logs.length}
itemContent={getItemContent}
overscan={200}
components={{
Footer: renderFooter,
}}
/>
</OverlayScrollbar>
</Card>
),
[logs, loadMoreLogs, getItemContent, renderFooter],
);
const showInitialLoading = isLoading || (isFetching && logs.length === 0);
const isKeyNotFound = isKeyNotFoundError(error);
const isDataEmpty =
!showInitialLoading && (!isError || isKeyNotFound) && logs.length === 0;
const hasAdditionalFilters = !!userExpression?.trim();
return (
<div className={styles.container}>
<div className={styles.filterContainer}>
<div className={styles.filterContainerTime}>
<DateTimeSelectionV2
showAutoRefresh
showRefreshText={false}
hideShareModal
isModalTimeSelection={isModalTimeSelection}
onTimeChange={handleTimeChange}
defaultRelativeTime="5m"
modalSelectedInterval={selectedInterval}
modalInitialStartTime={timeRange.startTime * 1000}
modalInitialEndTime={timeRange.endTime * 1000}
/>
<RunQueryBtn
isLoadingQueries={isFetching}
onStageRunQuery={(): void => handleRunQuery()}
handleCancelQuery={cancel}
/>
</div>
<div className={styles.filterQuerySearch}>
<QuerySearch
onChange={querySearchOnChange}
queryData={queryData}
dataSource={DataSource.LOGS}
onRun={handleRunQuery}
initialExpression={querySearchInitialExpressionProp}
/>
</div>
</div>
<div className={styles.logs}>
{showInitialLoading && <LogsLoading />}
{isDataEmpty && <EntityEmptyState hasFilters={hasAdditionalFilters} />}
{isError && !isKeyNotFound && !showInitialLoading && <EntityError />}
{!showInitialLoading && (!isError || isKeyNotFound) && logs.length > 0 && (
<div className={styles.listContainer} data-log-detail-ignore="true">
{renderContent}
</div>
)}
{selectedTab && activeLog && (
<LogDetail
log={activeLog}
onClose={handleCloseLogDetail}
logs={logs}
onNavigateLog={handleSetActiveLog}
selectedTab={selectedTab}
onAddToQuery={onAddToQuery}
onClickActionItem={onAddToQuery}
onScrollToLog={handleScrollToLog}
/>
)}
</div>
</div>
);
}
function EntityLogs({ initialExpression, ...rest }: Props): JSX.Element {
return (
<QuerySearchV2Provider
queryParamKey={K8S_ENTITY_LOGS_EXPRESSION_KEY}
initialExpression={initialExpression}
persistOnUnmount
>
<EntityLogsContent {...rest} />
</QuerySearchV2Provider>
);
}
export default EntityLogs;

View File

@@ -1,185 +0,0 @@
import { VirtuosoMockContext } from 'react-virtuoso';
import { mockQueryRangeV5WithLogsResponse } from '__tests__/query_range_v5.util';
import { InfraMonitoringEntity } from 'container/InfraMonitoringK8sV2/constants';
import { NuqsTestingAdapter } from 'nuqs/adapters/testing';
import {
act,
fireEvent,
render,
RenderResult,
screen,
waitFor,
} from 'tests/test-utils';
import { QueryRangePayloadV5 } from 'types/api/v5/queryRange';
import EntityLogs from '../EntityLogs';
import { K8S_ENTITY_LOGS_EXPRESSION_KEY } from '../hooks';
function verifyEntityLogsV5Request({
payload,
expectedOffset,
initialTimeRange,
}: {
payload: QueryRangePayloadV5;
expectedOffset: number;
initialTimeRange?: { start: number; end: number };
}): void {
const spec = payload.compositeQuery.queries[0]?.spec as {
offset?: number;
order?: Array<{ key: { name: string }; direction: string }>;
};
expect(spec.offset).toBe(expectedOffset);
if (initialTimeRange) {
expect(payload.start).toBe(initialTimeRange.start);
expect(payload.end).toBe(initialTimeRange.end);
}
const orderKeys = spec.order?.map((o) => o.key.name) ?? [];
expect(orderKeys).toContain('timestamp');
expect(orderKeys).toContain('id');
}
jest.mock(
'components/OverlayScrollbar/OverlayScrollbar',
() =>
function MockOverlayScrollbar({
children,
}: {
children: React.ReactNode;
}): JSX.Element {
return <div>{children}</div>;
},
);
jest.mock('container/TopNav/DateTimeSelectionV2/index.tsx', () => ({
__esModule: true,
default: ({
onTimeChange,
}: {
onTimeChange?: (interval: string, dateTimeRange?: [number, number]) => void;
}): JSX.Element => (
<button
type="button"
data-testid="mock-datetime-selection"
onClick={(): void => {
onTimeChange?.('5m');
}}
>
Select Time
</button>
),
}));
describe('EntityLogs', () => {
let capturedQueryRangePayloads: QueryRangePayloadV5[] = [];
const itemHeight = 100;
beforeEach(() => {
capturedQueryRangePayloads = [];
mockQueryRangeV5WithLogsResponse({
onReceiveRequest: async (req) => {
const body = (await req.json()) as QueryRangePayloadV5;
capturedQueryRangePayloads.push(body);
return {};
},
});
});
it('should check if k8s logs pagination flows work properly', async () => {
let renderResult: RenderResult;
let scrollableElement: HTMLElement;
act(() => {
renderResult = render(
<NuqsTestingAdapter
searchParams={`${K8S_ENTITY_LOGS_EXPRESSION_KEY}=k8s.pod.name+%3D+%22x%22`}
>
<VirtuosoMockContext.Provider value={{ viewportHeight: 500, itemHeight }}>
<EntityLogs
timeRange={{ startTime: 1, endTime: 2 }}
isModalTimeSelection={false}
handleTimeChange={jest.fn()}
selectedInterval="5m"
queryKey="test"
category={InfraMonitoringEntity.PODS}
initialExpression='k8s.pod.name = "x"'
/>
</VirtuosoMockContext.Provider>
</NuqsTestingAdapter>,
);
});
await waitFor(() => {
expect(
screen.queryByText('pending_data_placeholder'),
).not.toBeInTheDocument();
});
await waitFor(() => {
expect(capturedQueryRangePayloads).toHaveLength(1);
});
await waitFor(async () => {
scrollableElement = renderResult.container.querySelector(
'[data-test-id="virtuoso-scroller"]',
) as HTMLElement;
expect(scrollableElement).not.toBeNull();
if (scrollableElement) {
scrollableElement.scrollTop = 99 * itemHeight;
act(() => {
fireEvent.scroll(scrollableElement);
});
}
});
await waitFor(() => {
expect(capturedQueryRangePayloads).toHaveLength(2);
});
const firstPayload = capturedQueryRangePayloads[0];
verifyEntityLogsV5Request({
payload: firstPayload,
expectedOffset: 0,
});
const initialTimeRange = {
start: firstPayload.start,
end: firstPayload.end,
};
const secondPayload = capturedQueryRangePayloads[1];
verifyEntityLogsV5Request({
payload: secondPayload,
expectedOffset: 100,
initialTimeRange,
});
await waitFor(async () => {
scrollableElement = renderResult.container.querySelector(
'[data-test-id="virtuoso-scroller"]',
) as HTMLElement;
expect(scrollableElement).not.toBeNull();
if (scrollableElement) {
scrollableElement.scrollTop = 199 * itemHeight;
act(() => {
fireEvent.scroll(scrollableElement);
});
}
});
await waitFor(() => {
expect(capturedQueryRangePayloads.length).toBeGreaterThanOrEqual(3);
});
const thirdPayload = capturedQueryRangePayloads[2];
verifyEntityLogsV5Request({
payload: thirdPayload,
expectedOffset: 200,
initialTimeRange,
});
});
});

View File

@@ -1,186 +0,0 @@
import { QueryClient, QueryClientProvider } from 'react-query';
import { act, renderHook, waitFor } from '@testing-library/react';
import {
mockQueryRangeV5WithError,
mockQueryRangeV5WithLogsResponse,
} from '../../../../../__tests__/query_range_v5.util';
import { useInfiniteEntityLogs } from '../hooks';
const createWrapper = (): React.FC<{ children: React.ReactNode }> => {
const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: false,
},
},
});
return function Wrapper({
children,
}: {
children: React.ReactNode;
}): JSX.Element {
return (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
);
};
};
describe('useInfiniteEntityLogs', () => {
const defaultParams = {
queryKey: 'entityLogsTest',
timeRange: { startTime: 1708000000, endTime: 1708003600 },
expression: 'k8s.pod.name = "test"',
};
describe('initial state', () => {
it('should return initial loading state', () => {
mockQueryRangeV5WithLogsResponse({
delay: 100,
});
const { result } = renderHook(() => useInfiniteEntityLogs(defaultParams), {
wrapper: createWrapper(),
});
expect(result.current.isLoading).toBe(true);
expect(result.current.logs).toStrictEqual([]);
});
});
describe('successful data fetching', () => {
it('should return logs after successful fetch', async () => {
mockQueryRangeV5WithLogsResponse({
pageSize: 5,
hasMore: true,
});
const { result } = renderHook(() => useInfiniteEntityLogs(defaultParams), {
wrapper: createWrapper(),
});
await waitFor(() => {
expect(result.current.isLoading).toBe(false);
});
expect(result.current.error).toBeFalsy();
expect(result.current.logs).toHaveLength(5);
expect(result.current.isError).toBe(false);
});
it('should set hasNextPage based on response size', async () => {
mockQueryRangeV5WithLogsResponse({
pageSize: 100,
hasMore: true,
});
const { result } = renderHook(() => useInfiniteEntityLogs(defaultParams), {
wrapper: createWrapper(),
});
await waitFor(() => {
expect(result.current.isLoading).toBe(false);
});
expect(result.current.hasNextPage).toBe(true);
});
it('should not have next page when response is smaller than page size', async () => {
mockQueryRangeV5WithLogsResponse({
pageSize: 100,
hasMore: false,
});
const { result } = renderHook(() => useInfiniteEntityLogs(defaultParams), {
wrapper: createWrapper(),
});
await waitFor(() => {
expect(result.current.isLoading).toBe(false);
});
expect(result.current.hasNextPage).toBe(false);
});
});
describe('empty state', () => {
it('should return empty logs array when no data', async () => {
mockQueryRangeV5WithLogsResponse({
pageSize: 0,
hasMore: false,
});
const { result } = renderHook(() => useInfiniteEntityLogs(defaultParams), {
wrapper: createWrapper(),
});
await waitFor(() => {
expect(result.current.isLoading).toBe(false);
});
expect(result.current.logs).toStrictEqual([]);
expect(result.current.hasNextPage).toBe(false);
});
});
describe('error handling', () => {
it('should set isError on API failure', async () => {
mockQueryRangeV5WithError('Internal Server Error');
const { result } = renderHook(() => useInfiniteEntityLogs(defaultParams), {
wrapper: createWrapper(),
});
await waitFor(() => {
expect(result.current.isError).toBe(true);
});
expect(result.current.logs).toStrictEqual([]);
});
});
describe('load more functionality', () => {
it('should fetch next page when loadMoreLogs is called', async () => {
const requestCount = { count: 0 };
mockQueryRangeV5WithLogsResponse({
pageSize: 100,
offset: 0,
hasMore: true,
onReceiveRequest: () => {
requestCount.count += 1;
if (requestCount.count > 1) {
return { offset: 100, pageSize: 100, hasMore: false };
}
return undefined;
},
});
const { result } = renderHook(() => useInfiniteEntityLogs(defaultParams), {
wrapper: createWrapper(),
});
await waitFor(() => {
expect(result.current.isLoading).toBe(false);
});
expect(result.current.logs).toHaveLength(100);
expect(result.current.hasNextPage).toBe(true);
expect(requestCount.count).toBe(1);
act(() => {
result.current.loadMoreLogs();
});
await waitFor(() => {
expect(result.current.logs).toHaveLength(150);
});
expect(result.current.hasNextPage).toBe(false);
expect(requestCount.count).toBe(2);
});
});
});

View File

@@ -1,126 +0,0 @@
import { useCallback, useMemo } from 'react';
import { useInfiniteQuery, useQueryClient } from 'react-query';
import { ENTITY_VERSION_V5 } from 'constants/app';
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
import { DEFAULT_PER_PAGE_VALUE } from 'container/Controls/config';
import { GetMetricQueryRange } from 'lib/dashboard/getQueryResults';
import { ILog } from 'types/api/logs/log';
import { getEntityLogsQueryPayload } from './utils';
export const K8S_ENTITY_LOGS_EXPRESSION_KEY = 'k8sEntityLogsExpression';
export function useInfiniteEntityLogs({
queryKey,
timeRange,
expression,
}: {
queryKey: string;
timeRange: { startTime: number; endTime: number };
expression: string;
}): {
logs: ILog[];
isLoading: boolean;
isFetching: boolean;
isFetchingNextPage: boolean;
isError: boolean;
error?: unknown;
hasNextPage: boolean;
loadMoreLogs: () => void;
refetch: () => void;
cancel: () => void;
reactQueryKey: unknown[];
} {
const reactQueryKey = useMemo(
() => [
// TODO: remove AUTO_REFRESH_QUERY when migrating to date time selection v3
REACT_QUERY_KEY.AUTO_REFRESH_QUERY,
queryKey,
timeRange.startTime,
timeRange.endTime,
expression,
],
[queryKey, timeRange.startTime, timeRange.endTime, expression],
);
const {
data,
isLoading,
isFetching,
isFetchingNextPage,
isError,
error,
hasNextPage,
fetchNextPage,
refetch,
} = useInfiniteQuery({
queryKey: reactQueryKey,
queryFn: async ({ pageParam = 0, signal }) => {
const { query } = getEntityLogsQueryPayload({
start: timeRange.startTime,
end: timeRange.endTime,
expression,
offset: pageParam as number,
pageSize: DEFAULT_PER_PAGE_VALUE,
});
return GetMetricQueryRange(query, ENTITY_VERSION_V5, undefined, signal);
},
getNextPageParam: (lastPage, allPages) => {
const list = lastPage?.payload?.data?.newResult?.data?.result?.[0]?.list;
if (!list || list.length < DEFAULT_PER_PAGE_VALUE) {
return;
}
return allPages.length * DEFAULT_PER_PAGE_VALUE;
},
enabled: !!expression?.trim(),
});
const logs = useMemo<ILog[]>(() => {
if (!data?.pages) {
return [];
}
return data.pages.flatMap((page) => {
const list = page.payload.data.newResult.data.result?.[0]?.list;
if (!list) {
return [];
}
return list.map(
(item) =>
({
...item.data,
timestamp: item.timestamp,
}) as ILog,
);
});
}, [data?.pages]);
const loadMoreLogs = useCallback(() => {
if (hasNextPage && !isFetchingNextPage) {
void fetchNextPage();
}
}, [hasNextPage, isFetchingNextPage, fetchNextPage]);
const queryClient = useQueryClient();
const cancel = useCallback(() => {
void queryClient.cancelQueries({
queryKey: reactQueryKey,
});
}, [queryClient, reactQueryKey]);
return {
logs,
isLoading,
isFetching,
isFetchingNextPage,
isError,
error,
hasNextPage: !!hasNextPage,
loadMoreLogs,
refetch,
cancel,
reactQueryKey,
};
}

View File

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

View File

@@ -1,108 +0,0 @@
import {
initialQueryBuilderFormValuesMap,
PANEL_TYPES,
} from 'constants/queryBuilder';
import { DEFAULT_PER_PAGE_VALUE } from 'container/Controls/config';
import { GetQueryResultsProps } from 'lib/dashboard/getQueryResults';
import { DataTypes } from 'types/api/queryBuilder/queryAutocompleteResponse';
import { IBuilderQuery } from 'types/api/queryBuilder/queryBuilderData';
import { EQueryType } from 'types/common/dashboard';
import {
DataSource,
LogsAggregatorOperator,
ReduceOperators,
} from 'types/common/queryBuilder';
import { v4 as uuidv4 } from 'uuid';
export interface EntityLogsQueryParams {
start: number;
end: number;
expression: string;
offset?: number;
pageSize?: number;
}
function buildEntityLogsQueryData(
expression: string,
{
offset,
pageSize,
}: {
offset: number;
pageSize: number;
},
): IBuilderQuery {
return {
...initialQueryBuilderFormValuesMap.logs,
queryName: 'A',
dataSource: DataSource.LOGS,
aggregateOperator: LogsAggregatorOperator.NOOP,
aggregateAttribute: {
id: '------false',
dataType: DataTypes.String,
key: '',
type: '',
},
timeAggregation: 'rate',
spaceAggregation: 'sum',
functions: [],
aggregations: [],
filter: { expression },
expression,
having: {
expression: '',
},
disabled: false,
stepInterval: 60,
limit: null,
orderBy: [
{
columnName: 'timestamp',
order: 'desc',
},
{
columnName: 'id',
order: 'desc',
},
],
groupBy: [],
legend: '',
reduceTo: ReduceOperators.AVG,
offset,
pageSize,
};
}
export function getEntityLogsQueryPayload({
start,
end,
expression,
offset = 0,
pageSize = DEFAULT_PER_PAGE_VALUE,
}: EntityLogsQueryParams): {
query: GetQueryResultsProps;
queryData: IBuilderQuery;
} {
const queryData = buildEntityLogsQueryData(expression, { offset, pageSize });
return {
query: {
graphType: PANEL_TYPES.LIST,
selectedTime: 'GLOBAL_TIME',
query: {
clickhouse_sql: [],
promql: [],
builder: {
queryData: [queryData],
queryFormulas: [],
queryTraceOperator: [],
},
id: uuidv4(),
queryType: EQueryType.QUERY_BUILDER,
},
start,
end,
},
queryData,
};
}

View File

@@ -1,52 +0,0 @@
.entityMetricsContainer {
display: flex;
flex-wrap: wrap;
margin-top: 1rem;
margin-left: -12px;
margin-right: -12px;
}
.entityMetricsCol {
flex: 0 0 50%;
max-width: 50%;
padding-left: 12px;
padding-right: 12px;
box-sizing: border-box;
}
.entityMetricsTitle {
font-size: var(--periscope-font-size-base);
color: var(--l2-foreground);
}
.metricsHeader {
display: flex;
justify-content: flex-end;
margin-top: 1rem;
gap: 8px;
padding: 12px;
border-radius: 3px;
border: 1px solid var(--l1-border);
}
.entityMetricsCard {
position: relative;
margin: 8px 0 1rem 0;
height: 300px;
padding: 10px;
border: 1px solid var(--l1-border);
border-radius: 3px;
.chartContainer {
width: 100%;
height: 100%;
}
.noDataContainer {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
}
}

View File

@@ -1,220 +0,0 @@
import { useCallback, useMemo, useRef } from 'react';
import { UseQueryResult } from 'react-query';
import { Skeleton } from 'antd';
import cx from 'classnames';
import { PANEL_TYPES } from 'constants/queryBuilder';
import TimeSeries from 'container/DashboardContainer/visualization/charts/TimeSeries/TimeSeries';
import { LegendPosition } from 'lib/uPlotV2/components/types';
import { InfraMonitoringEntity } from 'container/InfraMonitoringK8sV2/constants';
import DateTimeSelectionV2 from 'container/TopNav/DateTimeSelectionV2';
import {
CustomTimeType,
Time,
} from 'container/TopNav/DateTimeSelectionV2/types';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { useIsDarkMode } from 'hooks/useDarkMode';
import { useResizeObserver } from 'hooks/useDimensions';
import { useMultiIntersectionObserver } from 'hooks/useMultiIntersectionObserver';
import { GetQueryResultsProps } from 'lib/dashboard/getQueryResults';
import { useTimezone } from 'providers/Timezone';
import { SuccessResponse } from 'types/api';
import { MetricRangePayloadProps } from 'types/api/metrics/getQueryRange';
import { buildEntityMetricsChartConfig } from './configBuilder';
import { useEntityMetrics } from './hooks';
import { isKeyNotFoundError } from '../utils';
import styles from './EntityMetrics.module.scss';
import { MetricsTable } from './MetricsTable';
import { DEFAULT_TIME_RANGE } from 'container/TopNav/DateTimeSelectionV2/constants';
interface EntityMetricsProps<T> {
timeRange: {
startTime: number;
endTime: number;
};
isModalTimeSelection: boolean;
handleTimeChange: (
interval: Time | CustomTimeType,
dateTimeRange?: [number, number],
) => void;
selectedInterval: Time;
entity: T;
entityWidgetInfo: {
title: string;
yAxisUnit: string;
}[];
getEntityQueryPayload: (
node: T,
start: number,
end: number,
dotMetricsEnabled: boolean,
) => GetQueryResultsProps[];
queryKey: string;
category: InfraMonitoringEntity;
}
function EntityMetrics<T>({
selectedInterval,
entity,
timeRange,
handleTimeChange,
isModalTimeSelection,
entityWidgetInfo,
getEntityQueryPayload,
queryKey,
category,
}: EntityMetricsProps<T>): JSX.Element {
const { visibilities, setElement } = useMultiIntersectionObserver(
entityWidgetInfo.length,
{ threshold: 0.1 },
);
const { queries, chartData, tableData, queryPayloads } = useEntityMetrics({
queryKey,
timeRange,
entity,
getEntityQueryPayload,
visibilities,
category,
});
const isDarkMode = useIsDarkMode();
const { timezone } = useTimezone();
const graphRef = useRef<HTMLDivElement>(null);
const dimensions = useResizeObserver(graphRef);
const { currentQuery } = useQueryBuilder();
const onDragSelect = useCallback(
(start: number, end: number): void => {
const startTimestamp = Math.trunc(start);
const endTimestamp = Math.trunc(end);
handleTimeChange('custom', [startTimestamp, endTimestamp]);
},
[handleTimeChange],
);
const configs = useMemo(
() =>
queries.map(({ data }, idx) => {
const panelType = queryPayloads[idx]?.graphType;
if (panelType === PANEL_TYPES.TABLE) {
return null;
}
const widgetTitle = entityWidgetInfo[idx].title
.toLowerCase()
.replace(/\s+/g, '-');
return buildEntityMetricsChartConfig({
id: `${category}-${widgetTitle}`,
isDarkMode,
currentQuery,
onDragSelect,
apiResponse: data?.payload,
timezone,
yAxisUnit: entityWidgetInfo[idx].yAxisUnit,
minTimeScale: timeRange.startTime,
maxTimeScale: timeRange.endTime,
});
}),
[
queries,
queryPayloads,
category,
isDarkMode,
currentQuery,
onDragSelect,
timezone,
entityWidgetInfo,
timeRange.startTime,
timeRange.endTime,
],
);
const renderCardContent = (
query: UseQueryResult<SuccessResponse<MetricRangePayloadProps>, unknown>,
idx: number,
): JSX.Element => {
if (
(!query.data && query.isLoading) ||
query.isFetching ||
!visibilities[idx]
) {
return <Skeleton />;
}
if (query.error && !isKeyNotFoundError(query.error)) {
const errorMessage =
(query.error as Error)?.message || 'Something went wrong';
return <div>{errorMessage}</div>;
}
const panelType = queryPayloads[idx]?.graphType;
return (
<div
className={cx(styles.chartContainer, {
[styles.noDataContainer]:
!query.isLoading && !query?.data?.payload?.data?.result?.length,
})}
>
{panelType === PANEL_TYPES.TABLE ? (
<MetricsTable
rows={tableData[idx]?.[0]?.rows ?? []}
columns={tableData[idx]?.[0]?.columns ?? []}
/>
) : (
configs[idx] &&
chartData[idx] && (
<TimeSeries
config={configs[idx]}
data={chartData[idx]}
legendConfig={{ position: LegendPosition.BOTTOM }}
width={dimensions.width}
height={dimensions.height}
timezone={timezone}
yAxisUnit={entityWidgetInfo[idx].yAxisUnit}
/>
)
)}
</div>
);
};
return (
<>
<div className={styles.metricsHeader}>
<DateTimeSelectionV2
showAutoRefresh
showRefreshText={false}
hideShareModal
onTimeChange={handleTimeChange}
defaultRelativeTime={DEFAULT_TIME_RANGE}
isModalTimeSelection={isModalTimeSelection}
modalSelectedInterval={selectedInterval}
modalInitialStartTime={timeRange.startTime * 1000}
modalInitialEndTime={timeRange.endTime * 1000}
/>
</div>
<div className={styles.entityMetricsContainer}>
{queries.map((query, idx) => (
<div
ref={setElement(idx)}
key={entityWidgetInfo[idx].title}
className={styles.entityMetricsCol}
>
<span className={styles.entityMetricsTitle}>
{entityWidgetInfo[idx].title}
</span>
<div className={styles.entityMetricsCard} ref={graphRef}>
{renderCardContent(query, idx)}
</div>
</div>
))}
</div>
</>
);
}
export default EntityMetrics;

View File

@@ -1,7 +0,0 @@
.table {
height: 100%;
}
.paginationContainer {
margin: 0;
}

View File

@@ -1,99 +0,0 @@
import { useCallback, useMemo, useState } from 'react';
import TanStackTable, { TableColumnDef } from 'components/TanStackTableView';
import { SortState } from 'components/TanStackTableView/types';
import styles from './MetricsTable.module.scss';
import { MetricsColumn } from './utils';
interface MetricsTableProps {
rows: Record<string, string>[];
columns: MetricsColumn[];
}
const DEFAULT_PAGE = 1;
const DEFAULT_LIMIT = 7; // the sweetspot for the amount of items without showing the scrollbar
export function MetricsTable({
rows,
columns,
}: MetricsTableProps): JSX.Element {
const [page, setPage] = useState(DEFAULT_PAGE);
const [limit, setLimit] = useState(DEFAULT_LIMIT);
const [orderBy, setOrderBy] = useState<SortState | null>(null);
const sortedRows = useMemo(() => {
if (!orderBy) {
return rows;
}
const { columnName, order } = orderBy;
return [...rows].sort((a, b) => {
const aVal = parseFloat(a[columnName]) || 0;
const bVal = parseFloat(b[columnName]) || 0;
return order === 'asc' ? aVal - bVal : bVal - aVal;
});
}, [rows, orderBy]);
const paginatedRows = useMemo(() => {
const startIndex = (page - 1) * limit;
return sortedRows.slice(startIndex, startIndex + limit);
}, [sortedRows, page, limit]);
const handlePageChange = useCallback((newPage: number) => {
setPage(newPage);
}, []);
const handleLimitChange = useCallback((newLimit: number) => {
setLimit(newLimit);
setPage(DEFAULT_PAGE);
}, []);
const columnDefs = useMemo(
() =>
columns.map(
(col, index) =>
({
id: col.key,
accessorKey: col.key as keyof Record<string, string>,
header: (): JSX.Element => (
<TanStackTable.Text title={col.label}>{col.label}</TanStackTable.Text>
),
cell: ({ value }): JSX.Element => {
const displayValue = String(value ?? '');
return (
<TanStackTable.Text title={displayValue}>
{displayValue}
</TanStackTable.Text>
);
},
enableMove: false,
enableResize: false,
enableRemove: false,
enableSort: col.isValueColumn,
width: {
min: index === 0 ? 220 : Math.max(col.label?.length * 12, 80) || 100,
},
}) satisfies TableColumnDef<Record<string, string>>,
),
[columns],
);
return (
<TanStackTable<Record<string, string>>
className={styles.table}
data={paginatedRows}
columns={columnDefs}
onSort={setOrderBy}
pagination={{
total: rows.length,
defaultPage: page,
defaultLimit: limit,
onPageChange: handlePageChange,
onLimitChange: handleLimitChange,
showPageSize: false,
}}
paginationClassname={styles.paginationContainer}
getRowKey={(row) => row.key}
getItemKey={(row) => row.key}
/>
);
}

View File

@@ -1,456 +0,0 @@
import { render, screen } from '@testing-library/react';
import { InfraMonitoringEntity } from 'container/InfraMonitoringK8sV2/constants';
import { Time } from 'container/TopNav/DateTimeSelectionV2/types';
import * as appContextHooks from 'providers/App/App';
import { LicenseEvent } from 'types/api/licensesV3/getActive';
import uPlot from 'uplot';
import EntityMetrics from '../EntityMetrics';
import { useEntityMetrics } from '../hooks';
jest.mock('../hooks', () => ({
useEntityMetrics: jest.fn(),
}));
const mockUseEntityMetrics = useEntityMetrics as jest.MockedFunction<
typeof useEntityMetrics
>;
jest.mock('../configBuilder', () => ({
buildEntityMetricsChartConfig: jest.fn().mockReturnValue({
getId: jest.fn().mockReturnValue('mock-id'),
}),
}));
jest.mock('lib/uPlotV2/utils/dataUtils', () => ({
prepareChartData: jest.fn().mockReturnValue([]),
hasSingleVisiblePoint: jest.fn().mockReturnValue(false),
}));
jest.mock('container/TopNav/DateTimeSelectionV2', () => ({
__esModule: true,
default: (): JSX.Element => (
<div data-testid="date-time-selection">Date Time</div>
),
}));
jest.mock(
'container/DashboardContainer/visualization/charts/TimeSeries/TimeSeries',
() => ({
__esModule: true,
default: (): JSX.Element => (
<div data-testid="uplot-chart">TimeSeries Chart</div>
),
}),
);
jest.mock('providers/Timezone', () => ({
useTimezone: (): { timezone: { value: string } } => ({
timezone: { value: 'UTC' },
}),
}));
jest.mock('../MetricsTable', () => ({
__esModule: true,
MetricsTable: jest
.fn()
.mockImplementation(
(): JSX.Element => <div data-testid="metrics-table">Metrics Table</div>,
),
}));
const mockUseQuery = jest.fn();
jest.mock('react-query', () => ({
...jest.requireActual('react-query'),
useQuery: (config: any): any => mockUseQuery(config),
}));
jest.mock('hooks/useDarkMode', () => ({
useIsDarkMode: (): boolean => false,
}));
jest.mock('hooks/useDimensions', () => ({
useResizeObserver: (): { width: number; height: number } => ({
width: 800,
height: 600,
}),
}));
jest.mock('hooks/useMultiIntersectionObserver', () => ({
useMultiIntersectionObserver: (count: number): any => ({
visibilities: new Array(count).fill(true),
setElement: jest.fn(),
}),
}));
jest.spyOn(appContextHooks, 'useAppContext').mockReturnValue({
user: {
role: 'admin',
},
activeLicenseV3: {
event_queue: {
created_at: '0',
event: LicenseEvent.NO_EVENT,
scheduled_at: '0',
status: '',
updated_at: '0',
},
license: {
license_key: 'test-license-key',
license_type: 'trial',
org_id: 'test-org-id',
plan_id: 'test-plan-id',
plan_name: 'test-plan-name',
plan_type: 'trial',
plan_version: 'test-plan-version',
},
},
featureFlags: [
{
name: 'DOT_METRICS_ENABLED',
active: false,
},
],
} as any);
const mockEntity = {
id: 'test-entity-1',
name: 'test-entity',
type: 'pod',
};
const mockEntityWidgetInfo = [
{
title: 'CPU Usage',
yAxisUnit: 'percentage',
},
{
title: 'Memory Usage',
yAxisUnit: 'bytes',
},
];
const mockGetEntityQueryPayload = jest.fn().mockReturnValue([
{
query: 'cpu_usage',
start: 1705315200,
end: 1705318800,
},
{
query: 'memory_usage',
start: 1705315200,
end: 1705318800,
},
]);
const mockTimeRange = {
startTime: 1705315200,
endTime: 1705318800,
};
const mockHandleTimeChange = jest.fn();
const mockQueries = [
{
data: {
payload: {
data: {
result: [
{
table: {
rows: [
{ data: { timestamp: '2024-01-15T10:00:00Z', value: '42.5' } },
{ data: { timestamp: '2024-01-15T10:01:00Z', value: '43.2' } },
],
columns: [
{ key: 'timestamp', label: 'Timestamp', isValueColumn: false },
{ key: 'value', label: 'Value', isValueColumn: true },
],
},
},
],
},
},
params: {
compositeQuery: {
panelType: 'time_series',
},
},
},
isLoading: false,
isError: false,
error: null,
},
{
data: {
payload: {
data: {
result: [
{
table: {
rows: [
{ data: { timestamp: '2024-01-15T10:00:00Z', value: '1024' } },
{ data: { timestamp: '2024-01-15T10:01:00Z', value: '1028' } },
],
columns: [
{ key: 'timestamp', label: 'Timestamp', isValueColumn: false },
{ key: 'value', label: 'Value', isValueColumn: true },
],
},
},
],
},
},
params: {
compositeQuery: {
panelType: 'table',
},
},
},
isLoading: false,
isError: false,
error: null,
},
];
const mockLoadingQueries = [
{
data: undefined,
isLoading: true,
isError: false,
error: null,
},
{
data: undefined,
isLoading: true,
isError: false,
error: null,
},
];
const mockErrorQueries = [
{
data: undefined,
isLoading: false,
isError: true,
error: new Error('API Error'),
},
{
data: undefined,
isLoading: false,
isError: true,
error: new Error('Network Error'),
},
];
const mockEmptyQueries = [
{
data: {
payload: {
data: {
result: [],
},
},
params: {
compositeQuery: {
panelType: 'time_series',
},
},
},
isLoading: false,
isError: false,
error: null,
},
{
data: {
payload: {
data: {
result: [],
},
},
params: {
compositeQuery: {
panelType: 'table',
},
},
},
isLoading: false,
isError: false,
error: null,
},
];
const renderEntityMetrics = (overrides = {}): any => {
const defaultProps = {
timeRange: mockTimeRange,
isModalTimeSelection: false,
handleTimeChange: mockHandleTimeChange,
selectedInterval: '5m' as Time,
entity: mockEntity,
entityWidgetInfo: mockEntityWidgetInfo,
getEntityQueryPayload: mockGetEntityQueryPayload,
queryKey: 'test-query-key',
category: InfraMonitoringEntity.PODS,
...overrides,
};
return render(
<EntityMetrics
timeRange={defaultProps.timeRange}
isModalTimeSelection={defaultProps.isModalTimeSelection}
handleTimeChange={defaultProps.handleTimeChange}
selectedInterval={defaultProps.selectedInterval}
entity={defaultProps.entity}
entityWidgetInfo={defaultProps.entityWidgetInfo}
getEntityQueryPayload={defaultProps.getEntityQueryPayload}
queryKey={defaultProps.queryKey}
category={defaultProps.category}
/>,
);
};
const mockChartData: (uPlot.AlignedData | null)[] = [
[
[1705315200, 1705318800],
[42.5, 43.2],
], // time_series chart data (AlignedData)
null, // table uses tableData
];
const mockTableData: (import('../utils').MetricsTableData[] | null)[] = [
null, // time_series uses chartData
[
{
rows: [
{ timestamp: '2024-01-15T10:00:00Z', value: '1024' },
{ timestamp: '2024-01-15T10:01:00Z', value: '1028' },
],
columns: [
{ key: 'timestamp', label: 'Timestamp', isValueColumn: false },
{ key: 'value', label: 'Value', isValueColumn: true },
],
},
], // table data
];
const mockQueryPayloads = [
{ graphType: 'graph' }, // time_series
{ graphType: 'table' }, // table
];
describe('EntityMetrics', () => {
beforeEach(() => {
jest.clearAllMocks();
mockUseEntityMetrics.mockReturnValue({
queries: mockQueries as any,
chartData: mockChartData,
tableData: mockTableData,
queryPayloads: mockQueryPayloads as any,
});
mockUseQuery.mockReturnValue({
data: {
data: {
data: {
variables: {},
title: 'Test Dashboard',
},
id: 'test-dashboard-id',
},
},
isLoading: false,
isError: false,
refetch: jest.fn(),
});
});
it('should render metrics with data', () => {
renderEntityMetrics();
expect(screen.getByText('CPU Usage')).toBeInTheDocument();
expect(screen.getByText('Memory Usage')).toBeInTheDocument();
expect(screen.getByTestId('date-time-selection')).toBeInTheDocument();
expect(screen.getByTestId('uplot-chart')).toBeInTheDocument();
expect(screen.getByTestId('metrics-table')).toBeInTheDocument();
});
it('renders loading state when fetching metrics', () => {
mockUseEntityMetrics.mockReturnValue({
queries: mockLoadingQueries as any,
chartData: [null, null],
tableData: [null, null],
queryPayloads: mockQueryPayloads as any,
});
renderEntityMetrics();
expect(screen.getAllByText('CPU Usage')).toHaveLength(1);
expect(screen.getAllByText('Memory Usage')).toHaveLength(1);
});
it('renders error state when query fails', () => {
mockUseEntityMetrics.mockReturnValue({
queries: mockErrorQueries as any,
chartData: [null, null],
tableData: [null, null],
queryPayloads: mockQueryPayloads as any,
});
renderEntityMetrics();
expect(screen.getByText('API Error')).toBeInTheDocument();
expect(screen.getByText('Network Error')).toBeInTheDocument();
});
it('renders empty state when no metrics data', () => {
mockUseEntityMetrics.mockReturnValue({
queries: mockEmptyQueries as any,
chartData: [[[]], null],
tableData: [
null,
[
{
rows: [],
columns: [],
},
],
],
queryPayloads: mockQueryPayloads as any,
});
renderEntityMetrics();
expect(screen.getByTestId('uplot-chart')).toBeInTheDocument();
expect(screen.getByTestId('metrics-table')).toBeInTheDocument();
});
it('calls handleTimeChange when datetime selection changes', () => {
renderEntityMetrics();
expect(screen.getByTestId('date-time-selection')).toBeInTheDocument();
});
it('renders multiple metric widgets', () => {
renderEntityMetrics();
expect(screen.getByText('CPU Usage')).toBeInTheDocument();
expect(screen.getByText('Memory Usage')).toBeInTheDocument();
});
it('handles different panel types correctly', () => {
renderEntityMetrics();
expect(screen.getByTestId('uplot-chart')).toBeInTheDocument();
expect(screen.getByTestId('metrics-table')).toBeInTheDocument();
});
it('applies intersection observer for visibility', () => {
renderEntityMetrics();
expect(mockUseEntityMetrics).toHaveBeenCalledWith(
expect.objectContaining({
visibilities: [true, true],
}),
);
});
it('passes correct parameters to useEntityMetrics hook', () => {
renderEntityMetrics();
expect(mockUseEntityMetrics).toHaveBeenCalledWith(
expect.objectContaining({
queryKey: 'test-query-key',
timeRange: mockTimeRange,
entity: mockEntity,
category: InfraMonitoringEntity.PODS,
}),
);
});
});

View File

@@ -1,122 +0,0 @@
import { Timezone } from 'components/CustomTimePicker/timezoneUtils';
import { PANEL_TYPES } from 'constants/queryBuilder';
import { getLegend } from 'lib/dashboard/getQueryResults';
import getLabelName from 'lib/getLabelName';
import {
DrawStyle,
FillMode,
LineInterpolation,
LineStyle,
SelectionPreferencesSource,
} from 'lib/uPlotV2/config/types';
import { UPlotConfigBuilder } from 'lib/uPlotV2/config/UPlotConfigBuilder';
import { hasSingleVisiblePoint } from 'lib/uPlotV2/utils/dataUtils';
import { get } from 'lodash-es';
import { MetricRangePayloadProps } from 'types/api/metrics/getQueryRange';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
import uPlot from 'uplot';
export interface EntityMetricsChartConfigProps {
id: string;
isDarkMode: boolean;
currentQuery: Query;
onDragSelect: (startTime: number, endTime: number) => void;
apiResponse?: MetricRangePayloadProps;
timezone: Timezone;
yAxisUnit: string;
minTimeScale?: number;
maxTimeScale?: number;
}
export function buildEntityMetricsChartConfig({
id,
isDarkMode,
currentQuery,
onDragSelect,
apiResponse,
timezone,
yAxisUnit,
minTimeScale,
maxTimeScale,
}: EntityMetricsChartConfigProps): UPlotConfigBuilder {
const stepIntervals = get(
apiResponse,
'data.newResult.meta.stepIntervals',
{},
) as Record<string, number>;
const minStepInterval = Object.keys(stepIntervals).length
? Math.min(...Object.values(stepIntervals))
: undefined;
const tzDate = (timestamp: number): Date =>
uPlot.tzDate(new Date(timestamp * 1e3), timezone.value);
const builder = new UPlotConfigBuilder({
id,
onDragSelect,
tzDate,
selectionPreferencesSource: SelectionPreferencesSource.IN_MEMORY,
stepInterval: minStepInterval,
});
builder.addScale({
scaleKey: 'x',
time: true,
min: minTimeScale,
max: maxTimeScale,
});
builder.addScale({
scaleKey: 'y',
time: false,
});
builder.addAxis({
scaleKey: 'x',
show: true,
side: 2,
isDarkMode,
panelType: PANEL_TYPES.TIME_SERIES,
});
builder.addAxis({
scaleKey: 'y',
show: true,
side: 3,
isDarkMode,
yAxisUnit,
panelType: PANEL_TYPES.TIME_SERIES,
});
if (!apiResponse?.data?.result) {
return builder;
}
apiResponse.data.result.forEach((series) => {
const hasSingleValidPoint = hasSingleVisiblePoint(series.values);
const baseLabelName = getLabelName(
series.metric,
series.queryName || '',
series.legend || '',
);
const label = getLegend(series, currentQuery, baseLabelName);
builder.addSeries({
scaleKey: 'y',
drawStyle: hasSingleValidPoint ? DrawStyle.Points : DrawStyle.Line,
label,
colorMapping: {},
spanGaps: true,
lineStyle: LineStyle.Solid,
lineInterpolation: LineInterpolation.Spline,
showPoints: hasSingleValidPoint,
pointSize: 5,
fillMode: FillMode.None,
isDarkMode,
metric: series.metric,
});
});
return builder;
}

View File

@@ -1,121 +0,0 @@
import { useMemo } from 'react';
import { QueryFunctionContext, useQueries, UseQueryResult } from 'react-query';
import { ENTITY_VERSION_V5 } from 'constants/app';
import { PANEL_TYPES } from 'constants/queryBuilder';
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
import { InfraMonitoringEntity } from 'container/InfraMonitoringK8sV2/constants';
import {
GetMetricQueryRange,
GetQueryResultsProps,
} from 'lib/dashboard/getQueryResults';
import { prepareChartData } from 'lib/uPlotV2/utils/dataUtils';
import { SuccessResponse } from 'types/api';
import { MetricRangePayloadProps } from 'types/api/metrics/getQueryRange';
import uPlot from 'uplot';
import { FeatureKeys } from 'constants/features';
import { useAppContext } from 'providers/App/App';
import { getMetricsTableData, MetricsTableData } from './utils';
export interface UseEntityMetricsParams<T> {
queryKey: string;
timeRange: { startTime: number; endTime: number };
entity: T;
getEntityQueryPayload: (
entity: T,
start: number,
end: number,
dotMetricsEnabled: boolean,
) => GetQueryResultsProps[];
visibilities: boolean[];
category: InfraMonitoringEntity;
}
export interface UseEntityMetricsResult {
queries: UseQueryResult<SuccessResponse<MetricRangePayloadProps>, unknown>[];
chartData: (uPlot.AlignedData | null)[];
tableData: (MetricsTableData[] | null)[];
queryPayloads: GetQueryResultsProps[];
}
export function useEntityMetrics<T>({
queryKey,
timeRange,
entity,
getEntityQueryPayload,
visibilities,
category,
}: UseEntityMetricsParams<T>): UseEntityMetricsResult {
const { featureFlags } = useAppContext();
const dotMetricsEnabled =
featureFlags?.find((flag) => flag.name === FeatureKeys.DOT_METRICS_ENABLED)
?.active || false;
const queryPayloads = useMemo(
() =>
getEntityQueryPayload(
entity,
timeRange.startTime,
timeRange.endTime,
dotMetricsEnabled,
),
[
getEntityQueryPayload,
entity,
timeRange.startTime,
timeRange.endTime,
dotMetricsEnabled,
],
);
const queries = useQueries(
queryPayloads.map((payload, index) => ({
// TODO: remove AUTO_REFRESH_QUERY when migrating to date time selection v3
queryKey: [
REACT_QUERY_KEY.AUTO_REFRESH_QUERY,
queryKey,
payload,
ENTITY_VERSION_V5,
category,
],
queryFn: ({
signal,
}: QueryFunctionContext): Promise<
SuccessResponse<MetricRangePayloadProps>
> => GetMetricQueryRange(payload, ENTITY_VERSION_V5, undefined, signal),
enabled: !!payload && visibilities[index],
keepPreviousData: true,
})),
);
const chartData = useMemo(
() =>
queries.map(({ data }, index) => {
const panelType = queryPayloads[index]?.graphType;
if (panelType === PANEL_TYPES.TABLE) {
return null;
}
return data?.payload ? prepareChartData(data.payload) : null;
}),
[queries, queryPayloads],
);
const tableData = useMemo(
() =>
queries.map(({ data }, index) => {
const panelType = queryPayloads[index]?.graphType;
if (panelType !== PANEL_TYPES.TABLE) {
return null;
}
return getMetricsTableData(data);
}),
[queries, queryPayloads],
);
return {
queries,
chartData,
tableData,
queryPayloads,
};
}

View File

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

View File

@@ -1,61 +0,0 @@
import { SuccessResponse } from 'types/api';
import { MetricRangePayloadProps } from 'types/api/metrics/getQueryRange';
export interface MetricsColumn {
key: string;
label: string;
isValueColumn: boolean;
id?: string;
}
export interface MetricsTableData {
rows: Record<string, string>[];
columns: MetricsColumn[];
}
export const getMetricsTableData = (
data: SuccessResponse<MetricRangePayloadProps> | undefined,
): MetricsTableData[] => {
if (data?.payload?.data?.result?.length) {
const rowsData = (data?.payload.data.result[0] as any).table?.rows;
const columnsData = (data?.payload.data.result[0] as any).table?.columns;
if (!rowsData || !columnsData) {
return [{ rows: [], columns: [] }];
}
// V4 uses builderQueries, V5 already includes legend in column name
const builderQueries = (data.params as any)?.compositeQuery?.builderQueries;
const columns = columnsData.map((columnData: any) => {
if (columnData.isValueColumn) {
// V5: column name already includes legend from convertV5ResponseToLegacy
// V4: need to get legend from builderQueries
const label = builderQueries?.[columnData.name]?.legend || columnData.name;
return {
id: columnData.id || columnData.name,
key: columnData.id || columnData.name,
label,
isValueColumn: true,
};
}
return {
key: columnData.id || columnData.name,
label: columnData.name,
isValueColumn: false,
};
});
if (columns.length === 0) {
return [{ rows: [], columns: [] }];
}
const firstColumnId = columns[0].id || columns[0].key;
const rows = rowsData.map((rowData: any) => ({
...rowData.data,
key: rowData[firstColumnId],
}));
return [{ rows, columns }];
}
return [{ rows: [], columns: [] }];
};

View File

@@ -1,49 +0,0 @@
.container {
margin-top: var(--spacing-8);
}
.filterContainer {
display: flex;
flex-direction: column;
gap: 8px;
padding: var(--spacing-6);
border-radius: var(--radius);
border: 1px solid var(--l1-border);
:global(.ant-select-selector) {
border-radius: 2px;
border: 1px solid var(--l1-border) !important;
background-color: var(--l3-background) !important;
input {
font-size: 12px;
}
:global([data-slot='badge'] .ant-typography) {
font-size: 12px;
}
}
}
.filterContainerTime {
display: flex;
justify-content: flex-end;
align-items: center;
gap: 8px;
}
.filterQuerySearch {
flex: 1;
}
.entityTracesTable {
margin-top: var(--spacing-8);
--typography-color: var(--l2-foreground);
}
.controls {
margin-bottom: 1rem;
width: 100%;
display: flex;
justify-content: flex-end;
}

View File

@@ -1,267 +0,0 @@
import { useCallback, useEffect, useMemo } from 'react';
import logEvent from 'api/common/logEvent';
import {
QuerySearchV2Provider,
useExpression,
useInitialExpression,
useInputExpression,
useQuerySearchInitialExpressionProp,
useQuerySearchOnChange,
useQuerySearchOnRun,
useUserExpression,
} from 'components/QueryBuilderV2';
import QuerySearch from 'components/QueryBuilderV2/QueryV2/QuerySearch/QuerySearch';
import {
combineInitialAndUserExpression,
getUserExpressionFromCombined,
} from 'components/QueryBuilderV2/QueryV2/QuerySearch/utils';
import { ResizeTable } from 'components/ResizeTable';
import { InfraMonitoringEvents } from 'constants/events';
import Controls from 'container/Controls';
import { InfraMonitoringEntity } from 'container/InfraMonitoringK8sV2/constants';
import RunQueryBtn from 'container/QueryBuilder/components/RunQueryBtn/RunQueryBtn';
import DateTimeSelectionV2 from 'container/TopNav/DateTimeSelectionV2';
import {
CustomTimeType,
Time,
} from 'container/TopNav/DateTimeSelectionV2/types';
import { PER_PAGE_OPTIONS } from 'container/TracesExplorer/ListView/configs';
import { TracesLoading } from 'container/TracesExplorer/TraceLoading/TraceLoading';
import { useQueryState } from 'nuqs';
import { DataSource } from 'types/common/queryBuilder';
import { parseAsJsonNoValidate } from 'utils/nuqsParsers';
import { validateQuery } from 'utils/queryValidationUtils';
import EntityEmptyState from '../EntityEmptyState/EntityEmptyState';
import EntityError from '../EntityError/EntityError';
import { selectedEntityTracesColumns } from '../utils';
import { isKeyNotFoundError } from '../utils';
import { K8S_ENTITY_TRACES_EXPRESSION_KEY, useEntityTraces } from './hooks';
import { getTraceListColumns } from './traceListColumns';
import { getEntityTracesQueryPayload } from './utils';
import styles from './EntityTraces.module.scss';
import { useTimezone } from 'providers/Timezone';
interface Props {
timeRange: {
startTime: number;
endTime: number;
};
isModalTimeSelection: boolean;
handleTimeChange: (
interval: Time | CustomTimeType,
dateTimeRange?: [number, number],
) => void;
selectedInterval: Time;
queryKey: string;
category: InfraMonitoringEntity;
initialExpression: string;
}
function EntityTracesContent({
timeRange,
isModalTimeSelection,
handleTimeChange,
selectedInterval,
queryKey,
category,
}: Omit<Props, 'initialExpression'>): JSX.Element {
const expression = useExpression();
const inputExpression = useInputExpression();
const userExpression = useUserExpression();
const initialExpression = useInitialExpression();
const querySearchOnChange = useQuerySearchOnChange();
const querySearchOnRun = useQuerySearchOnRun();
const querySearchInitialExpressionProp = useQuerySearchInitialExpressionProp();
const [pagination, setPagination] = useQueryState(
'pagination',
parseAsJsonNoValidate<{ offset: number; limit: number }>(),
);
const pageSize = pagination?.limit || PER_PAGE_OPTIONS[0];
const offset = pagination?.offset || 0;
const {
traces,
isLoading,
isFetching,
isError,
error,
currentCount,
hasMore,
refetch,
cancel,
} = useEntityTraces({
queryKey,
timeRange,
expression,
offset,
pageSize,
});
const handleRunQuery = useCallback(
(updatedExpression?: string): void => {
const newUserExpression = updatedExpression
? getUserExpressionFromCombined(initialExpression, updatedExpression)
: inputExpression;
const validation = validateQuery(
initialExpression
? combineInitialAndUserExpression(initialExpression, newUserExpression)
: newUserExpression || '',
);
if (validation.isValid) {
querySearchOnRun(newUserExpression || '');
logEvent(InfraMonitoringEvents.FilterApplied, {
entity: InfraMonitoringEvents.K8sEntity,
page: InfraMonitoringEvents.DetailedPage,
category,
view: InfraMonitoringEvents.TracesView,
});
refetch();
}
},
[inputExpression, initialExpression, refetch, querySearchOnRun, category],
);
const queryData = useMemo(
() =>
getEntityTracesQueryPayload({
start: timeRange.startTime,
end: timeRange.endTime,
expression: userExpression || '',
}).queryData,
[timeRange.startTime, timeRange.endTime, userExpression],
);
const { formatTimezoneAdjustedTimestamp } = useTimezone();
const traceListColumns = getTraceListColumns(
selectedEntityTracesColumns,
formatTimezoneAdjustedTimestamp,
);
const isKeyNotFound = isKeyNotFoundError(error);
const isDataEmpty =
!isLoading &&
!isFetching &&
(!isError || isKeyNotFound) &&
traces.length === 0;
const hasAdditionalFilters = !!userExpression?.trim();
const handleRowClick = useCallback(() => {
logEvent(InfraMonitoringEvents.ItemClicked, {
entity: InfraMonitoringEvents.K8sEntity,
category,
view: InfraMonitoringEvents.TracesView,
});
}, [category]);
useEffect(() => {
return (): void => {
void setPagination(null);
};
}, [setPagination]);
return (
<div className={styles.container}>
<div className={styles.filterContainer}>
<div className={styles.filterContainerTime}>
<DateTimeSelectionV2
showAutoRefresh
showRefreshText={false}
hideShareModal
isModalTimeSelection={isModalTimeSelection}
onTimeChange={handleTimeChange}
defaultRelativeTime="5m"
modalSelectedInterval={selectedInterval}
modalInitialStartTime={timeRange.startTime * 1000}
modalInitialEndTime={timeRange.endTime * 1000}
/>
<RunQueryBtn
isLoadingQueries={isFetching}
onStageRunQuery={(): void => handleRunQuery()}
handleCancelQuery={cancel}
/>
</div>
<div className={styles.filterQuerySearch}>
<QuerySearch
onChange={querySearchOnChange}
queryData={queryData}
dataSource={DataSource.TRACES}
onRun={handleRunQuery}
initialExpression={querySearchInitialExpressionProp}
/>
</div>
</div>
{isLoading && traces.length === 0 && <TracesLoading />}
{isDataEmpty && <EntityEmptyState hasFilters={hasAdditionalFilters} />}
{isError && !isKeyNotFound && !isLoading && <EntityError />}
{(!isError || isKeyNotFound) && traces.length > 0 && (
<div className={styles.entityTracesTable}>
<div className={styles.controls}>
<Controls
totalCount={hasMore ? currentCount + 1 : currentCount}
countPerPage={pageSize}
offset={offset}
perPageOptions={PER_PAGE_OPTIONS}
isLoading={false}
handleNavigatePrevious={(): void => {
void setPagination({
offset: Math.max(0, offset - pageSize),
limit: pageSize,
});
}}
handleNavigateNext={(): void => {
void setPagination({
offset: offset + pageSize,
limit: pageSize,
});
}}
handleCountItemsPerPageChange={(value): void => {
void setPagination({
offset: 0,
limit: value,
});
}}
/>
</div>
<ResizeTable
tableLayout="fixed"
pagination={false}
scroll={{ x: true }}
loading={isFetching && traces.length === 0}
dataSource={traces}
columns={traceListColumns}
onRow={(): Record<string, unknown> => ({
onClick: (): void => handleRowClick(),
})}
/>
</div>
)}
</div>
);
}
function EntityTraces({ initialExpression, ...rest }: Props): JSX.Element {
return (
<QuerySearchV2Provider
queryParamKey={K8S_ENTITY_TRACES_EXPRESSION_KEY}
initialExpression={initialExpression}
persistOnUnmount
>
<EntityTracesContent {...rest} />
</QuerySearchV2Provider>
);
}
export default EntityTraces;

View File

@@ -1,98 +0,0 @@
import { act, waitFor } from 'tests/test-utils';
import { QueryRangePayloadV5 } from 'types/api/v5/queryRange';
import { renderEntityTraces, verifyQueryPayload } from './testUtils';
import { mockQueryRangeV5WithTracesResponse } from '__tests__/query_range_v5.util';
describe('EntityTraces - Default Behavior', () => {
let capturedPayloads: QueryRangePayloadV5[] = [];
beforeEach(() => {
capturedPayloads = [];
mockQueryRangeV5WithTracesResponse({
onReceiveRequest: async (req) => {
const body = (await req.json()) as QueryRangePayloadV5;
capturedPayloads.push(body);
return {};
},
});
});
it('should fetch traces using V5 API on initial render', async () => {
act(() => {
renderEntityTraces();
});
await waitFor(() => {
expect(capturedPayloads).toHaveLength(1);
});
verifyQueryPayload({
payload: capturedPayloads[0],
expectedOffset: 0,
});
});
it('should pass time range to API (converted to milliseconds)', async () => {
const timeRange = { startTime: 1000, endTime: 2000 };
act(() => {
renderEntityTraces({ timeRange });
});
await waitFor(() => {
expect(capturedPayloads).toHaveLength(1);
});
verifyQueryPayload({
payload: capturedPayloads[0],
expectedTimeRange: {
start: timeRange.startTime * 1000,
end: timeRange.endTime * 1000,
},
});
});
it('should order results by timestamp desc', async () => {
act(() => {
renderEntityTraces();
});
await waitFor(() => {
expect(capturedPayloads).toHaveLength(1);
});
const spec = capturedPayloads[0].compositeQuery.queries[0]?.spec as {
order?: Array<{ key: { name: string }; direction: string }>;
};
const timestampOrder = spec.order?.find((o) => o.key.name === 'timestamp');
expect(timestampOrder?.direction).toBe('desc');
});
it('should use TRACES signal type', async () => {
act(() => {
renderEntityTraces();
});
await waitFor(() => {
expect(capturedPayloads).toHaveLength(1);
});
const query = capturedPayloads[0].compositeQuery.queries[0];
const spec = query?.spec as { signal?: string };
expect(spec?.signal).toBe('traces');
});
it('should use raw request type for traces list view', async () => {
act(() => {
renderEntityTraces();
});
await waitFor(() => {
expect(capturedPayloads).toHaveLength(1);
});
expect(capturedPayloads[0].requestType).toBe('raw');
});
});

View File

@@ -1,38 +0,0 @@
import { act, screen } from 'tests/test-utils';
import { renderEntityTraces } from './testUtils';
import { mockQueryRangeV5WithEmptyTraces } from '__tests__/query_range_v5.util';
describe('EntityTraces - Empty State', () => {
it('should show empty state when no traces returned', async () => {
mockQueryRangeV5WithEmptyTraces();
act(() => {
renderEntityTraces();
});
await expect(screen.findByText(/No data yet/i)).resolves.toBeInTheDocument();
});
it('should not show table when traces are empty', async () => {
mockQueryRangeV5WithEmptyTraces();
act(() => {
renderEntityTraces();
});
await expect(screen.findByText(/No data yet/i)).resolves.toBeInTheDocument();
expect(screen.queryByRole('table')).not.toBeInTheDocument();
});
it('should not show pagination controls when traces are empty', async () => {
mockQueryRangeV5WithEmptyTraces();
act(() => {
renderEntityTraces();
});
await expect(screen.findByText(/No data yet/i)).resolves.toBeInTheDocument();
expect(screen.queryByText(/1 - 10/)).not.toBeInTheDocument();
});
});

View File

@@ -1,44 +0,0 @@
import { act, screen } from 'tests/test-utils';
import { renderEntityTraces } from './testUtils';
import {
mockQueryRangeV5WithError,
mockQueryRangeV5WithKeyNotFoundError,
} from '__tests__/query_range_v5.util';
describe('EntityTraces - Error State', () => {
it('should show error state on API error', async () => {
mockQueryRangeV5WithError('Internal server error', 500);
act(() => {
renderEntityTraces();
});
await expect(
screen.findByText(/Something went wrong/i),
).resolves.toBeInTheDocument();
});
it('should show empty state for key not found error', async () => {
mockQueryRangeV5WithKeyNotFoundError();
act(() => {
renderEntityTraces();
});
await expect(screen.findByText(/No data yet/i)).resolves.toBeInTheDocument();
});
it('should not show table on API error', async () => {
mockQueryRangeV5WithError('Internal server error', 500);
act(() => {
renderEntityTraces();
});
await expect(
screen.findByText(/Something went wrong/i),
).resolves.toBeInTheDocument();
expect(screen.queryByRole('table')).not.toBeInTheDocument();
});
});

View File

@@ -1,55 +0,0 @@
import { act, waitFor } from 'tests/test-utils';
import { QueryRangePayloadV5 } from 'types/api/v5/queryRange';
import { renderEntityTraces } from './testUtils';
import { mockQueryRangeV5WithTracesResponse } from '__tests__/query_range_v5.util';
describe('EntityTraces - Expression', () => {
let capturedPayloads: QueryRangePayloadV5[] = [];
beforeEach(() => {
capturedPayloads = [];
mockQueryRangeV5WithTracesResponse({
onReceiveRequest: async (req) => {
const body = (await req.json()) as QueryRangePayloadV5;
capturedPayloads.push(body);
return {};
},
});
});
it('should include expression in query filter', async () => {
const expression = 'k8s.pod.name = "my-pod"';
act(() => {
renderEntityTraces({ expression });
});
await waitFor(() => {
expect(capturedPayloads).toHaveLength(1);
});
const spec = capturedPayloads[0].compositeQuery.queries[0]?.spec as {
filter?: { expression: string };
};
expect(spec.filter?.expression).toContain('k8s.pod.name');
});
it('should include complex expression with multiple conditions', async () => {
const expression = 'k8s.pod.name = "my-pod" AND service.name = "api"';
act(() => {
renderEntityTraces({ expression });
});
await waitFor(() => {
expect(capturedPayloads).toHaveLength(1);
});
const spec = capturedPayloads[0].compositeQuery.queries[0]?.spec as {
filter?: { expression: string };
};
expect(spec.filter?.expression).toContain('k8s.pod.name');
expect(spec.filter?.expression).toContain('service.name');
});
});

View File

@@ -1,39 +0,0 @@
import { act, screen, waitFor } from 'tests/test-utils';
import { renderEntityTraces } from './testUtils';
import { mockQueryRangeV5WithTracesResponse } from '__tests__/query_range_v5.util';
describe('EntityTraces - Loading State', () => {
it('should show loading state while fetching', async () => {
mockQueryRangeV5WithTracesResponse({ delay: 1000 });
act(() => {
renderEntityTraces();
});
await expect(
screen.findByText(/pending_data_placeholder/i),
).resolves.toBeInTheDocument();
});
it('should hide loading state after data loads', async () => {
mockQueryRangeV5WithTracesResponse({ delay: 100 });
act(() => {
renderEntityTraces();
});
await expect(
screen.findByText(/pending_data_placeholder/i),
).resolves.toBeInTheDocument();
await waitFor(
() => {
expect(
screen.queryByText(/pending_data_placeholder/i),
).not.toBeInTheDocument();
},
{ timeout: 3000 },
);
});
});

View File

@@ -1,96 +0,0 @@
import { act, waitFor } from 'tests/test-utils';
import { QueryRangePayloadV5 } from 'types/api/v5/queryRange';
import { renderEntityTraces, verifyQueryPayload } from './testUtils';
import { mockQueryRangeV5WithTracesResponse } from '__tests__/query_range_v5.util';
describe('EntityTraces - Pagination', () => {
let capturedPayloads: QueryRangePayloadV5[] = [];
beforeEach(() => {
capturedPayloads = [];
mockQueryRangeV5WithTracesResponse({
onReceiveRequest: async (req) => {
const body = (await req.json()) as QueryRangePayloadV5;
capturedPayloads.push(body);
return {};
},
});
});
it('should use default limit when no pagination provided', async () => {
act(() => {
renderEntityTraces();
});
await waitFor(() => {
expect(capturedPayloads).toHaveLength(1);
});
const spec = capturedPayloads[0].compositeQuery.queries[0]?.spec as {
limit?: number;
};
expect(spec.limit).toBe(10);
});
it('should use custom offset from pagination param', async () => {
act(() => {
renderEntityTraces({ pagination: { offset: 20, limit: 10 } });
});
await waitFor(() => {
expect(capturedPayloads).toHaveLength(1);
});
verifyQueryPayload({
payload: capturedPayloads[0],
expectedOffset: 20,
});
});
it('should use custom limit from pagination param', async () => {
act(() => {
renderEntityTraces({ pagination: { offset: 0, limit: 50 } });
});
await waitFor(() => {
expect(capturedPayloads).toHaveLength(1);
});
verifyQueryPayload({
payload: capturedPayloads[0],
expectedLimit: 50,
});
});
it('should default offset to 0', async () => {
act(() => {
renderEntityTraces();
});
await waitFor(() => {
expect(capturedPayloads).toHaveLength(1);
});
verifyQueryPayload({
payload: capturedPayloads[0],
expectedOffset: 0,
});
});
it('should combine offset and limit correctly', async () => {
act(() => {
renderEntityTraces({ pagination: { offset: 30, limit: 20 } });
});
await waitFor(() => {
expect(capturedPayloads).toHaveLength(1);
});
verifyQueryPayload({
payload: capturedPayloads[0],
expectedOffset: 30,
expectedLimit: 20,
});
});
});

View File

@@ -1,185 +0,0 @@
import { act, screen, within } from 'tests/test-utils';
import { renderEntityTraces } from './testUtils';
import { mockQueryRangeV5WithTracesResponse } from '__tests__/query_range_v5.util';
// Trace list columns are hidden below the antd `md` breakpoint. The global
// matchMedia mock reports `matches: false`, which drops every column, so make
// all breakpoints match for these rendering tests.
jest.spyOn(window, 'matchMedia').mockImplementation(
(query: string) =>
({
matches: true,
media: query,
onchange: null,
addListener: jest.fn(),
removeListener: jest.fn(),
addEventListener: jest.fn(),
removeEventListener: jest.fn(),
dispatchEvent: jest.fn(),
}) as unknown as MediaQueryList,
);
describe('EntityTraces - Table Rendering', () => {
it('should render table with all column headers', async () => {
mockQueryRangeV5WithTracesResponse({ pageSize: 3 });
act(() => {
renderEntityTraces();
});
await expect(screen.findByText('Service Name')).resolves.toBeInTheDocument();
const headerTexts = within(screen.getByRole('table'))
.getAllByRole('columnheader')
.map((header) => header.textContent);
expect(headerTexts).toStrictEqual(
expect.arrayContaining([
'Timestamp',
'Service Name',
'Name',
'Duration',
'HTTP Method',
'Status Code',
]),
);
});
it('should render trace values in table cells', async () => {
mockQueryRangeV5WithTracesResponse({
customTraces: [
{
serviceName: 'checkout-service',
name: 'GET /api/checkout',
durationNano: 5000000,
httpMethod: 'GET',
responseStatusCode: '200',
},
],
});
act(() => {
renderEntityTraces();
});
await expect(screen.findByTestId('serviceName')).resolves.toHaveTextContent(
'checkout-service',
);
expect(screen.getByTestId('name')).toHaveTextContent('GET /api/checkout');
expect(screen.getByTestId('durationNano')).toHaveTextContent('5.00ms');
expect(screen.getByTestId('httpMethod')).toHaveTextContent('GET');
expect(screen.getByTestId('responseStatusCode')).toHaveTextContent('200');
});
it('should render http method as robin colored outline badge', async () => {
mockQueryRangeV5WithTracesResponse({
customTraces: [{ httpMethod: 'POST', responseStatusCode: '200' }],
});
act(() => {
renderEntityTraces();
});
const badge = await screen.findByTestId('httpMethod');
expect(badge).toHaveTextContent('POST');
expect(badge).toHaveAttribute('data-color', 'robin');
expect(badge).toHaveAttribute('data-variant', 'outline');
});
it('should render N/A when http method is empty', async () => {
mockQueryRangeV5WithTracesResponse({
customTraces: [{ httpMethod: '', responseStatusCode: '200' }],
});
act(() => {
renderEntityTraces();
});
await expect(screen.findByText('N/A')).resolves.toBeInTheDocument();
expect(screen.queryByTestId('httpMethod')).not.toBeInTheDocument();
});
it('should render status code badge color per status range', async () => {
mockQueryRangeV5WithTracesResponse({
customTraces: [
{ responseStatusCode: '200' },
{ responseStatusCode: '302' },
{ responseStatusCode: '404' },
{ responseStatusCode: '500' },
{ responseStatusCode: '100' },
],
});
act(() => {
renderEntityTraces();
});
const badges = await screen.findAllByTestId('responseStatusCode');
const badgeColors = badges.map((badge) => badge.getAttribute('data-color'));
expect(badgeColors).toStrictEqual([
'forest', // 2xx -> success
'robin', // 3xx -> redirect
'amber', // 4xx -> client error
'cherry', // 5xx -> server error
'vanilla', // 1xx -> informational
]);
});
it('should render non-numeric status code as plain text without badge', async () => {
mockQueryRangeV5WithTracesResponse({
customTraces: [{ responseStatusCode: 'unknown' }],
});
act(() => {
renderEntityTraces();
});
await expect(screen.findByText('unknown')).resolves.toBeInTheDocument();
expect(screen.queryByTestId('responseStatusCode')).not.toBeInTheDocument();
});
it('should link cells to trace details page', async () => {
mockQueryRangeV5WithTracesResponse({
customTraces: [{ serviceName: 'frontend' }],
});
act(() => {
renderEntityTraces();
});
await expect(screen.findByTestId('serviceName')).resolves.toBeInTheDocument();
const links = within(screen.getByRole('table')).getAllByRole('link');
expect(links.length).toBeGreaterThan(0);
links.forEach((link) => {
expect(link).toHaveAttribute(
'href',
expect.stringContaining('/trace/trace-id-0'),
);
});
});
it('should render one row per trace', async () => {
mockQueryRangeV5WithTracesResponse({
customTraces: [
{ serviceName: 'frontend' },
{ serviceName: 'backend' },
{ serviceName: 'database' },
],
});
act(() => {
renderEntityTraces();
});
const serviceCells = await screen.findAllByTestId('serviceName');
expect(serviceCells.map((cell) => cell.textContent)).toStrictEqual([
'frontend',
'backend',
'database',
]);
});
});

View File

@@ -1,121 +0,0 @@
import { ENVIRONMENT } from 'constants/env';
import { InfraMonitoringEntity } from 'container/InfraMonitoringK8sV2/constants';
import { server } from 'mocks-server/server';
import { rest } from 'msw';
import { NuqsTestingAdapter } from 'nuqs/adapters/testing';
import { render, RenderResult } from 'tests/test-utils';
import { QueryRangePayloadV5 } from 'types/api/v5/queryRange';
import EntityTraces from '../EntityTraces';
import { K8S_ENTITY_TRACES_EXPRESSION_KEY } from '../hooks';
// QuerySearch fires autocomplete requests on mount; without handlers MSW
// passes them through to the real network and the resulting AxiosError fails
// whichever test happens to be running.
beforeEach(() => {
server.use(
rest.get(`${ENVIRONMENT.baseURL}/api/v1/fields/keys`, (_, res, ctx) =>
res(
ctx.status(200),
ctx.json({ status: 'success', data: { keys: {}, complete: true } }),
),
),
rest.get(`${ENVIRONMENT.baseURL}/api/v1/fields/values`, (_, res, ctx) =>
res(
ctx.status(200),
ctx.json({ status: 'success', data: { values: {}, complete: true } }),
),
),
);
});
export interface RenderEntityTracesOptions {
expression?: string;
timeRange?: { startTime: number; endTime: number };
category?: InfraMonitoringEntity;
selectedInterval?: '5m' | '15m' | '30m' | '1h';
pagination?: { offset: number; limit: number };
}
export function renderEntityTraces({
expression = 'k8s.pod.name = "test-pod"',
timeRange = { startTime: 1, endTime: 2 },
category = InfraMonitoringEntity.PODS,
selectedInterval = '5m',
pagination,
}: RenderEntityTracesOptions = {}): RenderResult {
const encodedExpression = encodeURIComponent(expression);
let searchParams = `${K8S_ENTITY_TRACES_EXPRESSION_KEY}=${encodedExpression}`;
if (pagination) {
const paginationStr = encodeURIComponent(JSON.stringify(pagination));
searchParams += `&pagination=${paginationStr}`;
}
return render(
<NuqsTestingAdapter searchParams={searchParams}>
<EntityTraces
timeRange={timeRange}
isModalTimeSelection={false}
handleTimeChange={jest.fn()}
selectedInterval={selectedInterval}
queryKey="test"
category={category}
initialExpression={expression}
/>
</NuqsTestingAdapter>,
);
}
export function verifyQueryPayload({
payload,
expectedOffset,
expectedLimit,
expectedTimeRange,
}: {
payload: QueryRangePayloadV5;
expectedOffset?: number;
expectedLimit?: number;
expectedTimeRange?: { start: number; end: number };
}): void {
const spec = payload.compositeQuery.queries[0]?.spec as {
offset?: number;
limit?: number;
order?: Array<{ key: { name: string }; direction: string }>;
};
if (expectedOffset !== undefined) {
expect(spec.offset).toBe(expectedOffset);
}
if (expectedLimit !== undefined) {
expect(spec.limit).toBe(expectedLimit);
}
if (expectedTimeRange) {
expect(payload.start).toBe(expectedTimeRange.start);
expect(payload.end).toBe(expectedTimeRange.end);
}
const orderKeys = spec.order?.map((o) => o.key.name) ?? [];
expect(orderKeys).toContain('timestamp');
}
jest.mock('container/TopNav/DateTimeSelectionV2/index.tsx', () => ({
__esModule: true,
default: ({
onTimeChange,
}: {
onTimeChange?: (interval: string, dateTimeRange?: [number, number]) => void;
}): JSX.Element => (
<button
type="button"
data-testid="mock-datetime-selection"
onClick={(): void => {
onTimeChange?.('5m');
}}
>
Select Time
</button>
),
}));

View File

@@ -1,115 +0,0 @@
import { useCallback, useMemo } from 'react';
import { useQuery, useQueryClient } from 'react-query';
import { ENTITY_VERSION_V5 } from 'constants/app';
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
import { GetMetricQueryRange } from 'lib/dashboard/getQueryResults';
import { getEntityTracesQueryPayload } from './utils';
export const K8S_ENTITY_TRACES_EXPRESSION_KEY = 'k8sEntityTracesExpression';
export interface TraceRow {
timestamp: string;
data: Record<string, unknown>;
}
export function useEntityTraces({
queryKey,
timeRange,
expression,
offset = 0,
pageSize = 10,
}: {
queryKey: string;
timeRange: { startTime: number; endTime: number };
expression: string;
offset?: number;
pageSize?: number;
}): {
traces: TraceRow[];
isLoading: boolean;
isFetching: boolean;
isError: boolean;
error?: unknown;
currentCount: number;
hasMore: boolean;
refetch: () => void;
cancel: () => void;
reactQueryKey: unknown[];
} {
const reactQueryKey = useMemo(
() => [
// TODO: remove AUTO_REFRESH_QUERY when migrating to date time selection v3
REACT_QUERY_KEY.AUTO_REFRESH_QUERY,
queryKey,
timeRange.startTime,
timeRange.endTime,
expression,
offset,
pageSize,
],
[
queryKey,
timeRange.startTime,
timeRange.endTime,
expression,
offset,
pageSize,
],
);
const { data, isLoading, isFetching, isError, error, refetch } = useQuery({
queryKey: reactQueryKey,
queryFn: async ({ signal }) => {
const { query } = getEntityTracesQueryPayload({
start: timeRange.startTime,
end: timeRange.endTime,
expression,
offset,
pageSize,
});
return GetMetricQueryRange(query, ENTITY_VERSION_V5, undefined, signal);
},
enabled: !!expression?.trim(),
});
const result = data?.payload?.data?.newResult?.data?.result?.[0];
const traces = useMemo<TraceRow[]>(() => {
const list = result?.list;
if (!list) {
return [];
}
return list.map((item) => ({
data: item.data,
timestamp: item.timestamp,
}));
}, [result?.list]);
const currentCount = result?.list?.length || 0;
// Has more if nextCursor exists or if we got a full page of results
const hasMore = !!result?.nextCursor || currentCount >= pageSize;
const queryClient = useQueryClient();
const cancel = useCallback(() => {
void queryClient.cancelQueries({
queryKey: reactQueryKey,
});
}, [queryClient, reactQueryKey]);
return {
traces,
isLoading,
isFetching,
isError,
error,
currentCount,
hasMore,
refetch,
cancel,
reactQueryKey,
};
}

View File

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

View File

@@ -1,7 +0,0 @@
.pointer {
cursor: pointer;
}
.cellText {
color: var(--l2-foreground);
}

View File

@@ -1,174 +0,0 @@
import { TableColumnsType as ColumnsType } from 'antd';
import { Badge } from '@signozhq/ui/badge';
import { Typography } from '@signozhq/ui/typography';
import HttpStatusBadge from 'components/HttpStatusBadge/HttpStatusBadge';
import { getMs } from 'container/Trace/Filters/Panel/PanelBody/Duration/util';
import {
BlockLink,
getTraceLink,
} from 'container/TracesExplorer/ListView/utils';
import { RowData } from 'lib/query/createTableColumnsFromQuery';
import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
import { FormatTimezoneAdjustedTimestamp } from 'hooks/useTimezoneFormatter/useTimezoneFormatter';
import styles from './traceListColumns.module.scss';
const keyToLabelMap: Record<string, string> = {
timestamp: 'Timestamp',
serviceName: 'Service Name',
name: 'Name',
durationNano: 'Duration',
httpMethod: 'HTTP Method',
responseStatusCode: 'Status Code',
spanID: 'Span ID',
traceID: 'Trace ID',
};
const keyAliases: Record<string, string[]> = {
serviceName: ['serviceName', 'service.name', 'service_name'],
durationNano: ['durationNano', 'duration.nano', 'duration_nano'],
httpMethod: ['httpMethod', 'http.method', 'http_method'],
responseStatusCode: [
'response_status_code',
'response.status.code',
'responseStatusCode',
],
spanID: ['spanID', 'span.id', 'span_id'],
traceID: ['traceID', 'trace.id', 'trace_id'],
};
const getPrimaryKey = (key: string): string => {
for (const [primaryKey, aliases] of Object.entries(keyAliases)) {
if (aliases.includes(key)) {
return primaryKey;
}
}
return key;
};
const getValueForKey = (data: Record<string, any>, key: string): any => {
const primaryKey = getPrimaryKey(key);
const aliases = keyAliases[primaryKey];
if (aliases) {
for (const alias of aliases) {
const aliasedValue = data[alias] || data.resource?.[alias];
if (aliasedValue !== undefined) {
return aliasedValue;
}
}
}
return data[key];
};
export const getTraceListColumns = (
selectedColumns: BaseAutocompleteData[],
formatTimezoneAdjustedTimestamp: FormatTimezoneAdjustedTimestamp,
): ColumnsType<RowData> => {
const columns: ColumnsType<RowData> =
selectedColumns.map(({ dataType, key, type }) => ({
title: keyToLabelMap[getPrimaryKey(key)],
dataIndex: key,
key: `${key}-${dataType}-${type}`,
width: 145,
render: (value, item): JSX.Element => {
const itemData = item.data as any;
const primaryKey = getPrimaryKey(key);
if (primaryKey === 'timestamp') {
const date =
typeof value === 'string'
? formatTimezoneAdjustedTimestamp(value)
: formatTimezoneAdjustedTimestamp(value / 1e6);
return (
<BlockLink to={getTraceLink(itemData)} openInNewTab>
<Typography.Text className={styles.cellText}>{date}</Typography.Text>
</BlockLink>
);
}
if (value === '') {
return (
<BlockLink to={getTraceLink(itemData)} openInNewTab>
<Typography data-testid={key} className={styles.cellText}>
N/A
</Typography>
</BlockLink>
);
}
if (primaryKey === 'httpMethod') {
const httpMethod = getValueForKey(itemData, key);
if (!httpMethod) {
return (
<BlockLink to={getTraceLink(itemData)} openInNewTab>
<Typography className={styles.cellText}>N/A</Typography>
</BlockLink>
);
}
return (
<BlockLink to={getTraceLink(itemData)} openInNewTab>
<Badge
data-testid={key}
color="robin"
variant="outline"
className={styles.pointer}
>
{httpMethod}
</Badge>
</BlockLink>
);
}
if (primaryKey === 'responseStatusCode') {
const statusCode = getValueForKey(itemData, key);
const numericCode = Number(statusCode);
const isValidCode = !Number.isNaN(numericCode) && numericCode > 0;
if (!isValidCode) {
return (
<BlockLink to={getTraceLink(itemData)} openInNewTab>
<Typography className={styles.cellText}>
{numericCode === 0 || !statusCode ? 'N/A' : statusCode}
</Typography>
</BlockLink>
);
}
return (
<BlockLink to={getTraceLink(itemData)} openInNewTab>
<HttpStatusBadge
statusCode={statusCode}
testId={key}
className={styles.pointer}
/>
</BlockLink>
);
}
if (primaryKey === 'durationNano') {
const durationNano = getValueForKey(itemData, key);
return (
<BlockLink to={getTraceLink(itemData)} openInNewTab>
<Typography data-testid={key} className={styles.cellText}>
{getMs(durationNano)}ms
</Typography>
</BlockLink>
);
}
return (
<BlockLink to={getTraceLink(itemData)} openInNewTab>
<Typography data-testid={key} className={styles.cellText}>
{getValueForKey(itemData, key)}
</Typography>
</BlockLink>
);
},
responsive: ['md'],
})) || [];
return columns;
};

View File

@@ -1,104 +0,0 @@
import {
initialQueryBuilderFormValuesMap,
PANEL_TYPES,
} from 'constants/queryBuilder';
import { DEFAULT_PER_PAGE_VALUE } from 'container/Controls/config';
import { GetQueryResultsProps } from 'lib/dashboard/getQueryResults';
import { DataTypes } from 'types/api/queryBuilder/queryAutocompleteResponse';
import { IBuilderQuery } from 'types/api/queryBuilder/queryBuilderData';
import { EQueryType } from 'types/common/dashboard';
import {
DataSource,
ReduceOperators,
TracesAggregatorOperator,
} from 'types/common/queryBuilder';
import { v4 as uuidv4 } from 'uuid';
export interface EntityTracesQueryParams {
start: number;
end: number;
expression: string;
offset?: number;
pageSize?: number;
}
function buildEntityTracesQueryData(
expression: string,
{
offset,
pageSize,
}: {
offset: number;
pageSize: number;
},
): IBuilderQuery {
return {
...initialQueryBuilderFormValuesMap.traces,
queryName: 'A',
dataSource: DataSource.TRACES,
aggregateOperator: TracesAggregatorOperator.NOOP,
aggregateAttribute: {
id: '------false',
dataType: DataTypes.String,
key: '',
type: '',
},
timeAggregation: 'rate',
spaceAggregation: 'sum',
functions: [],
aggregations: [],
filter: { expression },
expression,
having: {
expression: '',
},
disabled: false,
stepInterval: 60,
limit: null,
orderBy: [
{
columnName: 'timestamp',
order: 'desc',
},
],
groupBy: [],
legend: '',
reduceTo: ReduceOperators.AVG,
offset,
pageSize,
};
}
export function getEntityTracesQueryPayload({
start,
end,
expression,
offset = 0,
pageSize = DEFAULT_PER_PAGE_VALUE,
}: EntityTracesQueryParams): {
query: GetQueryResultsProps;
queryData: IBuilderQuery;
} {
const queryData = buildEntityTracesQueryData(expression, { offset, pageSize });
return {
query: {
graphType: PANEL_TYPES.LIST,
selectedTime: 'GLOBAL_TIME',
query: {
clickhouse_sql: [],
promql: [],
builder: {
queryData: [queryData],
queryFormulas: [],
queryTraceOperator: [],
},
id: uuidv4(),
queryType: EQueryType.QUERY_BUILDER,
},
start,
end,
},
queryData,
};
}

View File

@@ -1,456 +0,0 @@
// Entity Details
.entity-detail-drawer {
border-left: 1px solid var(--l1-border);
background: var(--l2-background);
box-shadow: -4px 10px 16px 2px rgba(0, 0, 0, 0.2);
.ant-drawer-header {
padding: 8px 16px;
border-bottom: none;
align-items: stretch;
border-bottom: 1px solid var(--l1-border);
background: var(--l2-background);
}
.ant-drawer-close {
margin-inline-end: 0px;
}
.ant-drawer-body {
display: flex;
flex-direction: column;
padding: 16px;
}
.title {
color: var(--l2-foreground);
font-family: 'Geist Mono';
font-size: 14px;
font-style: normal;
font-weight: 500;
line-height: 20px; /* 142.857% */
letter-spacing: -0.07px;
}
.radio-button {
display: flex;
align-items: center;
justify-content: center;
padding-top: var(--padding-1);
border: 1px solid var(--l1-border);
background: var(--l3-background);
box-shadow: 0px 0px 8px 0px rgba(0, 0, 0, 0.1);
}
.entity-detail-drawer__entity {
.entity-details-grid {
.labels-row,
.values-row {
display: grid;
grid-template-columns: 1.5fr 1.5fr 1.5fr 1.5fr;
gap: 30px;
align-items: center;
}
.labels-row {
margin-bottom: 8px;
}
.entity-details-metadata-label {
color: var(--l2-foreground);
font-family: Inter;
font-size: 11px;
font-style: normal;
font-weight: 500;
line-height: 18px; /* 163.636% */
letter-spacing: 0.44px;
text-transform: uppercase;
}
.entity-details-metadata-value {
color: var(--l2-foreground);
font-family: 'Geist Mono';
font-size: 12px;
font-style: normal;
font-weight: 500;
line-height: 20px; /* 142.857% */
letter-spacing: -0.07px;
width: 100%;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.status-tag {
margin: 0;
&.active {
color: var(--success-500);
background: var(--success-100);
border-color: var(--success-500);
}
&.inactive {
color: var(--error-500);
background: var(--error-100);
border-color: var(--error-500);
}
}
.progress-container {
width: 158px;
span {
font-weight: 600;
}
}
.ant-card {
&.ant-card-bordered {
border: 1px solid var(--l1-border) !important;
}
}
}
}
.tabs-and-search {
display: flex;
justify-content: space-between;
align-items: center;
margin: 16px 0;
.action-btn {
border-radius: 2px;
border: 1px solid var(--l1-border);
background: var(--l3-background);
box-shadow: 0px 0px 8px 0px rgba(0, 0, 0, 0.1);
display: flex;
align-items: center;
justify-content: center;
}
}
.views-tabs-container {
margin-top: 1.5rem;
display: flex;
justify-content: space-between;
align-items: center;
.views-tabs {
color: var(--l2-foreground);
.view-title {
display: flex;
gap: var(--margin-2);
align-items: center;
justify-content: center;
font-size: var(--font-size-xs);
font-style: normal;
font-weight: var(--font-weight-normal);
}
> button {
border: 1px solid var(--l1-border);
width: 114px;
&::before {
background: var(--l1-border);
}
&[data-state='on'] {
background: var(--l1-border);
color: var(--l1-foreground);
border: 1px solid var(--l1-border);
&::before {
background: var(--l1-border);
}
}
}
}
.compass-button {
width: 30px;
height: 30px;
border-radius: 2px;
border: 1px solid var(--l1-border);
background: var(--l3-background);
box-shadow: 0px 0px 8px 0px rgba(0, 0, 0, 0.1);
}
}
.ant-drawer-close {
padding: 0px;
}
}
.entity-metric-traces {
margin-top: 1rem;
.entity-metric-traces-header {
display: flex;
justify-content: space-between;
margin-bottom: 1rem;
gap: 8px;
padding: 12px;
border-radius: 3px;
border: 1px solid var(--l1-border);
.filter-section {
flex: 1;
.ant-select-selector {
border-radius: 2px;
border: 1px solid var(--l1-border) !important;
background-color: var(--l3-background) !important;
input {
font-size: 12px;
}
[data-slot='badge'] .ant-typography {
font-size: 12px;
}
}
}
}
.entity-metric-traces-table {
.ant-table-content {
overflow: hidden !important;
}
.ant-table {
border-radius: 3px;
border: 1px solid var(--l1-border);
.ant-table-thead > tr > th {
padding: 12px;
font-weight: 500;
font-size: 12px;
line-height: 18px;
background: color-mix(in srgb, var(--bg-robin-200) 1%, transparent);
border-bottom: none;
color: var(--l2-foreground);
font-family: Inter;
font-size: 11px;
font-style: normal;
font-weight: 600;
line-height: 18px; /* 163.636% */
letter-spacing: 0.44px;
text-transform: uppercase;
&::before {
background-color: transparent;
}
}
.ant-table-thead > tr > th:has(.entityname-column-header) {
background: var(--l2-background);
}
.ant-table-cell {
padding: 12px;
font-size: 13px;
line-height: 20px;
color: var(--l1-foreground);
background: color-mix(in srgb, var(--bg-robin-200) 1%, transparent);
}
.ant-table-cell:has(.entityname-column-value) {
background: var(--l2-background);
}
.entityname-column-value {
color: var(--l1-foreground);
font-family: 'Geist Mono';
font-style: normal;
font-weight: 600;
line-height: 20px; /* 142.857% */
letter-spacing: -0.07px;
}
.status-cell {
.active-tag {
color: var(--bg-forest-500);
padding: 4px 8px;
border-radius: 4px;
font-size: 12px;
font-weight: 500;
}
}
.progress-container {
}
.ant-table-tbody > tr:hover > td {
background: color-mix(in srgb, var(--l1-foreground) 4%, transparent);
}
.ant-table-cell:first-child {
text-align: justify;
}
.ant-table-cell:nth-child(2) {
padding-left: 16px;
padding-right: 16px;
}
.ant-table-cell:nth-child(n + 3) {
padding-right: 24px;
}
.column-header-right {
text-align: right;
}
.ant-table-tbody > tr > td {
border-bottom: none;
}
.ant-table-thead
> tr
> th:not(:last-child):not(.ant-table-selection-column):not(
.ant-table-row-expand-icon-cell
):not([colspan])::before {
background-color: transparent;
}
.ant-empty-normal {
visibility: hidden;
}
}
.ant-table-container::after {
content: none;
}
}
}
.entity-metrics-logs-container {
margin-top: 1rem;
.filter-section {
flex: 1;
.ant-select-selector {
border-radius: 2px;
border: 1px solid var(--l1-border) !important;
background-color: var(--l3-background) !important;
input {
font-size: 12px;
}
[data-slot='badge'] .ant-typography {
font-size: 12px;
}
}
}
.entity-metrics-logs-header {
display: flex;
justify-content: space-between;
gap: 8px;
padding: 12px;
border-radius: 3px;
border: 1px solid var(--l1-border);
}
.entity-metrics-logs {
margin-top: 1rem;
.virtuoso-list {
overflow-y: hidden !important;
&::-webkit-scrollbar {
width: 0.3rem;
height: 0.3rem;
}
&::-webkit-scrollbar-track {
background: transparent;
}
&::-webkit-scrollbar-thumb {
background: var(--l3-background);
}
&::-webkit-scrollbar-thumb:hover {
background: var(--l3-background);
}
.ant-row {
width: fit-content;
}
}
.skeleton-container {
height: 100%;
padding: 16px;
}
}
}
.entity-metrics-logs-list-container {
flex: 1;
height: calc(100vh - 272px) !important;
display: flex;
height: 100%;
.raw-log-content {
width: 100%;
text-wrap: inherit;
word-wrap: break-word;
}
}
.entity-metrics-logs-list-card {
width: 100%;
margin-top: 12px;
.ant-card-body {
padding: 0;
height: 100%;
width: 100%;
}
}
.logs-loading-skeleton {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 8px;
padding: 8px 0;
.ant-skeleton-input-sm {
height: 18px;
}
}
.no-logs-found {
height: 50vh;
width: 100%;
display: flex;
justify-content: center;
align-items: center;
padding: 24px;
box-sizing: border-box;
.ant-typography {
display: flex;
align-items: center;
gap: 16px;
}
}

View File

@@ -1,282 +0,0 @@
import { PANEL_TYPES } from 'constants/queryBuilder';
import { GetQueryResultsProps } from 'lib/dashboard/getQueryResults';
import {
BaseAutocompleteData,
DataTypes,
} from 'types/api/queryBuilder/queryAutocompleteResponse';
import {
IBuilderQuery,
TagFilterItem,
} from 'types/api/queryBuilder/queryBuilderData';
import APIError from 'types/api/error';
import { EQueryType } from 'types/common/dashboard';
import { DataSource, ReduceOperators } from 'types/common/queryBuilder';
import { nanoToMilli } from 'utils/timeUtils';
import { v4 as uuidv4 } from 'uuid';
export function isKeyNotFoundError(error: unknown): boolean {
if (!(error instanceof APIError)) {
return false;
}
const errorDetails = error.getErrorDetails();
if (errorDetails.error.code !== 'invalid_input') {
return false;
}
const errors = errorDetails.error.errors || [];
return errors.some((err) => err.message?.includes('not found'));
}
export const QUERY_KEYS = {
K8S_OBJECT_KIND: 'k8s.object.kind',
K8S_OBJECT_NAME: 'k8s.object.name',
K8S_POD_NAME: 'k8s.pod.name',
K8S_NAMESPACE_NAME: 'k8s.namespace.name',
K8S_CLUSTER_NAME: 'k8s.cluster.name',
K8S_NODE_NAME: 'k8s.node.name',
K8S_DEPLOYMENT_NAME: 'k8s.deployment.name',
K8S_STATEFUL_SET_NAME: 'k8s.statefulset.name',
K8S_JOB_NAME: 'k8s.job.name',
K8S_DAEMON_SET_NAME: 'k8s.daemonset.name',
K8S_PERSISTENT_VOLUME_CLAIM_NAME: 'k8s.persistentvolumeclaim.name',
};
/**
* Returns the payload configuration to fetch events for a K8s entity
*/
export const getEntityEventsOrLogsQueryPayload = (
start: number,
end: number,
filters: IBuilderQuery['filters'],
): GetQueryResultsProps => ({
graphType: PANEL_TYPES.LIST,
selectedTime: 'GLOBAL_TIME',
query: {
clickhouse_sql: [],
promql: [],
builder: {
queryData: [
{
dataSource: DataSource.LOGS,
queryName: 'A',
aggregateOperator: 'noop',
aggregateAttribute: {
id: '------false',
dataType: DataTypes.String,
key: '',
type: '',
},
timeAggregation: 'rate',
spaceAggregation: 'sum',
functions: [],
filters,
expression: 'A',
disabled: false,
stepInterval: 60,
having: [],
limit: null,
orderBy: [
{
columnName: 'timestamp',
order: 'desc',
},
],
groupBy: [],
legend: '',
reduceTo: ReduceOperators.AVG,
offset: 0,
pageSize: 100,
},
],
queryFormulas: [],
queryTraceOperator: [],
},
id: uuidv4(),
queryType: EQueryType.QUERY_BUILDER,
},
start,
end,
});
export const entityTracesColumns = [
{
dataIndex: 'timestamp',
key: 'timestamp',
title: 'Timestamp',
width: 200,
render: (timestamp: string): string => new Date(timestamp).toLocaleString(),
},
{
title: 'Service Name',
dataIndex: ['data', 'serviceName'],
key: 'serviceName-string-tag',
width: 150,
},
{
title: 'Name',
dataIndex: ['data', 'name'],
key: 'name-string-tag',
width: 145,
},
{
title: 'Duration',
dataIndex: ['data', 'durationNano'],
key: 'durationNano-float64-tag',
width: 145,
render: (duration: number): string => `${nanoToMilli(duration)}ms`,
},
{
title: 'HTTP Method',
dataIndex: ['data', 'httpMethod'],
key: 'httpMethod-string-tag',
width: 145,
},
{
title: 'Status Code',
dataIndex: ['data', 'responseStatusCode'],
key: 'responseStatusCode-string-tag',
width: 145,
},
];
export const selectedEntityTracesColumns: BaseAutocompleteData[] = [
{
key: 'timestamp',
dataType: DataTypes.String,
type: 'tag',
},
{
key: 'serviceName',
dataType: DataTypes.String,
type: 'tag',
},
{
key: 'name',
dataType: DataTypes.String,
type: 'tag',
},
{
key: 'durationNano',
dataType: DataTypes.Float64,
type: 'tag',
},
{
key: 'httpMethod',
dataType: DataTypes.String,
type: 'tag',
},
{
key: 'responseStatusCode',
dataType: DataTypes.String,
type: 'tag',
},
];
export const getEntityTracesQueryPayload = (
start: number,
end: number,
offset = 0,
filters: IBuilderQuery['filters'],
): GetQueryResultsProps => ({
query: {
promql: [],
clickhouse_sql: [],
builder: {
queryData: [
{
dataSource: DataSource.TRACES,
queryName: 'A',
aggregateOperator: 'noop',
aggregateAttribute: {
id: '------false',
dataType: DataTypes.EMPTY,
key: '',
type: '',
},
timeAggregation: 'rate',
spaceAggregation: 'sum',
functions: [],
filters,
expression: 'A',
disabled: false,
stepInterval: 60,
having: [],
limit: null,
orderBy: [
{
columnName: 'timestamp',
order: 'desc',
},
],
groupBy: [],
legend: '',
reduceTo: ReduceOperators.AVG,
},
],
queryFormulas: [],
queryTraceOperator: [],
},
id: '572f1d91-6ac0-46c0-b726-c21488b34434',
queryType: EQueryType.QUERY_BUILDER,
},
graphType: PANEL_TYPES.LIST,
selectedTime: 'GLOBAL_TIME',
start,
end,
params: {
dataSource: DataSource.TRACES,
},
tableParams: {
pagination: {
limit: 10,
offset,
},
selectColumns: [
{
key: 'serviceName',
dataType: 'string',
type: 'tag',
id: 'serviceName--string--tag--true',
isIndexed: false,
},
{
key: 'name',
dataType: 'string',
type: 'tag',
id: 'name--string--tag--true',
isIndexed: false,
},
{
key: 'durationNano',
dataType: 'float64',
type: 'tag',
id: 'durationNano--float64--tag--true',
isIndexed: false,
},
{
key: 'httpMethod',
dataType: 'string',
type: 'tag',
id: 'httpMethod--string--tag--true',
isIndexed: false,
},
{
key: 'responseStatusCode',
dataType: 'string',
type: 'tag',
id: 'responseStatusCode--string--tag--true',
isIndexed: false,
},
],
},
});
export const filterOutPrimaryFilters = (
filters: TagFilterItem[],
primaryKeys: string[],
): TagFilterItem[] =>
filters.filter(
(filter) =>
!primaryKeys.includes(filter.key?.key ?? '') && filter.key?.key !== 'id',
);

View File

@@ -1,234 +0,0 @@
.infraMonitoringContainer {
display: flex;
height: 100%;
flex-direction: column;
}
.infraContentRow {
display: flex;
flex-direction: row;
height: calc(100vh - 45px);
width: 100%;
position: relative;
overflow-y: auto;
:global(.periscope-btn) {
&.ghost:not(:disabled) {
border: none;
background: transparent;
&:hover {
background: transparent;
color: var(--bg-robin-500) !important;
font-weight: 500;
}
}
}
&::-webkit-scrollbar {
width: 0.1rem;
height: 0.1rem;
}
&::-webkit-scrollbar-track {
background: transparent;
}
&::-webkit-scrollbar-thumb {
background: var(--accent-primary);
}
&::-webkit-scrollbar-thumb:hover {
background: colox-mix(var(--accent-primary), var(--bg-vanilla-100), 20%);
}
}
.quickFiltersContainer {
// Width is owned by ResizableBox (inline style). No overflow here: the panel
// must not scroll (QuickFilters scrolls its own list internally) — an
// overflow on the root would also clip the edge-mounted resize grip.
flex-shrink: 0;
border-right: 1px solid var(--l1-border);
:global(.resizable-box__content) {
display: flex;
flex-direction: column;
}
:global(.quick-filters) {
overflow-y: auto;
overflow-x: hidden;
&::-webkit-scrollbar {
width: 0.1rem;
height: 0.1rem;
}
&::-webkit-scrollbar-track {
background: transparent;
}
&::-webkit-scrollbar-thumb {
background: var(--accent-primary);
}
&::-webkit-scrollbar-thumb:hover {
background: colox-mix(var(--accent-primary), var(--bg-vanilla-100), 20%);
}
}
}
.quickFiltersContainerHeader {
padding: 8px;
border-bottom: 1px solid var(--l1-border);
display: flex;
align-items: center;
justify-content: space-between;
}
.listContainer {
flex: 1;
min-width: 0;
max-width: 100%;
display: flex;
flex-direction: column;
align-items: stretch;
> * {
min-width: 0;
max-width: 100%;
box-sizing: border-box;
}
> :global(.ant-table-wrapper) {
width: 100%;
max-width: 100%;
:global(.ant-spin-container) {
display: flex !important;
flex-direction: column;
}
:global(.ant-table),
:global(.ant-table-container) {
max-width: 100%;
}
}
}
.listContainerFiltersVisible {
// Width is driven by flex now that the filters panel is resizable; the list
// fills whatever space the (variable-width) panel leaves.
max-width: 100%;
}
.categorySelectorSection {
padding: var(--spacing-4);
}
.sectionHeader {
display: flex;
align-items: center;
gap: var(--spacing-4);
margin-bottom: var(--spacing-4);
&[data-type='filter'] {
padding: 0 var(--spacing-4);
}
}
.sectionLabel {
font-size: var(--periscope-font-size-small);
font-weight: var(--font-weight-semibold);
text-transform: uppercase;
letter-spacing: 0.5px;
color: var(--l3-foreground);
white-space: nowrap;
}
.sectionLine {
flex: 1;
height: 1px;
background: var(--l1-border);
}
.categoryCard {
background: var(--l2-background);
border: 1px solid var(--l2-border);
border-radius: var(--spacing-3);
padding: var(--spacing-2);
}
.categoryList {
display: flex;
flex-direction: column;
gap: var(--spacing-1);
}
.categoryItem {
display: flex;
align-items: center;
gap: var(--spacing-5);
padding: var(--spacing-3) var(--spacing-4);
border: none;
border-radius: var(--spacing-2);
background: transparent;
cursor: pointer;
width: 100%;
text-align: left;
transition:
background-color 0.2s ease,
color 0.2s ease;
color: var(--l2-foreground);
font-size: var(--periscope-font-size-base);
--typography-color: var(--l1-foreground);
&:hover:not(.categoryItemSelected) {
background: var(--l2-background-hover);
}
svg {
color: var(--l3-foreground);
flex-shrink: 0;
transition: color 0.2s ease;
}
&.categoryItemSelected {
background: var(--accent-primary);
color: var(--l1-background);
font-weight: 600;
&:hover {
background: var(--accent-primary-hover, var(--accent-primary));
}
svg {
color: var(--l1-foreground);
}
}
}
.quickFiltersSection {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
overflow-y: auto;
:global(.quick-filters-container) {
flex: 1;
min-height: 0;
}
&::-webkit-scrollbar {
width: 0.1rem;
}
&::-webkit-scrollbar-track {
background: transparent;
}
&::-webkit-scrollbar-thumb {
background: var(--accent-primary);
}
}

View File

@@ -1,344 +0,0 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import * as Sentry from '@sentry/react';
import { Button, Tooltip } from 'antd';
import { Typography } from '@signozhq/ui/typography';
import logEvent from 'api/common/logEvent';
import QuickFilters from 'components/QuickFilters/QuickFilters';
import { QuickFiltersSource } from 'components/QuickFilters/types';
import { InfraMonitoringEvents } from 'constants/events';
import { LOCALSTORAGE } from 'constants/localStorage';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { useQueryOperations } from 'hooks/queryBuilder/useQueryBuilderOperations';
import {
ArrowUpDown,
ArrowUpToLine,
Bolt,
Boxes,
Computer,
Container,
FilePenLine,
Filter,
Group,
HardDrive,
Workflow,
} from '@signozhq/icons';
import ErrorBoundaryFallback from 'pages/ErrorBoundaryFallback/ErrorBoundaryFallback';
import { ResizableBox } from 'periscope/components/ResizableBox';
import usePanelWidth from 'periscope/components/ResizableBox/usePanelWidth';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
import { FeatureKeys } from '../../constants/features';
import { useAppContext } from '../../providers/App/App';
import K8sClustersList from './Clusters/K8sClustersList';
import {
GetClustersQuickFiltersConfig,
GetDaemonsetsQuickFiltersConfig,
GetDeploymentsQuickFiltersConfig,
GetJobsQuickFiltersConfig,
GetNamespaceQuickFiltersConfig,
GetNodesQuickFiltersConfig,
GetPodsQuickFiltersConfig,
GetStatefulsetsQuickFiltersConfig,
GetVolumesQuickFiltersConfig,
K8sCategories,
} from './constants';
import K8sDaemonSetsList from './DaemonSets/K8sDaemonSetsList';
import K8sDeploymentsList from './Deployments/K8sDeploymentsList';
import {
useInfraMonitoringCategory,
useInfraMonitoringFiltersK8s,
useInfraMonitoringGroupBy,
useInfraMonitoringOrderBy,
} from './hooks';
import K8sJobsList from './Jobs/K8sJobsList';
import K8sNamespacesList from './Namespaces/K8sNamespacesList';
import K8sNodesList from './Nodes/K8sNodesList';
import K8sPodLists from './Pods/K8sPodLists';
import K8sStatefulSetsList from './StatefulSets/K8sStatefulSetsList';
import K8sVolumesList from './Volumes/K8sVolumesList';
import styles from './InfraMonitoringK8s.module.scss';
const QUICK_FILTERS_DEFAULT_WIDTH = 280;
const QUICK_FILTERS_MIN_WIDTH = 240;
const QUICK_FILTERS_MAX_WIDTH = 500;
export default function InfraMonitoringK8s(): JSX.Element {
const [showFilters, setShowFilters] = useState(true);
const {
initialWidth: quickFiltersInitialWidth,
persistWidth: persistQuickFiltersWidth,
} = usePanelWidth({
storageKey: LOCALSTORAGE.QUICK_FILTERS_WIDTH_INFRA,
defaultWidth: QUICK_FILTERS_DEFAULT_WIDTH,
minWidth: QUICK_FILTERS_MIN_WIDTH,
maxWidth: QUICK_FILTERS_MAX_WIDTH,
});
const [selectedCategory, setSelectedCategory] = useInfraMonitoringCategory();
const [urlFilters, setUrlFilters] = useInfraMonitoringFiltersK8s();
const [, setGroupBy] = useInfraMonitoringGroupBy();
const [, setOrderBy] = useInfraMonitoringOrderBy();
const { currentQuery } = useQueryBuilder();
const handleFilterVisibilityChange = useCallback((): void => {
setShowFilters((show) => !show);
}, []);
const { handleChangeQueryData } = useQueryOperations({
index: 0,
query: currentQuery.builder.queryData[0],
entityVersion: '',
});
// Track previous urlFilters to only sync when the value actually changes
// (not when handleChangeQueryData changes due to query updates)
const prevUrlFiltersRef = useRef<string | null>(null);
useEffect(() => {
const currentFiltersJson = urlFilters ? JSON.stringify(urlFilters) : null;
// Only sync if urlFilters value has actually changed
if (prevUrlFiltersRef.current !== currentFiltersJson) {
prevUrlFiltersRef.current = currentFiltersJson;
// Sync filters to query builder, using empty filter when urlFilters is null
handleChangeQueryData('filters', urlFilters || { items: [], op: 'and' });
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [urlFilters]); // handleChangeQueryData intentionally omitted - we call the current version but don't re-run when it changes
const { featureFlags } = useAppContext();
const dotMetricsEnabled =
featureFlags?.find((flag) => flag.name === FeatureKeys.DOT_METRICS_ENABLED)
?.active || false;
const handleFilterChange = (query: Query): void => {
// update the current query with the new filters
// in infra monitoring k8s, we are using only one query, hence updating the 0th index of queryData
const filters = query.builder.queryData[0].filters;
// The useEffect will sync filters to query builder, avoiding double state updates
setUrlFilters(filters || null);
logEvent(InfraMonitoringEvents.FilterApplied, {
entity: InfraMonitoringEvents.K8sEntity,
page: InfraMonitoringEvents.ListPage,
category: selectedCategory,
view: InfraMonitoringEvents.QuickFiltersView,
});
};
const categories = useMemo(
() => [
{
key: K8sCategories.PODS,
label: 'Pods',
icon: <Container size={14} />,
config: GetPodsQuickFiltersConfig(dotMetricsEnabled),
},
{
key: K8sCategories.NODES,
label: 'Nodes',
icon: <Workflow size={14} />,
config: GetNodesQuickFiltersConfig(dotMetricsEnabled),
},
{
key: K8sCategories.NAMESPACES,
label: 'Namespaces',
icon: <FilePenLine size={14} />,
config: GetNamespaceQuickFiltersConfig(dotMetricsEnabled),
},
{
key: K8sCategories.CLUSTERS,
label: 'Clusters',
icon: <Boxes size={14} />,
config: GetClustersQuickFiltersConfig(dotMetricsEnabled),
},
{
key: K8sCategories.DEPLOYMENTS,
label: 'Deployments',
icon: <Computer size={14} />,
config: GetDeploymentsQuickFiltersConfig(dotMetricsEnabled),
},
{
key: K8sCategories.JOBS,
label: 'Jobs',
icon: <Bolt size={14} />,
config: GetJobsQuickFiltersConfig(dotMetricsEnabled),
},
{
key: K8sCategories.DAEMONSETS,
label: 'DaemonSets',
icon: <Group size={14} />,
config: GetDaemonsetsQuickFiltersConfig(dotMetricsEnabled),
},
{
key: K8sCategories.STATEFULSETS,
label: 'StatefulSets',
icon: <ArrowUpDown size={14} />,
config: GetStatefulsetsQuickFiltersConfig(dotMetricsEnabled),
},
{
key: K8sCategories.VOLUMES,
label: 'Volumes',
icon: <HardDrive size={14} />,
config: GetVolumesQuickFiltersConfig(dotMetricsEnabled),
},
],
[dotMetricsEnabled],
);
const selectedCategoryConfig = useMemo(
() => categories.find((cat) => cat.key === selectedCategory)?.config,
[categories, selectedCategory],
);
const handleCategorySelect = (key: string): void => {
if (key !== selectedCategory) {
setSelectedCategory(key);
// Reset filters
setUrlFilters(null);
setOrderBy(null);
setGroupBy(null);
handleChangeQueryData('filters', { items: [], op: 'and' });
}
};
const showFiltersComp = useMemo(() => {
return (
<>
{!showFilters && (
<div>
<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={styles.infraMonitoringContainer}>
<div className={styles.infraContentRow}>
{showFilters && (
<ResizableBox
handle="right"
defaultWidth={QUICK_FILTERS_DEFAULT_WIDTH}
initialWidth={quickFiltersInitialWidth}
minWidth={QUICK_FILTERS_MIN_WIDTH}
maxWidth={QUICK_FILTERS_MAX_WIDTH}
onResize={persistQuickFiltersWidth}
resetToDefaultOnDoubleClick
withHandle
className={styles.quickFiltersContainer}
handleTestId="quick-filters-resize-handle"
>
<div className={styles.categorySelectorSection}>
<div className={styles.sectionHeader} data-type="resource">
<Typography.Text className={styles.sectionLabel}>
Viewing · Resource
</Typography.Text>
<div className={styles.sectionLine} />
<Tooltip title="Collapse Filters">
<ArrowUpToLine
style={{ transform: 'rotate(270deg)' }}
onClick={handleFilterVisibilityChange}
size="md"
/>
</Tooltip>
</div>
<div className={styles.categoryCard}>
<div className={styles.categoryList}>
{categories.map((category) => (
<button
key={category.key}
type="button"
className={`${styles.categoryItem} ${
selectedCategory === category.key
? styles.categoryItemSelected
: ''
}`}
onClick={(): void => handleCategorySelect(category.key)}
data-testid={`category-${category.key}`}
>
{category.icon}
<Typography.Text>{category.label}</Typography.Text>
</button>
))}
</div>
</div>
</div>
<div className={styles.quickFiltersSection}>
<div className={styles.sectionHeader} data-type="filter">
<Typography.Text className={styles.sectionLabel}>
Filter by
</Typography.Text>
<div className={styles.sectionLine} />
</div>
{selectedCategoryConfig && (
<QuickFilters
source={QuickFiltersSource.INFRA_MONITORING}
config={selectedCategoryConfig}
handleFilterVisibilityChange={handleFilterVisibilityChange}
onFilterChange={handleFilterChange}
/>
)}
</div>
</ResizableBox>
)}
<div
className={`${styles.listContainer} ${
showFilters ? styles.listContainerFiltersVisible : ''
}`}
>
{selectedCategory === K8sCategories.PODS && (
<K8sPodLists controlListPrefix={showFiltersComp} />
)}
{selectedCategory === K8sCategories.NODES && (
<K8sNodesList controlListPrefix={showFiltersComp} />
)}
{selectedCategory === K8sCategories.CLUSTERS && (
<K8sClustersList controlListPrefix={showFiltersComp} />
)}
{selectedCategory === K8sCategories.DEPLOYMENTS && (
<K8sDeploymentsList controlListPrefix={showFiltersComp} />
)}
{selectedCategory === K8sCategories.NAMESPACES && (
<K8sNamespacesList controlListPrefix={showFiltersComp} />
)}
{selectedCategory === K8sCategories.STATEFULSETS && (
<K8sStatefulSetsList controlListPrefix={showFiltersComp} />
)}
{selectedCategory === K8sCategories.JOBS && (
<K8sJobsList controlListPrefix={showFiltersComp} />
)}
{selectedCategory === K8sCategories.DAEMONSETS && (
<K8sDaemonSetsList controlListPrefix={showFiltersComp} />
)}
{selectedCategory === K8sCategories.VOLUMES && (
<K8sVolumesList controlListPrefix={showFiltersComp} />
)}
</div>
</div>
</div>
</Sentry.ErrorBoundary>
);
}

View File

@@ -1,117 +0,0 @@
import React, { useCallback } from 'react';
import { InfraMonitoringEvents } from 'constants/events';
import { FeatureKeys } from 'constants/features';
import { useAppContext } from 'providers/App/App';
import K8sBaseDetails, { K8sDetailsFilters } from '../Base/K8sBaseDetails';
import { K8sBaseList } from '../Base/K8sBaseList';
import { K8sBaseFilters } from '../Base/types';
import { InfraMonitoringEntity } from '../constants';
import { getK8sJobsList, K8sJobsData } from './api';
import {
getJobMetricsQueryPayload,
jobWidgetInfo,
k8sJobDetailsMetadataConfig,
k8sJobGetEntityName,
k8sJobGetSelectedItemFilters,
k8sJobInitialEventsFilter,
k8sJobInitialLogTracesFilter,
} from './constants';
import {
getK8sJobItemKey,
getK8sJobRowKey,
k8sJobsColumnsConfig,
} from './table.config';
function K8sJobsList({
controlListPrefix,
}: {
controlListPrefix?: React.ReactNode;
}): JSX.Element {
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 getK8sJobsList(
filters,
signal,
undefined,
dotMetricsEnabled,
);
return {
data: response.payload?.data.records || [],
total: response.payload?.data.total || 0,
error: response.error,
rawData: response.payload?.data,
};
},
[dotMetricsEnabled],
);
const fetchEntityData = useCallback(
async (
filters: K8sDetailsFilters,
signal?: AbortSignal,
): Promise<{ data: K8sJobsData | null; error?: string | null }> => {
const response = await getK8sJobsList(
{
filters: filters.filters,
start: filters.start,
end: filters.end,
limit: 1,
offset: 0,
},
signal,
undefined,
dotMetricsEnabled,
);
const records = response.payload?.data.records || [];
return {
data: records.length > 0 ? records[0] : null,
error: response.error,
};
},
[dotMetricsEnabled],
);
return (
<>
<K8sBaseList<K8sJobsData>
controlListPrefix={controlListPrefix}
entity={InfraMonitoringEntity.JOBS}
tableColumns={k8sJobsColumnsConfig}
fetchListData={fetchListData}
getRowKey={getK8sJobRowKey}
getItemKey={getK8sJobItemKey}
eventCategory={InfraMonitoringEvents.Job}
/>
<K8sBaseDetails<K8sJobsData>
category={InfraMonitoringEntity.JOBS}
eventCategory={InfraMonitoringEvents.Job}
getSelectedItemFilters={k8sJobGetSelectedItemFilters}
fetchEntityData={fetchEntityData}
getEntityName={k8sJobGetEntityName}
getInitialLogTracesFilters={k8sJobInitialLogTracesFilter}
getInitialEventsFilters={k8sJobInitialEventsFilter}
metadataConfig={k8sJobDetailsMetadataConfig}
entityWidgetInfo={jobWidgetInfo}
getEntityQueryPayload={getJobMetricsQueryPayload}
queryKeyPrefix="job"
/>
</>
);
}
export default K8sJobsList;

View File

@@ -1,119 +0,0 @@
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 { K8sBaseFilters } from '../Base/types';
export interface K8sJobsData {
jobName: string;
cpuUsage: number;
memoryUsage: number;
cpuRequest: number;
memoryRequest: number;
cpuLimit: number;
memoryLimit: number;
restarts: number;
desiredSuccessfulPods: number;
activePods: number;
failedPods: number;
successfulPods: number;
meta: {
k8s_cluster_name: string;
k8s_job_name: string;
k8s_namespace_name: string;
};
}
export interface K8sJobsListResponse {
status: string;
data: {
type: string;
records: K8sJobsData[];
groups: null;
total: number;
sentAnyHostMetricsData: boolean;
isSendingK8SAgentMetrics: boolean;
};
}
// TODO(H4ad): Erase this whole file when migrating to openapi
export const jobsMetaMap = [
{ dot: 'k8s.cluster.name', under: 'k8s_cluster_name' },
{ dot: 'k8s.job.name', under: 'k8s_job_name' },
{ dot: 'k8s.namespace.name', under: 'k8s_namespace_name' },
] as const;
export function mapJobsMeta(raw: Record<string, unknown>): K8sJobsData['meta'] {
const out: Record<string, unknown> = { ...raw };
jobsMetaMap.forEach(({ dot, under }) => {
if (dot in raw) {
const v = raw[dot];
out[under] = typeof v === 'string' ? v : raw[under];
}
});
return out as K8sJobsData['meta'];
}
export const getK8sJobsList = async (
props: K8sBaseFilters,
signal?: AbortSignal,
headers?: Record<string, string>,
dotMetricsEnabled = false,
): Promise<SuccessResponse<K8sJobsListResponse> | ErrorResponse> => {
try {
const requestProps = dotMetricsEnabled
? {
...props,
filters: {
...props.filters,
items: props.filters?.items.reduce<typeof props.filters.items>(
(acc, item) => {
if (item.value === undefined) {
return acc;
}
if (
item.key &&
typeof item.key === 'object' &&
'key' in item.key &&
typeof item.key.key === 'string'
) {
const mappedKey = UnderscoreToDotMap[item.key.key] ?? item.key.key;
acc.push({
...item,
key: { ...item.key, key: mappedKey },
});
} else {
acc.push(item);
}
return acc;
},
[] as typeof props.filters.items,
),
},
}
: props;
const response = await axios.post('/jobs/list', requestProps, {
signal,
headers,
});
const payload: K8sJobsListResponse = response.data;
payload.data.records = payload.data.records.map((record) => ({
...record,
meta: mapJobsMeta(record.meta as Record<string, unknown>),
}));
return {
statusCode: 200,
error: null,
message: 'Success',
payload,
params: requestProps,
};
} catch (error) {
return ErrorResponseHandler(error as AxiosError);
}
};

View File

@@ -1,472 +0,0 @@
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 { K8sJobsData } from './api';
export const k8sJobGetSelectedItemFilters = (
selectedItemId: string,
): TagFilter => ({
op: 'AND',
items: [
{
id: 'k8s_job_name',
key: {
key: 'k8s_job_name',
type: null,
},
op: '=',
value: selectedItemId,
},
],
});
export const k8sJobDetailsMetadataConfig: K8sDetailsMetadataConfig<K8sJobsData>[] =
[
{
label: 'Job Name',
getValue: (p): string => p.meta.k8s_job_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 k8sJobInitialEventsFilter = (
item: K8sJobsData,
): ReturnType<typeof createFilterItem>[] => [
createFilterItem(QUERY_KEYS.K8S_OBJECT_KIND, 'Job'),
createFilterItem(QUERY_KEYS.K8S_OBJECT_NAME, item.meta.k8s_job_name),
];
export const k8sJobInitialLogTracesFilter = (
item: K8sJobsData,
): ReturnType<typeof createFilterItem>[] => [
createFilterItem(QUERY_KEYS.K8S_JOB_NAME, item.meta.k8s_job_name),
createFilterItem(QUERY_KEYS.K8S_NAMESPACE_NAME, item.meta.k8s_namespace_name),
];
export const k8sJobGetEntityName = (item: K8sJobsData): string =>
item.meta.k8s_job_name;
export const jobWidgetInfo = [
{
title: 'CPU usage',
yAxisUnit: '',
},
{
title: 'Memory Usage',
yAxisUnit: 'bytes',
},
{
title: 'Network IO',
yAxisUnit: 'binBps',
},
{
title: 'Network errors count',
yAxisUnit: '',
},
];
export const getJobMetricsQueryPayload = (
job: K8sJobsData,
start: number,
end: number,
dotMetricsEnabled: boolean,
): GetQueryResultsProps[] => {
const k8sPodCpuUtilizationKey = dotMetricsEnabled
? 'k8s.pod.cpu.usage'
: 'k8s_pod_cpu_usage';
const k8sPodMemoryUsageKey = dotMetricsEnabled
? 'k8s.pod.memory.usage'
: 'k8s_pod_memory_usage';
const k8sPodNetworkIoKey = dotMetricsEnabled
? 'k8s.pod.network.io'
: 'k8s_pod_network_io';
const k8sPodNetworkErrorsKey = dotMetricsEnabled
? 'k8s.pod.network.errors'
: 'k8s_pod_network_errors';
const k8sJobNameKey = dotMetricsEnabled ? 'k8s.job.name' : 'k8s_job_name';
const k8sNamespaceNameKey = dotMetricsEnabled
? 'k8s.namespace.name'
: 'k8s_namespace_name';
return [
{
selectedTime: 'GLOBAL_TIME',
graphType: PANEL_TYPES.TIME_SERIES,
query: {
builder: {
queryData: [
{
aggregateAttribute: {
dataType: DataTypes.Float64,
id: 'k8s_pod_cpu_usage--float64--Gauge--true',
key: k8sPodCpuUtilizationKey,
type: 'Gauge',
},
aggregateOperator: 'avg',
dataSource: DataSource.METRICS,
disabled: false,
expression: 'A',
filters: {
items: [
{
id: '6b59b690',
key: {
dataType: DataTypes.String,
id: 'k8s_job_name--string--tag--false',
key: k8sJobNameKey,
type: 'tag',
},
op: '=',
value: job.jobName,
},
{
id: '47b3adae',
key: {
dataType: DataTypes.String,
id: 'k8s_namespace_name--string--tag--false',
key: k8sNamespaceNameKey,
type: 'tag',
},
op: '=',
value: job.meta.k8s_namespace_name,
},
],
op: 'AND',
},
functions: [],
groupBy: [],
having: [],
legend: 'usage',
limit: null,
orderBy: [],
queryName: 'A',
reduceTo: ReduceOperators.AVG,
spaceAggregation: 'sum',
stepInterval: 60,
timeAggregation: 'avg',
},
],
queryFormulas: [],
queryTraceOperator: [],
},
clickhouse_sql: [
{
disabled: false,
legend: '',
name: 'A',
query: '',
},
],
id: v4(),
promql: [
{
disabled: false,
legend: '',
name: 'A',
query: '',
},
],
queryType: EQueryType.QUERY_BUILDER,
},
variables: {},
formatForWeb: false,
start,
end,
},
{
selectedTime: 'GLOBAL_TIME',
graphType: PANEL_TYPES.TIME_SERIES,
query: {
builder: {
queryData: [
{
aggregateAttribute: {
dataType: DataTypes.Float64,
id: 'k8s_pod_memory_usage--float64--Gauge--true',
key: k8sPodMemoryUsageKey,
type: 'Gauge',
},
aggregateOperator: 'avg',
dataSource: DataSource.METRICS,
disabled: false,
expression: 'A',
filters: {
items: [
{
id: '8c217f4d',
key: {
dataType: DataTypes.String,
id: 'k8s_job_name--string--tag--false',
key: k8sJobNameKey,
type: 'tag',
},
op: '=',
value: job.jobName,
},
{
id: '47b3adae',
key: {
dataType: DataTypes.String,
id: 'k8s_namespace_name--string--tag--false',
key: k8sNamespaceNameKey,
type: 'tag',
},
op: '=',
value: job.meta.k8s_namespace_name,
},
],
op: 'AND',
},
functions: [],
groupBy: [],
having: [],
legend: 'usage',
limit: null,
orderBy: [],
queryName: 'A',
reduceTo: ReduceOperators.AVG,
spaceAggregation: 'sum',
stepInterval: 60,
timeAggregation: 'avg',
},
],
queryFormulas: [],
queryTraceOperator: [],
},
clickhouse_sql: [
{
disabled: false,
legend: '',
name: 'A',
query: '',
},
],
id: v4(),
promql: [
{
disabled: false,
legend: '',
name: 'A',
query: '',
},
],
queryType: EQueryType.QUERY_BUILDER,
},
variables: {},
formatForWeb: false,
start,
end,
},
{
selectedTime: 'GLOBAL_TIME',
graphType: PANEL_TYPES.TIME_SERIES,
query: {
builder: {
queryData: [
{
aggregateAttribute: {
dataType: DataTypes.Float64,
id: 'k8s_pod_network_io--float64--Sum--true',
key: k8sPodNetworkIoKey,
type: 'Sum',
},
aggregateOperator: 'rate',
dataSource: DataSource.METRICS,
disabled: false,
expression: 'A',
filters: {
items: [
{
id: '2bbf9d0c',
key: {
dataType: DataTypes.String,
id: 'k8s_job_name--string--tag--false',
key: k8sJobNameKey,
type: 'tag',
},
op: '=',
value: job.jobName,
},
{
id: '47b3adae',
key: {
dataType: DataTypes.String,
id: 'k8s_namespace_name--string--tag--false',
key: k8sNamespaceNameKey,
type: 'tag',
},
op: '=',
value: job.meta.k8s_namespace_name,
},
],
op: 'AND',
},
functions: [],
groupBy: [
{
dataType: DataTypes.String,
id: 'direction--string--tag--false',
key: 'direction',
type: 'tag',
},
{
dataType: DataTypes.String,
id: 'interface--string--tag--false',
key: 'interface',
type: 'tag',
},
],
having: [],
legend: '{{direction}} :: {{interface}}',
limit: null,
orderBy: [],
queryName: 'A',
reduceTo: ReduceOperators.AVG,
spaceAggregation: 'sum',
stepInterval: 60,
timeAggregation: 'rate',
},
],
queryFormulas: [],
queryTraceOperator: [],
},
clickhouse_sql: [
{
disabled: false,
legend: '',
name: 'A',
query: '',
},
],
id: v4(),
promql: [
{
disabled: false,
legend: '',
name: 'A',
query: '',
},
],
queryType: EQueryType.QUERY_BUILDER,
},
variables: {},
formatForWeb: false,
start,
end,
},
{
selectedTime: 'GLOBAL_TIME',
graphType: PANEL_TYPES.TIME_SERIES,
query: {
builder: {
queryData: [
{
aggregateAttribute: {
dataType: DataTypes.Float64,
id: 'k8s_pod_network_errors--float64--Sum--true',
key: k8sPodNetworkErrorsKey,
type: 'Sum',
},
aggregateOperator: 'increase',
dataSource: DataSource.METRICS,
disabled: false,
expression: 'A',
filters: {
items: [
{
id: '448e6cf7',
key: {
dataType: DataTypes.String,
id: 'k8s_job_name--string--tag--false',
key: k8sJobNameKey,
type: 'tag',
},
op: '=',
value: job.jobName,
},
{
id: '47b3adae',
key: {
dataType: DataTypes.String,
id: 'k8s_namespace_name--string--tag--false',
key: k8sNamespaceNameKey,
type: 'tag',
},
op: '=',
value: job.meta.k8s_namespace_name,
},
],
op: 'AND',
},
functions: [],
groupBy: [
{
dataType: DataTypes.String,
id: 'direction--string--tag--false',
key: 'direction',
type: 'tag',
},
{
dataType: DataTypes.String,
id: 'interface--string--tag--false',
key: 'interface',
type: 'tag',
},
],
having: [],
legend: '{{direction}} :: {{interface}}',
limit: null,
orderBy: [],
queryName: 'A',
reduceTo: ReduceOperators.AVG,
spaceAggregation: 'sum',
stepInterval: 60,
timeAggregation: 'increase',
},
],
queryFormulas: [],
queryTraceOperator: [],
},
clickhouse_sql: [
{
disabled: false,
legend: '',
name: 'A',
query: '',
},
],
id: v4(),
promql: [
{
disabled: false,
legend: '',
name: 'A',
query: '',
},
],
queryType: EQueryType.QUERY_BUILDER,
},
variables: {},
formatForWeb: false,
start,
end,
},
];
};

View File

@@ -1,273 +0,0 @@
import { Tooltip } from 'antd';
import { TableColumnDef } from 'components/TanStackTableView';
import TanStackTable from 'components/TanStackTableView';
import { ExpandButtonWrapper } from 'container/InfraMonitoringK8sV2/components';
import EntityGroupHeader from '../Base/EntityGroupHeader';
import K8sGroupCell from '../Base/K8sGroupCell';
import { formatBytes } from '../commonUtils';
import { EntityProgressBar, ValidateColumnValueWrapper } from '../components';
import { InfraMonitoringEntity } from '../constants';
import { K8sJobsData } from './api';
import { Bolt } from '@signozhq/icons';
export function getK8sJobRowKey(job: K8sJobsData): string {
return job.jobName || job.meta.k8s_job_name || '';
}
export function getK8sJobItemKey(job: K8sJobsData): string {
return job.meta.k8s_job_name;
}
export const k8sJobsColumnsConfig: TableColumnDef<K8sJobsData>[] = [
{
id: 'jobGroup',
header: (): React.ReactNode => <EntityGroupHeader title="JOB GROUP" />,
accessorFn: (row): string => row.meta.k8s_job_name || '',
width: { min: 270 },
enableSort: false,
enableRemove: false,
enableMove: false,
pin: 'left',
visibilityBehavior: 'hidden-on-collapse',
cell: ({ isExpanded, toggleExpanded, row }): JSX.Element | null => {
return (
<ExpandButtonWrapper
isExpanded={isExpanded}
toggleExpanded={toggleExpanded}
>
<K8sGroupCell row={row} />
</ExpandButtonWrapper>
);
},
},
{
id: 'jobName',
header: (): React.ReactNode => (
<EntityGroupHeader
title="Job Name"
icon={<Bolt data-hide-expanded="true" size={14} />}
/>
),
accessorFn: (row): string => row.meta.k8s_job_name || '',
width: { min: 260 },
enableSort: false,
enableRemove: false,
enableMove: false,
pin: 'left',
visibilityBehavior: 'hidden-on-expand',
cell: ({ value }): React.ReactNode => {
const jobName = value as string;
return (
<Tooltip title={jobName}>
<TanStackTable.Text>{jobName}</TanStackTable.Text>
</Tooltip>
);
},
},
{
id: 'namespaceName',
header: 'Namespace Name',
accessorFn: (row): string => row.meta.k8s_namespace_name || '',
width: { default: 150 },
enableSort: false,
cell: ({ value }): React.ReactNode => {
const namespaceName = value as string;
return (
<Tooltip title={namespaceName}>
<TanStackTable.Text>{namespaceName}</TanStackTable.Text>
</Tooltip>
);
},
},
{
id: 'successful_pods',
header: 'Successful',
accessorFn: (row): number => row.successfulPods,
width: { min: 120 },
enableSort: true,
cell: ({ value }): React.ReactNode => {
const successfulPods = value as number;
return (
<ValidateColumnValueWrapper
value={successfulPods}
entity={InfraMonitoringEntity.JOBS}
attribute="successful pod"
>
<TanStackTable.Text>{successfulPods}</TanStackTable.Text>
</ValidateColumnValueWrapper>
);
},
},
{
id: 'failed_pods',
header: 'Failed',
accessorFn: (row): number => row.failedPods,
width: { min: 100 },
enableSort: true,
cell: ({ value }): React.ReactNode => {
const failedPods = value as number;
return (
<ValidateColumnValueWrapper
value={failedPods}
entity={InfraMonitoringEntity.JOBS}
attribute="failed pod"
>
<TanStackTable.Text>{failedPods}</TanStackTable.Text>
</ValidateColumnValueWrapper>
);
},
},
{
id: 'desired_successful_pods',
header: 'Desired Successful',
accessorFn: (row): number => row.desiredSuccessfulPods,
width: { min: 160 },
enableSort: true,
cell: ({ value }): React.ReactNode => {
const desiredSuccessfulPods = value as number;
return (
<ValidateColumnValueWrapper
value={desiredSuccessfulPods}
entity={InfraMonitoringEntity.JOBS}
attribute="desired successful pod"
>
<TanStackTable.Text>{desiredSuccessfulPods}</TanStackTable.Text>
</ValidateColumnValueWrapper>
);
},
},
{
id: 'active_pods',
header: 'Active',
accessorFn: (row): number => row.activePods,
width: { min: 100 },
enableSort: true,
cell: ({ value }): React.ReactNode => {
const activePods = value as number;
return (
<ValidateColumnValueWrapper
value={activePods}
entity={InfraMonitoringEntity.JOBS}
attribute="active pod"
>
<TanStackTable.Text>{activePods}</TanStackTable.Text>
</ValidateColumnValueWrapper>
);
},
},
{
id: 'cpu_request',
header: 'CPU Req Usage (%)',
accessorFn: (row): number => row.cpuRequest,
width: { min: 200, default: 200 },
enableSort: true,
cell: ({ value }): React.ReactNode => {
const cpuRequest = value as number;
return (
<ValidateColumnValueWrapper
value={cpuRequest}
entity={InfraMonitoringEntity.JOBS}
attribute="CPU Request"
>
<EntityProgressBar value={cpuRequest} type="request" />
</ValidateColumnValueWrapper>
);
},
},
{
id: 'cpu_limit',
header: 'CPU Limit Usage (%)',
accessorFn: (row): number => row.cpuLimit,
width: { min: 200, default: 200 },
enableSort: true,
cell: ({ value }): React.ReactNode => {
const cpuLimit = value as number;
return (
<ValidateColumnValueWrapper
value={cpuLimit}
entity={InfraMonitoringEntity.JOBS}
attribute="CPU Limit"
>
<EntityProgressBar value={cpuLimit} type="limit" />
</ValidateColumnValueWrapper>
);
},
},
{
id: 'cpu',
header: 'CPU Usage (cores)',
accessorFn: (row): number => row.cpuUsage,
width: { min: 190 },
enableSort: true,
cell: ({ value }): React.ReactNode => {
const cpu = value as number;
return (
<ValidateColumnValueWrapper
value={cpu}
entity={InfraMonitoringEntity.JOBS}
attribute="CPU metric"
>
<TanStackTable.Text>{cpu}</TanStackTable.Text>
</ValidateColumnValueWrapper>
);
},
},
{
id: 'memory_request',
header: 'Mem Req Usage (%)',
accessorFn: (row): number => row.memoryRequest,
width: { min: 190 },
enableSort: true,
cell: ({ value }): React.ReactNode => {
const memoryRequest = value as number;
return (
<ValidateColumnValueWrapper
value={memoryRequest}
entity={InfraMonitoringEntity.JOBS}
attribute="Memory Request"
>
<EntityProgressBar value={memoryRequest} type="request" />
</ValidateColumnValueWrapper>
);
},
},
{
id: 'memory_limit',
header: 'Mem Limit Usage (%)',
accessorFn: (row): number => row.memoryLimit,
width: { min: 180 },
enableSort: true,
cell: ({ value }): React.ReactNode => {
const memoryLimit = value as number;
return (
<ValidateColumnValueWrapper
value={memoryLimit}
entity={InfraMonitoringEntity.JOBS}
attribute="Memory Limit"
>
<EntityProgressBar value={memoryLimit} type="limit" />
</ValidateColumnValueWrapper>
);
},
},
{
id: 'memory',
header: 'Mem Usage (WSS)',
accessorFn: (row): number => row.memoryUsage,
width: { min: 160 },
enableSort: true,
cell: ({ value }): React.ReactNode => {
const memory = value as number;
return (
<ValidateColumnValueWrapper
value={memory}
entity={InfraMonitoringEntity.JOBS}
attribute="memory metric"
>
<TanStackTable.Text>{formatBytes(memory)}</TanStackTable.Text>
</ValidateColumnValueWrapper>
);
},
},
];

View File

@@ -1,11 +0,0 @@
.loadingState {
padding: 8px;
display: flex;
flex-direction: column;
gap: 2px;
}
.loadingStateItem {
height: 48px;
width: 100%;
}

View File

@@ -1,30 +0,0 @@
import { Skeleton } from 'antd';
import styles from './LoadingContainer.module.scss';
function LoadingContainer(): JSX.Element {
return (
<div className={styles.loadingState} data-testid="loader">
<Skeleton.Input
className={styles.loadingStateItem}
size="large"
block
active
/>
<Skeleton.Input
className={styles.loadingStateItem}
size="large"
block
active
/>
<Skeleton.Input
className={styles.loadingStateItem}
size="large"
block
active
/>
</div>
);
}
export default LoadingContainer;

View File

@@ -1,117 +0,0 @@
import React, { useCallback } from 'react';
import { InfraMonitoringEvents } from 'constants/events';
import { FeatureKeys } from 'constants/features';
import { useAppContext } from 'providers/App/App';
import K8sBaseDetails, { K8sDetailsFilters } from '../Base/K8sBaseDetails';
import { K8sBaseList } from '../Base/K8sBaseList';
import { K8sBaseFilters } from '../Base/types';
import { InfraMonitoringEntity } from '../constants';
import { getK8sNamespacesList, K8sNamespacesData } from './api';
import {
getNamespaceMetricsQueryPayload,
k8sNamespaceDetailsMetadataConfig,
k8sNamespaceGetEntityName,
k8sNamespaceGetSelectedItemFilters,
k8sNamespaceInitialEventsFilter,
k8sNamespaceInitialLogTracesFilter,
namespaceWidgetInfo,
} from './constants';
import {
getK8sNamespaceItemKey,
getK8sNamespaceRowKey,
k8sNamespacesColumnsConfig,
} from './table.config';
function K8sNamespacesList({
controlListPrefix,
}: {
controlListPrefix?: React.ReactNode;
}): JSX.Element {
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 getK8sNamespacesList(
filters,
signal,
undefined,
dotMetricsEnabled,
);
return {
data: response.payload?.data.records || [],
total: response.payload?.data.total || 0,
error: response.error,
rawData: response.payload?.data,
};
},
[dotMetricsEnabled],
);
const fetchEntityData = useCallback(
async (
filters: K8sDetailsFilters,
signal?: AbortSignal,
): Promise<{ data: K8sNamespacesData | null; error?: string | null }> => {
const response = await getK8sNamespacesList(
{
filters: filters.filters,
start: filters.start,
end: filters.end,
limit: 1,
offset: 0,
},
signal,
undefined,
dotMetricsEnabled,
);
const records = response.payload?.data.records || [];
return {
data: records.length > 0 ? records[0] : null,
error: response.error,
};
},
[dotMetricsEnabled],
);
return (
<>
<K8sBaseList<K8sNamespacesData>
controlListPrefix={controlListPrefix}
entity={InfraMonitoringEntity.NAMESPACES}
tableColumns={k8sNamespacesColumnsConfig}
fetchListData={fetchListData}
getRowKey={getK8sNamespaceRowKey}
getItemKey={getK8sNamespaceItemKey}
eventCategory={InfraMonitoringEvents.Namespace}
/>
<K8sBaseDetails<K8sNamespacesData>
category={InfraMonitoringEntity.NAMESPACES}
eventCategory={InfraMonitoringEvents.Namespace}
getSelectedItemFilters={k8sNamespaceGetSelectedItemFilters}
fetchEntityData={fetchEntityData}
getEntityName={k8sNamespaceGetEntityName}
getInitialLogTracesFilters={k8sNamespaceInitialLogTracesFilter}
getInitialEventsFilters={k8sNamespaceInitialEventsFilter}
metadataConfig={k8sNamespaceDetailsMetadataConfig}
entityWidgetInfo={namespaceWidgetInfo}
getEntityQueryPayload={getNamespaceMetricsQueryPayload}
queryKeyPrefix="namespace"
/>
</>
);
}
export default K8sNamespacesList;

View File

@@ -1,123 +0,0 @@
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/types';
export interface K8sNamespacesListPayload {
filters: TagFilter;
groupBy?: BaseAutocompleteData[];
offset?: number;
limit?: number;
orderBy?: {
columnName: string;
order: 'asc' | 'desc';
};
}
export interface K8sNamespacesData {
namespaceName: string;
cpuUsage: number;
memoryUsage: number;
meta: {
k8s_cluster_name: string;
k8s_namespace_name: string;
};
}
export interface K8sNamespacesListResponse {
status: string;
data: {
type: string;
records: K8sNamespacesData[];
groups: null;
total: number;
sentAnyHostMetricsData: boolean;
isSendingK8SAgentMetrics: boolean;
};
}
// TODO(H4ad): Erase this whole file when migrating to openapi
export const namespacesMetaMap = [
{ dot: 'k8s.cluster.name', under: 'k8s_cluster_name' },
{ dot: 'k8s.namespace.name', under: 'k8s_namespace_name' },
] as const;
export function mapNamespacesMeta(
raw: Record<string, unknown>,
): K8sNamespacesData['meta'] {
const out: Record<string, unknown> = { ...raw };
namespacesMetaMap.forEach(({ dot, under }) => {
if (dot in raw) {
const v = raw[dot];
out[under] = typeof v === 'string' ? v : raw[under];
}
});
return out as K8sNamespacesData['meta'];
}
export const getK8sNamespacesList = async (
props: K8sBaseFilters,
signal?: AbortSignal,
headers?: Record<string, string>,
dotMetricsEnabled = false,
): Promise<SuccessResponse<K8sNamespacesListResponse> | ErrorResponse> => {
try {
const requestProps = dotMetricsEnabled
? {
...props,
filters: {
...props.filters,
items: props.filters?.items.reduce<typeof props.filters.items>(
(acc, item) => {
if (item.value === undefined) {
return acc;
}
if (
item.key &&
typeof item.key === 'object' &&
'key' in item.key &&
typeof item.key.key === 'string'
) {
const mappedKey = UnderscoreToDotMap[item.key.key] ?? item.key.key;
acc.push({
...item,
key: { ...item.key, key: mappedKey },
});
} else {
acc.push(item);
}
return acc;
},
[] as typeof props.filters.items,
),
},
}
: props;
const response = await axios.post('/namespaces/list', requestProps, {
signal,
headers,
});
const payload: K8sNamespacesListResponse = response.data;
payload.data.records = payload.data.records.map((record) => ({
...record,
meta: mapNamespacesMeta(record.meta as Record<string, unknown>),
}));
return {
statusCode: 200,
error: null,
message: 'Success',
payload,
params: requestProps,
};
} catch (error) {
return ErrorResponseHandler(error as AxiosError);
}
};

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