mirror of
https://github.com/SigNoz/signoz.git
synced 2026-07-08 23:50:40 +01:00
Compare commits
3 Commits
fixes/dash
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cd8bf27da7 | ||
|
|
dde4a3abc7 | ||
|
|
1d441b63cf |
@@ -116,6 +116,15 @@ 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 {
|
||||
|
||||
@@ -11,6 +11,7 @@ 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',
|
||||
}
|
||||
|
||||
@@ -1,4 +0,0 @@
|
||||
.wrapper :global(button[data-color]) {
|
||||
--checkbox-checked-background: var(--series-color);
|
||||
--checkbox-border-color: var(--series-color);
|
||||
}
|
||||
@@ -3,7 +3,6 @@ import { Checkbox } from '@signozhq/ui/checkbox';
|
||||
import { CSSProperties } from 'react';
|
||||
|
||||
import { CheckBoxProps } from '../types';
|
||||
import styles from './CustomCheckBox.module.scss';
|
||||
|
||||
function CustomCheckBox({
|
||||
data,
|
||||
@@ -16,11 +15,12 @@ function CustomCheckBox({
|
||||
const isChecked = graphVisibilityState[index] || false;
|
||||
|
||||
const colorStyle = {
|
||||
'--series-color': color,
|
||||
'--checkbox-checked-background': color,
|
||||
'--checkbox-border-color': color,
|
||||
} as CSSProperties;
|
||||
|
||||
return (
|
||||
<span className={styles.wrapper} style={colorStyle}>
|
||||
<span style={colorStyle}>
|
||||
<Checkbox
|
||||
onChange={(checked): void => checkBoxOnChangeHandler(checked, index)}
|
||||
value={isChecked}
|
||||
|
||||
225
frontend/src/container/InfraMonitoringHostsV2/Hosts.tsx
Normal file
225
frontend/src/container/InfraMonitoringHostsV2/Hosts.tsx
Normal file
@@ -0,0 +1,225 @@
|
||||
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;
|
||||
@@ -0,0 +1,171 @@
|
||||
.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);
|
||||
}
|
||||
191
frontend/src/container/InfraMonitoringHostsV2/constants.tsx
Normal file
191
frontend/src/container/InfraMonitoringHostsV2/constants.tsx
Normal file
@@ -0,0 +1,191 @@
|
||||
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,
|
||||
};
|
||||
}
|
||||
69
frontend/src/container/InfraMonitoringHostsV2/index.tsx
Normal file
69
frontend/src/container/InfraMonitoringHostsV2/index.tsx
Normal file
@@ -0,0 +1,69 @@
|
||||
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;
|
||||
201
frontend/src/container/InfraMonitoringHostsV2/table.config.tsx
Normal file
201
frontend/src/container/InfraMonitoringHostsV2/table.config.tsx
Normal file
@@ -0,0 +1,201 @@
|
||||
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>
|
||||
);
|
||||
},
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,68 @@
|
||||
.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;
|
||||
}
|
||||
156
frontend/src/container/InfraMonitoringHostsV2/utils.tsx
Normal file
156
frontend/src/container/InfraMonitoringHostsV2/utils.tsx
Normal file
@@ -0,0 +1,156 @@
|
||||
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,
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
.entityGroupHeader {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-5);
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
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;
|
||||
@@ -0,0 +1,666 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
.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;
|
||||
}
|
||||
}
|
||||
297
frontend/src/container/InfraMonitoringK8sV2/Base/K8sBaseList.tsx
Normal file
297
frontend/src/container/InfraMonitoringK8sV2/Base/K8sBaseList.tsx
Normal file
@@ -0,0 +1,297 @@
|
||||
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}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
.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;
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
.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);
|
||||
}
|
||||
@@ -0,0 +1,238 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
.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);
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
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;
|
||||
@@ -0,0 +1,17 @@
|
||||
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;
|
||||
@@ -0,0 +1,80 @@
|
||||
.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);
|
||||
}
|
||||
232
frontend/src/container/InfraMonitoringK8sV2/Base/K8sHeader.tsx
Normal file
232
frontend/src/container/InfraMonitoringK8sV2/Base/K8sHeader.tsx
Normal file
@@ -0,0 +1,232 @@
|
||||
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;
|
||||
@@ -0,0 +1,912 @@
|
||||
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();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
26
frontend/src/container/InfraMonitoringK8sV2/Base/types.ts
Normal file
26
frontend/src/container/InfraMonitoringK8sV2/Base/types.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
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>;
|
||||
};
|
||||
@@ -0,0 +1,114 @@
|
||||
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] ?? []];
|
||||
};
|
||||
@@ -0,0 +1,18 @@
|
||||
.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;
|
||||
}
|
||||
100
frontend/src/container/InfraMonitoringK8sV2/Base/utils.tsx
Normal file
100
frontend/src/container/InfraMonitoringK8sV2/Base/utils.tsx
Normal file
@@ -0,0 +1,100 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
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;
|
||||
125
frontend/src/container/InfraMonitoringK8sV2/Clusters/api.ts
Normal file
125
frontend/src/container/InfraMonitoringK8sV2/Clusters/api.ts
Normal file
@@ -0,0 +1,125 @@
|
||||
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);
|
||||
}
|
||||
};
|
||||
1637
frontend/src/container/InfraMonitoringK8sV2/Clusters/constants.ts
Normal file
1637
frontend/src/container/InfraMonitoringK8sV2/Clusters/constants.ts
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,156 @@
|
||||
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>
|
||||
);
|
||||
},
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,117 @@
|
||||
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;
|
||||
119
frontend/src/container/InfraMonitoringK8sV2/DaemonSets/api.ts
Normal file
119
frontend/src/container/InfraMonitoringK8sV2/DaemonSets/api.ts
Normal file
@@ -0,0 +1,119 @@
|
||||
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);
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,697 @@
|
||||
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,
|
||||
},
|
||||
];
|
||||
};
|
||||
@@ -0,0 +1,241 @@
|
||||
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>
|
||||
);
|
||||
},
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,117 @@
|
||||
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;
|
||||
132
frontend/src/container/InfraMonitoringK8sV2/Deployments/api.ts
Normal file
132
frontend/src/container/InfraMonitoringK8sV2/Deployments/api.ts
Normal file
@@ -0,0 +1,132 @@
|
||||
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);
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,608 @@
|
||||
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,
|
||||
},
|
||||
];
|
||||
};
|
||||
@@ -0,0 +1,246 @@
|
||||
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>
|
||||
);
|
||||
},
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,32 @@
|
||||
.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);
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
.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;
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
.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;
|
||||
}
|
||||
@@ -0,0 +1,334 @@
|
||||
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;
|
||||
@@ -0,0 +1,74 @@
|
||||
.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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
.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;
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
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,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,122 @@
|
||||
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,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
import Events from './EntityEvents';
|
||||
|
||||
export default Events;
|
||||
@@ -0,0 +1,130 @@
|
||||
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,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
.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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,309 @@
|
||||
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;
|
||||
@@ -0,0 +1,185 @@
|
||||
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,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,186 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,126 @@
|
||||
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,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
import EntityLogs from './EntityLogs';
|
||||
|
||||
export default EntityLogs;
|
||||
@@ -0,0 +1,108 @@
|
||||
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,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
.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%);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,220 @@
|
||||
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;
|
||||
@@ -0,0 +1,7 @@
|
||||
.table {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.paginationContainer {
|
||||
margin: 0;
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,456 @@
|
||||
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,
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,122 @@
|
||||
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;
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
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,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
import EntityMetrics from './EntityMetrics';
|
||||
|
||||
export default EntityMetrics;
|
||||
@@ -0,0 +1,61 @@
|
||||
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: [] }];
|
||||
};
|
||||
@@ -0,0 +1,49 @@
|
||||
.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;
|
||||
}
|
||||
@@ -0,0 +1,267 @@
|
||||
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;
|
||||
@@ -0,0 +1,98 @@
|
||||
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');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,38 @@
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,44 @@
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,55 @@
|
||||
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');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,39 @@
|
||||
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 },
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,96 @@
|
||||
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,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,185 @@
|
||||
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',
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,121 @@
|
||||
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>
|
||||
),
|
||||
}));
|
||||
@@ -0,0 +1,115 @@
|
||||
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,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
import EntityTraces from './EntityTraces';
|
||||
|
||||
export default EntityTraces;
|
||||
@@ -0,0 +1,7 @@
|
||||
.pointer {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.cellText {
|
||||
color: var(--l2-foreground);
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
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;
|
||||
};
|
||||
@@ -0,0 +1,104 @@
|
||||
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,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,456 @@
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,282 @@
|
||||
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',
|
||||
);
|
||||
@@ -0,0 +1,234 @@
|
||||
.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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,344 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
117
frontend/src/container/InfraMonitoringK8sV2/Jobs/K8sJobsList.tsx
Normal file
117
frontend/src/container/InfraMonitoringK8sV2/Jobs/K8sJobsList.tsx
Normal file
@@ -0,0 +1,117 @@
|
||||
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;
|
||||
119
frontend/src/container/InfraMonitoringK8sV2/Jobs/api.ts
Normal file
119
frontend/src/container/InfraMonitoringK8sV2/Jobs/api.ts
Normal file
@@ -0,0 +1,119 @@
|
||||
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);
|
||||
}
|
||||
};
|
||||
472
frontend/src/container/InfraMonitoringK8sV2/Jobs/constants.ts
Normal file
472
frontend/src/container/InfraMonitoringK8sV2/Jobs/constants.ts
Normal file
@@ -0,0 +1,472 @@
|
||||
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,
|
||||
},
|
||||
];
|
||||
};
|
||||
@@ -0,0 +1,273 @@
|
||||
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>
|
||||
);
|
||||
},
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,11 @@
|
||||
.loadingState {
|
||||
padding: 8px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.loadingStateItem {
|
||||
height: 48px;
|
||||
width: 100%;
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
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;
|
||||
@@ -0,0 +1,117 @@
|
||||
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;
|
||||
123
frontend/src/container/InfraMonitoringK8sV2/Namespaces/api.ts
Normal file
123
frontend/src/container/InfraMonitoringK8sV2/Namespaces/api.ts
Normal file
@@ -0,0 +1,123 @@
|
||||
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);
|
||||
}
|
||||
};
|
||||
1656
frontend/src/container/InfraMonitoringK8sV2/Namespaces/constants.ts
Normal file
1656
frontend/src/container/InfraMonitoringK8sV2/Namespaces/constants.ts
Normal file
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user